Merge remote-tracking branch 'origin/next' into fix/1477-surface-source-marker
# Conflicts: # tests/golden-install-parity.test.cjs
This commit is contained in:
@@ -1,304 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Integration tests for `phase uat-passed <N>` CLI command.
|
||||
* Issue #247 — phase uat-passed predicate
|
||||
*
|
||||
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set up a minimal project with a phase directory and ROADMAP so that
|
||||
* findPhaseInternal(cwd, phaseNum) can resolve it.
|
||||
* Returns { tmpDir, phaseDir }.
|
||||
*/
|
||||
function setupProject(phaseSlug = '01-feature') {
|
||||
const tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 1: Feature',
|
||||
'',
|
||||
'### Phase 1: Feature',
|
||||
'**Goal:** Build feature',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
return { tmpDir, phaseDir };
|
||||
}
|
||||
|
||||
function writeUatFile(phaseDir, filename, content) {
|
||||
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
|
||||
}
|
||||
|
||||
function setMtime(filePath, time) {
|
||||
fs.utimesSync(filePath, time, time);
|
||||
}
|
||||
|
||||
function makePassingUat() {
|
||||
return [
|
||||
'---',
|
||||
'status: passed',
|
||||
'---',
|
||||
'',
|
||||
'# UAT Results',
|
||||
'',
|
||||
'### 1. Login works',
|
||||
'expected: User logs in successfully',
|
||||
'result: passed',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function makePendingUat() {
|
||||
return [
|
||||
'---',
|
||||
'status: partial',
|
||||
'---',
|
||||
'',
|
||||
'# UAT Results',
|
||||
'',
|
||||
'### 1. Login works',
|
||||
'expected: User logs in successfully',
|
||||
'result: passed',
|
||||
'',
|
||||
'### 2. Logout works',
|
||||
'expected: User logs out successfully',
|
||||
'result: pending',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function makeFencedFalsePositiveUat() {
|
||||
// Only "result: passed" lines are inside a fenced block.
|
||||
// The real test has result: pending → should evaluate to passed:false.
|
||||
return [
|
||||
'---',
|
||||
'status: partial',
|
||||
'---',
|
||||
'',
|
||||
'# UAT Results',
|
||||
'',
|
||||
'## Example (do not run)',
|
||||
'```',
|
||||
'### 1. Test',
|
||||
'expected: Example',
|
||||
'result: passed',
|
||||
'```',
|
||||
'',
|
||||
'### 1. Real Test',
|
||||
'expected: The thing works',
|
||||
'result: pending',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
|
||||
|
||||
describe('phase uat-passed — basic pass/fail', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('passing UAT → passed:true with correct JSON shape', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, true);
|
||||
assert.strictEqual(out.phase, '1');
|
||||
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
|
||||
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
|
||||
assert.ok(Array.isArray(out.checks), 'checks must be an array');
|
||||
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
|
||||
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
|
||||
'policy.require_verification must be a boolean');
|
||||
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
|
||||
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
|
||||
assert.strictEqual(out.blockers.length, 0);
|
||||
});
|
||||
|
||||
test('pending UAT → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
|
||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false);
|
||||
assert.strictEqual(out.phase, '1');
|
||||
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
|
||||
});
|
||||
|
||||
test('false-positive only (fenced block) → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
|
||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false,
|
||||
'result:passed inside a fenced block must not flip the predicate to passed');
|
||||
});
|
||||
|
||||
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
|
||||
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
|
||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false,
|
||||
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
|
||||
assert.strictEqual(out.no_uat_artifacts, true,
|
||||
'no_uat_artifacts must be true when no UAT items found');
|
||||
assert.deepStrictEqual(out.uat_files, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── --require-verification flag ──────────────────────────────────────────────
|
||||
|
||||
describe('phase uat-passed — --require-verification flag', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('--require-verification with no verification file → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false,
|
||||
'require-verification with no verification file should fail');
|
||||
assert.strictEqual(out.policy.require_verification, true);
|
||||
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
||||
});
|
||||
|
||||
test('--require-verification with passing verification → passed:true', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, true);
|
||||
assert.strictEqual(out.policy.require_verification, true);
|
||||
});
|
||||
|
||||
test('--require-verification with stale passed verification → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
|
||||
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
|
||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
||||
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
|
||||
const now = new Date();
|
||||
setMtime(verificationPath, new Date(now.getTime() - 60_000));
|
||||
setMtime(summaryPath, now);
|
||||
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false);
|
||||
assert.ok(
|
||||
out.blockers.some(b => /verification status=stale/i.test(b)),
|
||||
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--require-verification with non-canonical complete verification → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false);
|
||||
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('phase uat-passed — error cases', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
// Write a minimal ROADMAP so phase 1 exists
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Feature',
|
||||
'**Goal:** Build feature',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('missing phase number → error message', () => {
|
||||
const result = runGsdTools('phase uat-passed', tmpDir);
|
||||
assert.ok(!result.success, 'Should fail with no phase number');
|
||||
assert.ok(
|
||||
result.error.includes('phase number required') ||
|
||||
result.error.includes('Available:'),
|
||||
`Expected phase-number-required error, got: ${result.error}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown phase number → error message', () => {
|
||||
const result = runGsdTools('phase uat-passed 99', tmpDir);
|
||||
assert.ok(!result.success, 'Should fail for unknown phase');
|
||||
assert.ok(
|
||||
result.error.includes('not found') || result.error.includes('99'),
|
||||
`Expected not-found error, got: ${result.error}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
|
||||
assert.ok(!result.success,
|
||||
'Unknown flag must cause an error, not silently pass');
|
||||
assert.ok(
|
||||
result.error.includes('--require-verifcation') ||
|
||||
result.error.includes('does not support') ||
|
||||
result.error.includes('invalid'),
|
||||
`Expected unknown-flag error, got: ${result.error}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #26 (open-gsd/gsd-core).
|
||||
* Three generator-pattern drift items: W005 phaseDirNameRe,
|
||||
* W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE),
|
||||
* I001 canonicalPlanStem.
|
||||
*
|
||||
* After the generator migration, all three helpers are sourced from
|
||||
* validate.cjs. If they diverge from validate.ts, these
|
||||
* tests go RED.
|
||||
*
|
||||
* References:
|
||||
* - Issue #26 (open-gsd/gsd-core) — three drift items + reproducer
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - PR #154 (issue #4) — generator pattern precedent
|
||||
* - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this)
|
||||
* - Original PR #3479 — first validate.ts false-positive fix (never reached CJS)
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function mkplanning(base) {
|
||||
const planningDir = path.join(base, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
return { planningDir, phasesDir };
|
||||
}
|
||||
|
||||
function writeProjectMd(planningDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||||
);
|
||||
}
|
||||
|
||||
function writeStateMd(planningDir, phase) {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeConfigJson(planningDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced' }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Drift Item W005: phaseDirNameRe ──────────────────────────────────────────
|
||||
//
|
||||
// Issue #26 reproducer (verbatim):
|
||||
// mkdir -p .planning/phases/999.1-foo
|
||||
// echo "# Roadmap" > .planning/ROADMAP.md
|
||||
// node .claude/gsd-core/bin/gsd-tools.cjs validate health
|
||||
// # Bug: emits W005 about 999.1-foo not following NN-name format
|
||||
//
|
||||
// verify.cjs must consume phaseDirNameRe from validate.cjs so
|
||||
// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth.
|
||||
|
||||
describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir, '999.1');
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n',
|
||||
);
|
||||
|
||||
// Exact reproducer from issue #26
|
||||
fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true });
|
||||
});
|
||||
|
||||
after(() => { cleanup(tmpDir); });
|
||||
|
||||
test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005');
|
||||
assert.strictEqual(w005.length, 0,
|
||||
`Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`);
|
||||
});
|
||||
|
||||
test('phaseDirNameRe is exported from validate.cjs', () => {
|
||||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||||
assert.ok(gen.phaseDirNameRe instanceof RegExp,
|
||||
'validate.cjs must export phaseDirNameRe as a RegExp');
|
||||
const re = gen.phaseDirNameRe;
|
||||
assert.ok(re.test('01-setup'), 'should accept 01-setup');
|
||||
assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)');
|
||||
assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)');
|
||||
assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes');
|
||||
assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes');
|
||||
assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes');
|
||||
assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─
|
||||
//
|
||||
// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants.
|
||||
// After migration both are sourced from validate.cjs:
|
||||
// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service"
|
||||
// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases"
|
||||
//
|
||||
// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/.
|
||||
// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md
|
||||
// but no directory on disk".
|
||||
|
||||
describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir, '65');
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [x] **Phase 65:** Current Work',
|
||||
'',
|
||||
'### Phase 65: Current Work',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Milestone v1.0 — Shipped</summary>',
|
||||
'',
|
||||
'### Phase 64: Auth Service',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Active phasesDir: only phase 65
|
||||
fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true });
|
||||
|
||||
// Archive: milestones/v1.0-phases/64-auth-service
|
||||
// MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases"
|
||||
// PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service"
|
||||
fs.mkdirSync(
|
||||
path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
after(() => { cleanup(tmpDir); });
|
||||
|
||||
test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w006 = (data.warnings ?? []).filter(
|
||||
(w) => w.code === 'W006' && /Phase 64/i.test(w.message),
|
||||
);
|
||||
assert.strictEqual(w006.length, 0,
|
||||
`Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`);
|
||||
});
|
||||
|
||||
test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => {
|
||||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||||
assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp,
|
||||
'validate.cjs must export MILESTONE_ARCHIVE_DIR_RE');
|
||||
const re = gen.MILESTONE_ARCHIVE_DIR_RE;
|
||||
assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases');
|
||||
assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases');
|
||||
assert.ok(!re.test('phases'), 'should NOT match plain phases');
|
||||
assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix');
|
||||
});
|
||||
|
||||
test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => {
|
||||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||||
assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp,
|
||||
'validate.cjs must export PHASE_TOKEN_FROM_DIR_RE');
|
||||
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
|
||||
assert.strictEqual(re.exec('64-auth-service')?.[1], '64');
|
||||
assert.strictEqual(re.exec('03B-feature')?.[1], '03B');
|
||||
assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1');
|
||||
assert.strictEqual(re.exec('CK-64-auth')?.[1], '64');
|
||||
assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], '64');
|
||||
assert.strictEqual(re.exec('APP1-64-auth')?.[1], '64');
|
||||
assert.strictEqual(re.exec('APP_1-64-auth')?.[1], '64');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drift Item I001: canonicalPlanStem ────────────────────────────────────────
|
||||
//
|
||||
// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01'
|
||||
// verify.cjs had an inline copy. After migration, canonicalPlanStem is
|
||||
// sourced from validate.cjs.
|
||||
//
|
||||
// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001
|
||||
// Both stems canonicalize to "68-01" → match found → I001 suppressed.
|
||||
|
||||
describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir, '68');
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n',
|
||||
);
|
||||
|
||||
const phaseDir = path.join(phasesDir, '68-scaffolding');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
// Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem
|
||||
fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n');
|
||||
});
|
||||
|
||||
after(() => { cleanup(tmpDir); });
|
||||
|
||||
test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const i001 = (data.info ?? []).filter((i) => i.code === 'I001');
|
||||
assert.strictEqual(i001.length, 0,
|
||||
`Expected zero I001, got: ${JSON.stringify(i001)}`);
|
||||
});
|
||||
|
||||
test('canonicalPlanStem is exported from validate.cjs', () => {
|
||||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||||
assert.strictEqual(typeof gen.canonicalPlanStem, 'function',
|
||||
'validate.cjs must export canonicalPlanStem as a function');
|
||||
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
|
||||
assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01');
|
||||
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
|
||||
assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression / migration-gate test for #551 and ADR-457 (TS migration).
|
||||
*
|
||||
* ESLint must apply the correct policy to every gsd-core/bin/lib/*.cjs
|
||||
* file as modules migrate from hand-written CJS to tsc-generated artifacts:
|
||||
*
|
||||
* - tsc-generated artifact (has src/<basename>.cts counterpart) → MUST be
|
||||
* eslint-ignored. We lint the *.cts source instead (ADR-457).
|
||||
* - Genuinely hand-written (no src/*.cts counterpart) → MUST be linted
|
||||
* (NOT ignored). Includes scripts-generated package-identity.cjs which
|
||||
* has no *.cts source.
|
||||
*
|
||||
* The test is filesystem-driven — it scans bin/lib at runtime and checks each
|
||||
* file against the src/ directory, so it stays correct automatically as more
|
||||
* modules migrate. No hardcoded lists.
|
||||
*
|
||||
* ESLint behaviour is verified via ESLint's own `isPathIgnored()` API so the
|
||||
* test reflects real resolved flat-config precedence, not a textual scan of
|
||||
* eslint.config.mjs.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { ESLint } = require('eslint');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LIB_DIR = path.join(ROOT, 'gsd-core', 'bin', 'lib');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
|
||||
/**
|
||||
* Returns true if the given bin/lib/*.cjs file has a corresponding
|
||||
* src/<basename>.cts TypeScript source (meaning it is tsc-generated).
|
||||
*/
|
||||
function hasTsSource(absPath) {
|
||||
const base = path.basename(absPath, '.cjs');
|
||||
return (
|
||||
fs.existsSync(path.join(SRC_DIR, `${base}.cts`)) ||
|
||||
fs.existsSync(path.join(SRC_DIR, `${base}.ts`))
|
||||
);
|
||||
}
|
||||
|
||||
let eslint;
|
||||
before(() => {
|
||||
eslint = new ESLint({ cwd: ROOT });
|
||||
});
|
||||
|
||||
describe('ESLint coverage tracks the bin/lib TS migration (ADR-457 / #537)', () => {
|
||||
/**
|
||||
* Main invariant: scan every *.cjs in bin/lib and assert the correct ESLint
|
||||
* policy is applied.
|
||||
*/
|
||||
test('each bin/lib/*.cjs is linted xor ignored according to migration state', async () => {
|
||||
const wronglyIgnored = []; // hand-written but ignored — should be linted
|
||||
const wronglyLinted = []; // tsc-generated but not ignored — should be ignored
|
||||
|
||||
const entries = fs.readdirSync(LIB_DIR).filter((e) => e.endsWith('.cjs'));
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(LIB_DIR, entry);
|
||||
const generated = hasTsSource(abs);
|
||||
const ignored = await eslint.isPathIgnored(abs);
|
||||
|
||||
if (generated && !ignored) {
|
||||
wronglyLinted.push(entry);
|
||||
} else if (!generated && ignored) {
|
||||
wronglyIgnored.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
wronglyLinted,
|
||||
[],
|
||||
`tsc-generated bin/lib modules not yet added to ESLint ignore list: ${wronglyLinted.join(', ')}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
wronglyIgnored,
|
||||
[],
|
||||
`Hand-written bin/lib modules silently excluded from ESLint: ${wronglyIgnored.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('semver-compare.cjs (tsc-generated publish artifact) stays eslint-ignored (ADR-457)', async () => {
|
||||
const f = path.join(LIB_DIR, 'semver-compare.cjs');
|
||||
assert.equal(
|
||||
await eslint.isPathIgnored(f),
|
||||
true,
|
||||
'semver-compare.cjs is a tsc-generated publish-time artifact and must stay ignored',
|
||||
);
|
||||
});
|
||||
|
||||
test('package-identity.cjs (script-generated, no *.cts source) is linted, not ignored (#551)', async () => {
|
||||
const f = path.join(LIB_DIR, 'package-identity.cjs');
|
||||
assert.equal(
|
||||
await eslint.isPathIgnored(f),
|
||||
false,
|
||||
'package-identity.cjs has no src/*.cts counterpart and must be linted, not ignored',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #6 (open-gsd/gsd-core):
|
||||
* Three validation behaviors present in validate.ts are missing from verify.cjs,
|
||||
* producing silent false negatives on the CJS production path.
|
||||
*
|
||||
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
|
||||
*
|
||||
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
|
||||
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
|
||||
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
|
||||
*
|
||||
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
|
||||
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
|
||||
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
|
||||
* letter-suffix phases with padding mismatch between ROADMAP and disk.
|
||||
*
|
||||
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
|
||||
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
|
||||
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #6 (open-gsd/gsd-core)
|
||||
* - PR #154 (issue #4) — precedent for the generator pattern
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function mkplanning(base) {
|
||||
const planningDir = path.join(base, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
return { planningDir, phasesDir };
|
||||
}
|
||||
|
||||
function writeProjectMd(planningDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||||
);
|
||||
}
|
||||
|
||||
function writeStateMd(planningDir, phase = '2') {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeConfigJson(planningDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced' }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
|
||||
//
|
||||
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
|
||||
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
|
||||
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
|
||||
//
|
||||
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
|
||||
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
|
||||
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
|
||||
//
|
||||
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
|
||||
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
|
||||
// in roadmapPhases → W007 fires for "1". False positive.
|
||||
|
||||
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir);
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [x] **Phase 2:** API',
|
||||
'',
|
||||
'### Phase 2: API',
|
||||
'',
|
||||
'## Progress',
|
||||
'| Phase | Status |',
|
||||
'|-------|--------|',
|
||||
'| 2 | Complete |',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Active phasesDir: only phase 2
|
||||
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
|
||||
|
||||
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
|
||||
fs.mkdirSync(
|
||||
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
|
||||
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
|
||||
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
|
||||
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
|
||||
fs.mkdirSync(
|
||||
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
|
||||
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
|
||||
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
|
||||
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
|
||||
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
|
||||
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
|
||||
// in roadmapPhases → W007 fires for "1". False positive.
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
|
||||
const w007Phase1 = w007.filter(
|
||||
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
|
||||
);
|
||||
assert.strictEqual(
|
||||
w007Phase1.length,
|
||||
0,
|
||||
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
|
||||
//
|
||||
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
|
||||
// Disk has directory "1A-foo" (unpadded letter-suffix form).
|
||||
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
|
||||
//
|
||||
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
|
||||
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
|
||||
// has "01A" → match → no W007.
|
||||
//
|
||||
// verify.cjs pre-fix:
|
||||
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
|
||||
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
|
||||
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
|
||||
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
|
||||
|
||||
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir);
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
// ROADMAP: Phase 01A (zero-padded + letter suffix)
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [x] **Phase 01A:** Suffix Phase',
|
||||
'',
|
||||
'### Phase 01A: Suffix Phase',
|
||||
'',
|
||||
'## Progress',
|
||||
'| Phase | Status |',
|
||||
'|-------|--------|',
|
||||
'| 01A | Complete |',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Disk: unpadded form "1A-foo"
|
||||
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||||
assert.strictEqual(
|
||||
w006.length,
|
||||
0,
|
||||
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||
assert.strictEqual(
|
||||
w007.length,
|
||||
0,
|
||||
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
|
||||
//
|
||||
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
|
||||
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
|
||||
//
|
||||
// validate.ts:
|
||||
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
|
||||
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
|
||||
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
|
||||
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
|
||||
//
|
||||
// verify.cjs pre-fix:
|
||||
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
|
||||
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
|
||||
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
|
||||
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
|
||||
|
||||
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
|
||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||
writeProjectMd(planningDir);
|
||||
writeStateMd(planningDir, '3B');
|
||||
writeConfigJson(planningDir);
|
||||
|
||||
// ROADMAP: Phase 3B (unpadded in heading)
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [x] **Phase 3B:** Feature Extension',
|
||||
'',
|
||||
'### Phase 3B: Feature Extension',
|
||||
'',
|
||||
'## Progress',
|
||||
'| Phase | Status |',
|
||||
'|-------|--------|',
|
||||
'| 3B | Complete |',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Disk: "03B-feature" (zero-padded letter-suffix form)
|
||||
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||||
assert.strictEqual(
|
||||
w006.length,
|
||||
0,
|
||||
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
|
||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||
assert.strictEqual(
|
||||
w007.length,
|
||||
0,
|
||||
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
102
tests/adapter-declarative-equivalence.test.cjs
Normal file
102
tests/adapter-declarative-equivalence.test.cjs
Normal file
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Equivalence test for the declarative embedding adapter (ADR-1239 Phase C-1,
|
||||
* AC1 / #1680).
|
||||
*
|
||||
* The declarative adapter NAMES + BOUNDS today's projection path behind
|
||||
* `HostIntegrationInterface`. This test pins the three load-bearing properties:
|
||||
*
|
||||
* 1. KIND — every runtime's adapter classifies as `kind: 'declarative'` and
|
||||
* echoes its runtime id.
|
||||
* 2. DELEGATION (the byte-identity link) — `install`/`uninstall` delegate
|
||||
* IN-PROCESS to install-engine's `installRuntimeArtifacts` /
|
||||
* `uninstallRuntimeArtifacts` with the EXACT args passed through. Because
|
||||
* the adapter calls the SAME engine functions `bin/install.js` uses, its
|
||||
* output is byte-identical to today's install — the 16-runtime byte
|
||||
* identity is gated by `tests/golden-install-parity.test.cjs` (which
|
||||
* exercises these engine functions end-to-end). Asserting the delegation
|
||||
* link here proves the adapter never diverges from the reference path.
|
||||
* 3. FAIL-CLOSED CONSTRUCTION — missing/invalid runtime throws (no silent
|
||||
* default adapter).
|
||||
*
|
||||
* Behavioral tests only; delegation verified via module-ref monkeypatch (the
|
||||
* install-engine.cts:31-38 stub-compatible pattern — Node module cache shares
|
||||
* the one module object, so patching it here is seen by the adapter).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
|
||||
const installEngine = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const RUNTIMES = Object.keys(registry.runtimes);
|
||||
|
||||
test('declarative adapter: kind === "declarative" + runtime echoed, for all 16 runtimes', () => {
|
||||
assert.ok(RUNTIMES.length >= 16, `expected ≥16 runtimes in registry, got ${RUNTIMES.length}`);
|
||||
for (const r of RUNTIMES) {
|
||||
const adapter = createDeclarativeAdapter({ runtime: r });
|
||||
assert.strictEqual(adapter.kind, 'declarative', `${r}: kind must be 'declarative'`);
|
||||
assert.strictEqual(adapter.runtime, r, `${r}: adapter must echo the runtime id`);
|
||||
assert.strictEqual(typeof adapter.install, 'function', `${r}: install must be a function`);
|
||||
assert.strictEqual(typeof adapter.uninstall, 'function', `${r}: uninstall must be a function`);
|
||||
}
|
||||
});
|
||||
|
||||
test('declarative adapter.install delegates in-process to installRuntimeArtifacts with exact args (byte-identity link)', () => {
|
||||
const original = installEngine.installRuntimeArtifacts;
|
||||
try {
|
||||
for (const r of RUNTIMES) {
|
||||
const adapter = createDeclarativeAdapter({ runtime: r });
|
||||
let captured = null;
|
||||
installEngine.installRuntimeArtifacts = function (...args) { captured = args; return undefined; };
|
||||
const resolveAttribution = () => 'attr-' + r;
|
||||
const intent = {
|
||||
configDir: '/tmp/adapter-eq/' + r,
|
||||
scope: 'global',
|
||||
resolvedProfile: { profile: r },
|
||||
resolveAttribution,
|
||||
};
|
||||
assert.doesNotThrow(() => adapter.install(intent), `${r}: install threw`);
|
||||
assert.ok(captured, `${r}: install did not delegate to installRuntimeArtifacts`);
|
||||
assert.deepStrictEqual(
|
||||
captured,
|
||||
[r, '/tmp/adapter-eq/' + r, 'global', { profile: r }, resolveAttribution],
|
||||
`${r}: install delegation args diverged from the engine signature`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
installEngine.installRuntimeArtifacts = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('declarative adapter.uninstall delegates in-process to uninstallRuntimeArtifacts with exact args', () => {
|
||||
const original = installEngine.uninstallRuntimeArtifacts;
|
||||
try {
|
||||
for (const r of RUNTIMES) {
|
||||
const adapter = createDeclarativeAdapter({ runtime: r });
|
||||
let captured = null;
|
||||
installEngine.uninstallRuntimeArtifacts = function (...args) { captured = args; return undefined; };
|
||||
assert.doesNotThrow(() => adapter.uninstall({ configDir: '/tmp/adapter-eq/' + r, scope: 'local' }), `${r}: uninstall threw`);
|
||||
assert.ok(captured, `${r}: uninstall did not delegate to uninstallRuntimeArtifacts`);
|
||||
assert.deepStrictEqual(
|
||||
captured,
|
||||
[r, '/tmp/adapter-eq/' + r, 'local'],
|
||||
`${r}: uninstall delegation args diverged from the engine signature`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
installEngine.uninstallRuntimeArtifacts = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('createDeclarativeAdapter: throws on missing/invalid runtime (fail-closed construction)', () => {
|
||||
for (const bad of ['', undefined, null]) {
|
||||
assert.throws(
|
||||
() => createDeclarativeAdapter({ runtime: bad }),
|
||||
TypeError,
|
||||
`runtime=${JSON.stringify(bad)} must throw TypeError`,
|
||||
);
|
||||
}
|
||||
assert.throws(() => createDeclarativeAdapter({}), TypeError, 'missing runtime must throw TypeError');
|
||||
});
|
||||
98
tests/adapter-imperative.test.cjs
Normal file
98
tests/adapter-imperative.test.cjs
Normal file
@@ -0,0 +1,98 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for the imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680).
|
||||
*
|
||||
* Pins:
|
||||
* 1. KIND — `kind: 'imperative'`, satisfies the same HostIntegrationInterface
|
||||
* as the declarative adapter (both bind one engine).
|
||||
* 2. REGISTRY COMPOSITION — the adapter composes loadRegistry({includeInstalled:
|
||||
* true}) and exposes the result as `.registry` (first-party ∪ installed, so
|
||||
* an in-process host gets identical trust semantics to the CLI).
|
||||
* 3. DELEGATION — install/uninstall delegate in-process to install-engine
|
||||
* (byte-identity link, same as the declarative adapter).
|
||||
* 4. FAIL-CLOSED CONSTRUCTION — missing/invalid runtime throws.
|
||||
*
|
||||
* Behavioral tests only; delegation + loadRegistry verified via module-ref
|
||||
* monkeypatch (Node module cache shares the one module object).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const installEngine = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const capabilityLoader = require('../gsd-core/bin/lib/capability-loader.cjs');
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const RUNTIMES = Object.keys(registry.runtimes);
|
||||
|
||||
test('imperative adapter: kind === "imperative" + runtime echoed + registry present, for all 16 runtimes', () => {
|
||||
for (const r of RUNTIMES) {
|
||||
const adapter = createImperativeAdapter({ runtime: r });
|
||||
assert.strictEqual(adapter.kind, 'imperative', `${r}: kind must be 'imperative'`);
|
||||
assert.strictEqual(adapter.runtime, r, `${r}: adapter must echo the runtime id`);
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object', `${r}: registry must be present (composed loadRegistry result)`);
|
||||
assert.strictEqual(typeof adapter.install, 'function', `${r}: install must be a function`);
|
||||
assert.strictEqual(typeof adapter.uninstall, 'function', `${r}: uninstall must be a function`);
|
||||
}
|
||||
});
|
||||
|
||||
test('imperative adapter: loadRegistry composed with includeInstalled:true (host gets CLI-equivalent trust semantics)', () => {
|
||||
// Restore-able spy on capabilityLoader.loadRegistry (module-ref, shared via Node cache).
|
||||
const original = capabilityLoader.loadRegistry;
|
||||
const calls = [];
|
||||
capabilityLoader.loadRegistry = function (opts) {
|
||||
calls.push(opts);
|
||||
// Return a minimal stand-in registry so the factory does not crash.
|
||||
return { _spy: true, runtimes: registry.runtimes };
|
||||
};
|
||||
try {
|
||||
const adapter = createImperativeAdapter({ runtime: 'opencode' });
|
||||
assert.ok(calls.length >= 1, 'loadRegistry must be invoked once during construction');
|
||||
assert.strictEqual(calls[0].includeInstalled, true, 'loadRegistry must be called with includeInstalled:true (compose first-party ∪ installed)');
|
||||
assert.strictEqual(adapter.registry._spy, true, 'adapter.registry must expose the composed loadRegistry result');
|
||||
} finally {
|
||||
capabilityLoader.loadRegistry = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('imperative adapter: loadOptions forwarded to loadRegistry (cwd/gsdHome/hostVersion pass-through)', () => {
|
||||
const original = capabilityLoader.loadRegistry;
|
||||
let captured = null;
|
||||
capabilityLoader.loadRegistry = function (opts) { captured = opts; return { runtimes: registry.runtimes }; };
|
||||
try {
|
||||
createImperativeAdapter({ runtime: 'codex' }, { loadOptions: { cwd: '/tmp/proj', hostVersion: '1.7.0' } });
|
||||
assert.strictEqual(captured.includeInstalled, true, 'includeInstalled default preserved');
|
||||
assert.strictEqual(captured.cwd, '/tmp/proj', 'loadOptions.cwd forwarded');
|
||||
assert.strictEqual(captured.hostVersion, '1.7.0', 'loadOptions.hostVersion forwarded');
|
||||
} finally {
|
||||
capabilityLoader.loadRegistry = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('imperative adapter.install/uninstall delegate in-process to install-engine with exact args', () => {
|
||||
const origInstall = installEngine.installRuntimeArtifacts;
|
||||
const origUninstall = installEngine.uninstallRuntimeArtifacts;
|
||||
try {
|
||||
for (const r of RUNTIMES) {
|
||||
const adapter = createImperativeAdapter({ runtime: r });
|
||||
let installArgs = null;
|
||||
let uninstallArgs = null;
|
||||
installEngine.installRuntimeArtifacts = function (...a) { installArgs = a; return undefined; };
|
||||
installEngine.uninstallRuntimeArtifacts = function (...a) { uninstallArgs = a; return undefined; };
|
||||
adapter.install({ configDir: '/tmp/imp/' + r, scope: 'global', resolvedProfile: { p: 1 } });
|
||||
adapter.uninstall({ configDir: '/tmp/imp/' + r, scope: 'local' });
|
||||
assert.deepStrictEqual(installArgs, [r, '/tmp/imp/' + r, 'global', { p: 1 }, undefined], `${r}: install delegation args`);
|
||||
assert.deepStrictEqual(uninstallArgs, [r, '/tmp/imp/' + r, 'local'], `${r}: uninstall delegation args`);
|
||||
}
|
||||
} finally {
|
||||
installEngine.installRuntimeArtifacts = origInstall;
|
||||
installEngine.uninstallRuntimeArtifacts = origUninstall;
|
||||
}
|
||||
});
|
||||
|
||||
test('createImperativeAdapter: throws on missing/invalid runtime (fail-closed construction)', () => {
|
||||
for (const bad of ['', undefined, null]) {
|
||||
assert.throws(() => createImperativeAdapter({ runtime: bad }), TypeError, `runtime=${JSON.stringify(bad)} must throw`);
|
||||
}
|
||||
assert.throws(() => createImperativeAdapter({}), TypeError, 'missing runtime must throw');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,3 +82,128 @@ describe('READING: agents with reading blocks use <required_reading>', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-patterns-reference.test.cjs — consolidation epic #1969 (B8 #1977)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-patterns-reference (consolidation epic #1969 B8 #1977)", () => {
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Common Bug Patterns Reference Tests
|
||||
*
|
||||
* Structural tests for the common-bug-patterns.md reference file:
|
||||
* - File exists at expected path
|
||||
* - Contains expected bug pattern categories (at least 5 of 10)
|
||||
* - Debugger agent references the file in required_reading
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REFERENCE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'references', 'common-bug-patterns.md'
|
||||
);
|
||||
const DEBUGGER_AGENT_PATH = path.join(
|
||||
__dirname, '..', 'agents', 'gsd-debugger.md'
|
||||
);
|
||||
|
||||
const EXPECTED_CATEGORIES = [
|
||||
'Off-by-One',
|
||||
'Null',
|
||||
'Async',
|
||||
'State Management',
|
||||
'Import',
|
||||
'Environment',
|
||||
'Data Shape',
|
||||
'String Handling',
|
||||
'File System',
|
||||
'Error Handling',
|
||||
];
|
||||
|
||||
describe('common-bug-patterns.md reference', () => {
|
||||
test('reference file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(REFERENCE_PATH),
|
||||
`Expected reference file at ${REFERENCE_PATH}`
|
||||
);
|
||||
});
|
||||
|
||||
test('has title and intro', () => {
|
||||
const content = fs.readFileSync(REFERENCE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.startsWith('# Common Bug Patterns'),
|
||||
'File should start with "# Common Bug Patterns" title'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('---'),
|
||||
'File should contain --- separator after intro'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains at least 5 of 10 expected categories', () => {
|
||||
const content = fs.readFileSync(REFERENCE_PATH, 'utf-8');
|
||||
const found = EXPECTED_CATEGORIES.filter(cat =>
|
||||
content.toLowerCase().includes(cat.toLowerCase())
|
||||
);
|
||||
assert.ok(
|
||||
found.length >= 5,
|
||||
`Expected at least 5 categories, found ${found.length}: ${found.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('each pattern category has at least one bold bullet item', () => {
|
||||
const content = fs.readFileSync(REFERENCE_PATH, 'utf-8');
|
||||
// Only check sections inside <patterns> block, not <usage>
|
||||
const patternsBlock = (content.split('<patterns>')[1] || '').split('</patterns>')[0];
|
||||
const sections = patternsBlock.split(/^## /m).slice(1);
|
||||
assert.ok(sections.length >= 5, `Expected at least 5 pattern sections, got ${sections.length}`);
|
||||
for (const section of sections) {
|
||||
const title = section.split('\n')[0].trim();
|
||||
const bullets = section.match(/^- \*\*/gm);
|
||||
assert.ok(
|
||||
bullets && bullets.length >= 1,
|
||||
`Pattern section "${title}" should have at least one "- **" bullet item`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('debugger agent references bug patterns', () => {
|
||||
test('gsd-debugger.md exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(DEBUGGER_AGENT_PATH),
|
||||
`Expected debugger agent at ${DEBUGGER_AGENT_PATH}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-debugger.md references common-bug-patterns.md', () => {
|
||||
const content = fs.readFileSync(DEBUGGER_AGENT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('common-bug-patterns.md'),
|
||||
'Debugger agent should reference common-bug-patterns.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('reference is inside <required_reading> block', () => {
|
||||
const content = fs.readFileSync(DEBUGGER_AGENT_PATH, 'utf-8');
|
||||
const reqReadMatch = content.match(
|
||||
/<required_reading>([\s\S]*?)<\/required_reading>/
|
||||
);
|
||||
assert.ok(reqReadMatch, 'Debugger agent should have a <required_reading> block');
|
||||
assert.ok(
|
||||
reqReadMatch[1].includes('common-bug-patterns.md'),
|
||||
'common-bug-patterns.md should be inside <required_reading> block'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"gsd-advisor-researcher.md": 4603,
|
||||
"gsd-ai-researcher.md": 5911,
|
||||
"gsd-assumptions-analyzer.md": 4556,
|
||||
"gsd-code-fixer.md": 36506,
|
||||
"gsd-code-reviewer.md": 16780,
|
||||
"gsd-codebase-mapper.md": 21395,
|
||||
"gsd-advisor-researcher.md": 4693,
|
||||
"gsd-ai-researcher.md": 5909,
|
||||
"gsd-assumptions-analyzer.md": 4646,
|
||||
"gsd-code-fixer.md": 36596,
|
||||
"gsd-code-reviewer.md": 16870,
|
||||
"gsd-codebase-mapper.md": 21485,
|
||||
"gsd-debug-session-manager.md": 14159,
|
||||
"gsd-debugger.md": 51220,
|
||||
"gsd-debugger.md": 51310,
|
||||
"gsd-doc-classifier.md": 7689,
|
||||
"gsd-doc-synthesizer.md": 9782,
|
||||
"gsd-doc-verifier.md": 12403,
|
||||
"gsd-doc-writer.md": 38834,
|
||||
"gsd-doc-writer.md": 38924,
|
||||
"gsd-domain-researcher.md": 6998,
|
||||
"gsd-eval-auditor.md": 12362,
|
||||
"gsd-eval-auditor.md": 12452,
|
||||
"gsd-eval-planner.md": 7008,
|
||||
"gsd-executor.md": 43343,
|
||||
"gsd-executor.md": 43534,
|
||||
"gsd-framework-selector.md": 6778,
|
||||
"gsd-integration-checker.md": 15148,
|
||||
"gsd-integration-checker.md": 15238,
|
||||
"gsd-intel-updater.md": 18122,
|
||||
"gsd-mempalace-curator.md": 4160,
|
||||
"gsd-nyquist-auditor.md": 7255,
|
||||
"gsd-nyquist-auditor.md": 7345,
|
||||
"gsd-pattern-mapper.md": 12487,
|
||||
"gsd-phase-researcher.md": 40698,
|
||||
"gsd-plan-checker.md": 44646,
|
||||
"gsd-planner.md": 48023,
|
||||
"gsd-project-researcher.md": 22074,
|
||||
"gsd-research-synthesizer.md": 13713,
|
||||
"gsd-roadmapper.md": 22183,
|
||||
"gsd-security-auditor.md": 8891,
|
||||
"gsd-ui-auditor.md": 17159,
|
||||
"gsd-ui-checker.md": 11088,
|
||||
"gsd-ui-researcher.md": 19332,
|
||||
"gsd-phase-researcher.md": 40788,
|
||||
"gsd-plan-checker.md": 44736,
|
||||
"gsd-planner.md": 48113,
|
||||
"gsd-project-researcher.md": 22164,
|
||||
"gsd-research-synthesizer.md": 13803,
|
||||
"gsd-roadmapper.md": 22273,
|
||||
"gsd-security-auditor.md": 8981,
|
||||
"gsd-ui-auditor.md": 17249,
|
||||
"gsd-ui-checker.md": 11178,
|
||||
"gsd-ui-researcher.md": 19422,
|
||||
"gsd-user-profiler.md": 8516,
|
||||
"gsd-verifier.md": 48859
|
||||
"gsd-verifier.md": 49117
|
||||
}
|
||||
|
||||
124
tests/agent-skills-bootstrap.test.cjs
Normal file
124
tests/agent-skills-bootstrap.test.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
// allow-test-rule: source-text-is-the-product see #1866
|
||||
// Agent .md files are the installed AI agents — the self-load instruction IS the deployed
|
||||
// behavior. Checking text content IS checking what runs in production. Same exemption basis
|
||||
// as tests/agent-skills-awareness.test.cjs (agent_skills is a config/state contract).
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
const REFS_DIR = path.join(__dirname, '..', 'gsd-core', 'references');
|
||||
const BOOTSTRAP_REF = 'agent-skills-bootstrap.md';
|
||||
const BOOTSTRAP_PATH = path.join(REFS_DIR, BOOTSTRAP_REF);
|
||||
|
||||
// The 22 agent_skills consumer agents. This MUST stay in sync with the
|
||||
// CONSUMER_AGENTS list in tests/agent-skills.test.cjs ("all 22 agent_skills
|
||||
// consumer agents"). The parity test below enforces the bijection between
|
||||
// this list and the set of agent files that carry the self-load bootstrap
|
||||
// (Generative-Fix-Divergence guard — CLAUDE.md "Generative Fix Divergence").
|
||||
const CONSUMER_AGENTS = [
|
||||
'gsd-advisor-researcher',
|
||||
'gsd-assumptions-analyzer',
|
||||
'gsd-code-fixer',
|
||||
'gsd-code-reviewer',
|
||||
'gsd-codebase-mapper',
|
||||
'gsd-debugger',
|
||||
'gsd-doc-writer',
|
||||
'gsd-eval-auditor',
|
||||
'gsd-executor',
|
||||
'gsd-integration-checker',
|
||||
'gsd-nyquist-auditor',
|
||||
'gsd-phase-researcher',
|
||||
'gsd-plan-checker',
|
||||
'gsd-planner',
|
||||
'gsd-project-researcher',
|
||||
'gsd-research-synthesizer',
|
||||
'gsd-roadmapper',
|
||||
'gsd-security-auditor',
|
||||
'gsd-ui-auditor',
|
||||
'gsd-ui-checker',
|
||||
'gsd-ui-researcher',
|
||||
'gsd-verifier',
|
||||
];
|
||||
|
||||
function readAgent(name) {
|
||||
return fs.readFileSync(path.join(AGENTS_DIR, `${name}.md`), 'utf8');
|
||||
}
|
||||
|
||||
function allAgentFiles() {
|
||||
return fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md'));
|
||||
}
|
||||
|
||||
describe('agent_skills self-load bootstrap', () => {
|
||||
test('bootstrap reference file exists at gsd-core/references/', () => {
|
||||
assert.ok(fs.existsSync(BOOTSTRAP_PATH), `missing bootstrap reference: ${BOOTSTRAP_PATH}`);
|
||||
});
|
||||
|
||||
test('bootstrap documents the dedup guard + frontmatter-name query', () => {
|
||||
assert.ok(fs.existsSync(BOOTSTRAP_PATH), 'bootstrap missing');
|
||||
const content = fs.readFileSync(BOOTSTRAP_PATH, 'utf8');
|
||||
assert.ok(/<agent_skills>/.test(content), 'bootstrap must name the <agent_skills> block');
|
||||
// Dedup guard (C2): must instruct the agent to skip self-load when the
|
||||
// orchestrator already injected an <agent_skills> block, so the prompt
|
||||
// never carries two copies on runtimes where orchestrator injection runs.
|
||||
assert.ok(/skip/i.test(content) && /already/i.test(content),
|
||||
'bootstrap must document the dedup guard (skip when <agent_skills> already present)');
|
||||
// The bootstrap uses the agent's own frontmatter name as the query type —
|
||||
// there is no per-agent name baked into each agent file (keeps each file
|
||||
// small and the wording uniform). Assert the bootstrap says so.
|
||||
assert.ok(/frontmatter/i.test(content) && /query agent-skills/i.test(content),
|
||||
'bootstrap must instruct query agent-skills using the frontmatter name');
|
||||
});
|
||||
|
||||
describe('every consumer agent carries the self-load bootstrap reference', () => {
|
||||
for (const name of CONSUMER_AGENTS) {
|
||||
test(`${name} @-includes the bootstrap reference`, () => {
|
||||
const content = readAgent(name);
|
||||
assert.ok(
|
||||
content.includes(BOOTSTRAP_REF),
|
||||
`${name} must @-include references/${BOOTSTRAP_REF} (self-load contract)`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('parity: set of agents referencing the bootstrap == CONSUMER_AGENTS (bijection)', () => {
|
||||
const withBootstrap = allAgentFiles()
|
||||
.filter((f) =>
|
||||
fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8').includes(BOOTSTRAP_REF))
|
||||
.map((f) => f.replace(/\.md$/, ''));
|
||||
|
||||
const consumer = new Set(CONSUMER_AGENTS);
|
||||
const bootstrap = new Set(withBootstrap);
|
||||
const missing = [...consumer].filter((a) => !bootstrap.has(a));
|
||||
const extra = [...bootstrap].filter((a) => !consumer.has(a));
|
||||
assert.deepStrictEqual(
|
||||
{ missing, extra },
|
||||
{ missing: [], extra: [] },
|
||||
`bootstrap presence must exactly match CONSUMER_AGENTS (Generative-Fix-Divergence)\n` +
|
||||
` missing bootstrap: ${missing.join(', ') || '(none)'}\n` +
|
||||
` extra bootstrap: ${extra.join(', ') || '(none)'}`
|
||||
);
|
||||
});
|
||||
|
||||
test('property (fast-check): for any sampled agent file, bootstrap-presence iff consumer-status', () => {
|
||||
const files = allAgentFiles();
|
||||
fc.assert(
|
||||
fc.property(fc.shuffledSubarray(files, { minLength: 1 }), (sample) => {
|
||||
for (const f of sample) {
|
||||
const stem = f.replace(/\.md$/, '');
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8');
|
||||
const hasBootstrap = content.includes(BOOTSTRAP_REF);
|
||||
const isConsumer = CONSUMER_AGENTS.includes(stem);
|
||||
if (hasBootstrap !== isConsumer) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
{ numRuns: 60 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,3 +78,153 @@ describe('isCanonicalPlanningFile', () => {
|
||||
assert.strictEqual(isCanonicalPlanningFile('state.md'), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-2448-artifact-registry.test.cjs — consolidation epic #1969 (B3 #1972)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-2448-artifact-registry (consolidation epic #1969 B3 #1972)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2448)
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for canonical artifact registry and gsd-health W019 lint (#2448).
|
||||
*/
|
||||
|
||||
const { test, describe, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const helpers = require('./helpers.cjs');
|
||||
|
||||
const { isCanonicalPlanningFile, CANONICAL_EXACT } = require('../gsd-core/bin/lib/artifacts.cjs');
|
||||
const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs');
|
||||
|
||||
const _dirsToClean = [];
|
||||
after(() => { for (const d of _dirsToClean) helpers.cleanup(d); });
|
||||
|
||||
function makeTempProject(files = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2448-'));
|
||||
_dirsToClean.push(dir);
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content, 'utf-8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const BASE_FILES = {
|
||||
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
|
||||
'.planning/ROADMAP.md': '# Roadmap\n',
|
||||
'.planning/STATE.md': '# State\n',
|
||||
'.planning/config.json': '{}',
|
||||
};
|
||||
|
||||
describe('artifacts.cjs — isCanonicalPlanningFile', () => {
|
||||
test('returns true for all exact canonical names', () => {
|
||||
for (const name of CANONICAL_EXACT) {
|
||||
assert.ok(isCanonicalPlanningFile(name), `Expected ${name} to be canonical`);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns true for version-stamped milestone audit file', () => {
|
||||
assert.ok(isCanonicalPlanningFile('v1.0-MILESTONE-AUDIT.md'));
|
||||
assert.ok(isCanonicalPlanningFile('v2.3.1-MILESTONE-AUDIT.md'));
|
||||
});
|
||||
|
||||
test('returns true for RETROSPECTIVE.md (produced by /gsd-complete-milestone)', () => {
|
||||
assert.strictEqual(isCanonicalPlanningFile('RETROSPECTIVE.md'), true);
|
||||
});
|
||||
|
||||
test('returns false for clearly non-canonical names', () => {
|
||||
assert.strictEqual(isCanonicalPlanningFile('MY-NOTES.md'), false);
|
||||
assert.strictEqual(isCanonicalPlanningFile('scratch.md'), false);
|
||||
assert.strictEqual(isCanonicalPlanningFile('random-output.md'), false);
|
||||
});
|
||||
|
||||
test('returns false for phase-level artifacts at the root (they belong in phases/)', () => {
|
||||
assert.strictEqual(isCanonicalPlanningFile('01-CONTEXT.md'), false);
|
||||
assert.strictEqual(isCanonicalPlanningFile('01-01-PLAN.md'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-health W019 — unrecognized .planning/ root files', () => {
|
||||
test('W019 fires for a non-canonical .md file at .planning/ root', () => {
|
||||
const dir = makeTempProject({
|
||||
...BASE_FILES,
|
||||
'.planning/MY-NOTES.md': '# notes\n',
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w019 = result.warnings.find(w => w.code === 'W019');
|
||||
assert.ok(w019, 'W019 should be emitted for unrecognized file');
|
||||
assert.ok(w019.message.includes('MY-NOTES.md'), 'warning should name the file');
|
||||
assert.strictEqual(w019.repairable, false, 'W019 is not auto-repairable');
|
||||
});
|
||||
|
||||
test('no W019 for canonical files', () => {
|
||||
const dir = makeTempProject({ ...BASE_FILES });
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w019 = result.warnings.find(w => w.code === 'W019');
|
||||
assert.strictEqual(w019, undefined, 'no W019 for canonical files');
|
||||
});
|
||||
|
||||
test('no W019 for phase subdirectory files (only root is checked)', () => {
|
||||
const dir = makeTempProject({
|
||||
...BASE_FILES,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': '---\nphase: "1"\n---\n',
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w019 = result.warnings.find(w => w.code === 'W019');
|
||||
assert.strictEqual(w019, undefined, 'phase subdir files not flagged by W019');
|
||||
});
|
||||
|
||||
test('no W019 for version-stamped files like vX.Y-MILESTONE-AUDIT.md', () => {
|
||||
const dir = makeTempProject({
|
||||
...BASE_FILES,
|
||||
'.planning/v1.0-MILESTONE-AUDIT.md': '# Audit\n',
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w019 = result.warnings.find(w => w.code === 'W019');
|
||||
assert.strictEqual(w019, undefined, 'version-stamped audit file is canonical');
|
||||
});
|
||||
|
||||
test('multiple unrecognized files produce multiple W019 warnings', () => {
|
||||
const dir = makeTempProject({
|
||||
...BASE_FILES,
|
||||
'.planning/scratch.md': '# scratch\n',
|
||||
'.planning/temp-notes.md': '# temp\n',
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w019s = result.warnings.filter(w => w.code === 'W019');
|
||||
assert.strictEqual(w019s.length, 2, 'one W019 per unrecognized file');
|
||||
});
|
||||
|
||||
test('templates/README.md exists and documents W019', () => {
|
||||
const readme = fs.readFileSync(
|
||||
path.join(__dirname, '../gsd-core/templates/README.md'), 'utf-8'
|
||||
);
|
||||
assert.ok(readme.includes('W019'), 'README.md documents W019');
|
||||
assert.ok(readme.includes('artifacts.cjs'), 'README.md references artifacts.cjs for adding new artifacts');
|
||||
assert.ok(readme.includes('PROJECT.md'), 'README.md lists PROJECT.md as canonical');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,3 +86,116 @@ describe('AskUserQuestion text-mode fallback (#2012)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-17-askuserquestion-option-cap.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-17-askuserquestion-option-cap (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #17)
|
||||
// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion
|
||||
// option limits is a behavioral guard, not an implementation-detail assertion.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4;
|
||||
|
||||
function walkMarkdownFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findBalancedClose(text, openIndex, openCh, closeCh) {
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === openCh) depth++;
|
||||
else if (ch === closeCh) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
return text.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function collectOptionCapViolations(file, text) {
|
||||
const violations = [];
|
||||
const askRe = /\bAskUserQuestion\s*\(\s*\[/g;
|
||||
let askMatch;
|
||||
|
||||
while ((askMatch = askRe.exec(text)) !== null) {
|
||||
const askStart = askMatch.index;
|
||||
const arrayOpen = text.indexOf('[', askStart);
|
||||
if (arrayOpen === -1) continue;
|
||||
const arrayClose = findBalancedClose(text, arrayOpen, '[', ']');
|
||||
if (arrayClose === -1) continue;
|
||||
const askBlock = text.slice(arrayOpen, arrayClose + 1);
|
||||
const blockOffset = arrayOpen;
|
||||
|
||||
const optionsRe = /\boptions\s*:\s*\[/g;
|
||||
let optionsMatch;
|
||||
while ((optionsMatch = optionsRe.exec(askBlock)) !== null) {
|
||||
const openInBlock = optionsMatch.index + optionsMatch[0].length - 1;
|
||||
const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']');
|
||||
if (closeInBlock === -1) continue;
|
||||
const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1);
|
||||
const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length;
|
||||
if (labelCount > ASK_USER_QUESTION_OPTION_CAP) {
|
||||
const globalIdx = blockOffset + optionsMatch.index;
|
||||
violations.push({
|
||||
file,
|
||||
line: getLineNumber(text, globalIdx),
|
||||
count: labelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => {
|
||||
test('every AskUserQuestion options array in workflows has at most 4 options', () => {
|
||||
const files = walkMarkdownFiles(ROOT);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
violations.push(...collectOptionCapViolations(file, text));
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations.length,
|
||||
0,
|
||||
[
|
||||
`Found ${violations.length} AskUserQuestion options-array cap violation(s).`,
|
||||
`Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`,
|
||||
...violations.map((v) => {
|
||||
const rel = path.relative(path.join(__dirname, '..'), v.file);
|
||||
return ` ${rel}:${v.line} -> ${v.count} options`;
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,3 +379,717 @@ describe('audit cutover: registry entries correct', () => {
|
||||
assert.strictEqual(entry.router, 'routeGraphifyCommand');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2659-audit-open-crash.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2659-audit-open-crash (consolidation epic #1969 B2 #1971)", () => {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2659.
|
||||
*
|
||||
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
||||
* `output(...)` on both the --json and text branches. `output` is never in
|
||||
* local scope — the entire core module is imported as `const core`, so every
|
||||
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
||||
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
||||
*
|
||||
* This test runs both invocations against a minimal temp project and asserts
|
||||
* they exit successfully with non-empty stdout. It fails with the
|
||||
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-bug-2659-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('audit-open (text output) succeeds and produces stdout', () => {
|
||||
const result = runGsdTools('audit-open', tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open must write a non-empty report to stdout'
|
||||
);
|
||||
});
|
||||
|
||||
test('audit-open --json succeeds and produces stdout', () => {
|
||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open --json must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open --json must write output to stdout'
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(
|
||||
() => { parsed = JSON.parse(result.output); },
|
||||
'audit-open --json must emit valid JSON'
|
||||
);
|
||||
assert.ok(
|
||||
parsed !== null && typeof parsed === 'object',
|
||||
'audit-open --json must emit a JSON object or array'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2911-audit-open-output-shape.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2911-audit-open-output-shape (consolidation epic #1969 B2 #1971)", () => {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2911.
|
||||
*
|
||||
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
|
||||
*
|
||||
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
|
||||
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
||||
* branch JSON-stringifies the formatted string because `core.output` only
|
||||
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
||||
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
||||
* literal) instead of the rendered report.
|
||||
*
|
||||
* The shape assertions below catch both regressions structurally — never via
|
||||
* substring matching on serialized output:
|
||||
*
|
||||
* - text mode: parse stdout as a sequence of lines and assert the expected
|
||||
* section headers exist as standalone lines (i.e. raw text, not escaped).
|
||||
* If the report is JSON-stringified, the stdout is a single line wrapped
|
||||
* in double quotes with `\n` escapes — line-array assertions fail.
|
||||
* - --json mode: JSON.parse the stdout and assert the keys returned by
|
||||
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
|
||||
* are present and well-typed.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('audit-open — output shape (#2911)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-bug-2911-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
|
||||
const result = runGsdTools('audit-open', tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open must not crash. stderr: ${result.error}`
|
||||
);
|
||||
|
||||
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
// The first non-empty line must be the divider character row, *not* a
|
||||
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
||||
// the formatted report, the entire payload sits on one line wrapped in
|
||||
// double quotes ("━━━…\n…").
|
||||
assert.ok(
|
||||
!result.output.startsWith('"'),
|
||||
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
||||
);
|
||||
assert.ok(
|
||||
!result.output.includes('\\n'),
|
||||
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
|
||||
);
|
||||
|
||||
// Section headers from formatAuditReport that must appear as standalone lines.
|
||||
assert.ok(
|
||||
lines.includes('Milestone Close: Open Artifact Audit'),
|
||||
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
||||
);
|
||||
assert.ok(
|
||||
lines.includes('All artifact types clear. Safe to proceed.'),
|
||||
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
|
||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open --json must not crash. stderr: ${result.error}`
|
||||
);
|
||||
|
||||
let parsed;
|
||||
assert.doesNotThrow(
|
||||
() => { parsed = JSON.parse(result.output); },
|
||||
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
|
||||
);
|
||||
|
||||
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
||||
assert.ok(parsed !== null, 'parsed payload must not be null');
|
||||
|
||||
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
|
||||
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
|
||||
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
|
||||
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
|
||||
assert.equal(typeof parsed.items, 'object', 'must include items object');
|
||||
|
||||
const expectedCountKeys = [
|
||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
|
||||
];
|
||||
for (const key of expectedCountKeys) {
|
||||
assert.equal(
|
||||
typeof parsed.counts[key], 'number',
|
||||
`counts.${key} must be a number`
|
||||
);
|
||||
}
|
||||
|
||||
const expectedItemKeys = [
|
||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
||||
];
|
||||
for (const key of expectedItemKeys) {
|
||||
assert.ok(
|
||||
Array.isArray(parsed.items[key]),
|
||||
`items.${key} must be an array`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2836-audit-open-summary-uat-drift.test.cjs — consolidation epic #1969 (B3 #1972)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2836-audit-open-summary-uat-drift (consolidation epic #1969 B3 #1972)", () => {
|
||||
/**
|
||||
* Regression tests for bug #2836
|
||||
*
|
||||
* audit-open had two convention drifts vs the documented workflows:
|
||||
* 1. quick-task scanner looked for bare `SUMMARY.md`, but workflows/quick.md
|
||||
* mandates `${quick_id}-SUMMARY.md`. Result: every documented quick task
|
||||
* reported as `status: missing`.
|
||||
* 2. UAT terminal-status enum only accepted `complete`, but
|
||||
* workflows/execute-phase.md uses `resolved` post-gap-closure.
|
||||
* Result: gap-closed UATs reported as open.
|
||||
*
|
||||
* Tests structurally invoke auditOpenArtifacts() against real fixtures on disk
|
||||
* and assert the returned items array — never regex on raw file content.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
|
||||
const { auditOpenArtifacts } = auditModule;
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function mkTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2836-'));
|
||||
}
|
||||
|
||||
describe('bug #2836: audit-open quick-task summary filename + UAT terminal status', () => {
|
||||
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
|
||||
let prevProject, prevWorkstream;
|
||||
before(() => {
|
||||
prevProject = process.env.GSD_PROJECT;
|
||||
prevWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
after(() => {
|
||||
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
|
||||
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
|
||||
});
|
||||
|
||||
test('quick task with ${quick_id}-SUMMARY.md is recognized as complete (not missing)', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260429-test-foo';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
||||
'---\nstatus: complete\n---\ntest summary\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
realQuickTasks.length, 0,
|
||||
`quick task with ${quickId}-SUMMARY.md (status: complete) must not appear ` +
|
||||
`as an open item; got: ${JSON.stringify(realQuickTasks)}`
|
||||
);
|
||||
assert.equal(result.counts.quick_tasks, 0);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('UAT with status: resolved is treated as terminal (not an open gap)', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '01-UAT.md'),
|
||||
'---\nstatus: resolved\n---\nUAT body — gap closed via execute-phase flow.\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
||||
|
||||
assert.equal(
|
||||
realUatGaps.length, 0,
|
||||
`UAT with status: resolved must not appear as an open gap; ` +
|
||||
`got: ${JSON.stringify(realUatGaps)}`
|
||||
);
|
||||
assert.equal(result.counts.uat_gaps, 0);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('UAT with status: complete remains terminal (no regression)', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '02-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '02-UAT.md'),
|
||||
'---\nstatus: complete\n---\nUAT body.\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
||||
assert.equal(realUatGaps.length, 0);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('UAT with status: pending is still flagged as an open gap', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '03-UAT.md'),
|
||||
'---\nstatus: pending\n---\nresult: pending\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
||||
assert.equal(realUatGaps.length, 1, 'pending UAT must still be flagged');
|
||||
assert.equal(realUatGaps[0].status, 'pending');
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('quick task without any SUMMARY file is still flagged as missing', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260429-test-bar';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
// No SUMMARY file at all.
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
assert.equal(realQuickTasks.length, 1);
|
||||
assert.equal(realQuickTasks[0].status, 'missing');
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2836: workflows/help.md one-liner reconciliation', () => {
|
||||
test('help.md quick-task one-liner uses ${quick_id}-SUMMARY.md pattern', () => {
|
||||
// After #3039, help content moved into help/modes/full.md.
|
||||
const helpPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md'
|
||||
);
|
||||
const content = fs.readFileSync(helpPath, 'utf-8');
|
||||
|
||||
// Locate the documented "Result: Creates ..." quick-task one-liner and
|
||||
// assert it references the per-task SUMMARY filename pattern, not bare
|
||||
// SUMMARY.md. We parse by line to avoid false positives elsewhere.
|
||||
const resultLines = content.split(/\r?\n/).filter(l =>
|
||||
l.includes('Result: Creates') && l.includes('.planning/quick/')
|
||||
);
|
||||
assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md');
|
||||
for (const line of resultLines) {
|
||||
assert.ok(
|
||||
/\$\{quick_id\}-SUMMARY\.md|NNN-slug-SUMMARY\.md/.test(line),
|
||||
`help.md quick-task Result line must reference per-task SUMMARY filename ` +
|
||||
`(e.g. \${quick_id}-SUMMARY.md); got: ${line}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-950-quick-summary-status-complete.test.cjs — consolidation epic #1969 (B3 #1972)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-950-quick-summary-status-complete (consolidation epic #1969 B3 #1972)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #950)
|
||||
/**
|
||||
* Regression tests for bug #950
|
||||
*
|
||||
* audit-open chronically flagged genuinely-complete quick tasks as [unknown]
|
||||
* because NO shipped summary template carried a `status:` frontmatter field —
|
||||
* so status was only emitted when the writing agent improvised it.
|
||||
*
|
||||
* The fix: add `status: complete` to all four summary templates and enforce it
|
||||
* in the executor agent + quick.md workflow. Tests here exercise the scanner
|
||||
* directly via auditOpenArtifacts() and also guard template text as a secondary
|
||||
* contract check.
|
||||
*
|
||||
* Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks)
|
||||
* Secondary guard: template-contract text checks (template text IS the runtime contract)
|
||||
* Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
|
||||
const { auditOpenArtifacts } = auditModule;
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates');
|
||||
const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md');
|
||||
|
||||
function mkTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first YAML frontmatter block from a file's content.
|
||||
*
|
||||
* Two layouts are handled:
|
||||
* - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md)
|
||||
* - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md,
|
||||
* whose content IS a markdown example showing the template). In that case we extract
|
||||
* the `---\n…\n---` block that sits immediately after the opening fence line.
|
||||
*
|
||||
* Returns the raw text of the YAML block (between the two `---` delimiters, exclusive),
|
||||
* or null if no frontmatter could be found.
|
||||
*/
|
||||
function extractFrontmatter(content) {
|
||||
// Case 1: file begins with --- (leading frontmatter)
|
||||
if (/^---\r?\n/.test(content)) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n)
|
||||
const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/);
|
||||
if (fenceMatch) {
|
||||
// Strip the outer --- delimiters to get just the YAML body
|
||||
const block = fenceMatch[1];
|
||||
const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/);
|
||||
return inner ? inner[1] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
|
||||
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
|
||||
let prevProject, prevWorkstream;
|
||||
before(() => {
|
||||
prevProject = process.env.GSD_PROJECT;
|
||||
prevWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
after(() => {
|
||||
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
|
||||
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
|
||||
});
|
||||
|
||||
// ── Behavioral: scanner recognizes complete quick tasks ───────────────────
|
||||
|
||||
test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => {
|
||||
// Simulates an executor that correctly wrote the SUMMARY with status: complete
|
||||
// (as required after the fix). The scanner must report 0 open quick tasks.
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260609-test-status-complete';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
||||
[
|
||||
'---',
|
||||
'status: complete',
|
||||
'date: 2026-06-09',
|
||||
'slug: test-status-complete',
|
||||
'---',
|
||||
'',
|
||||
'# Quick Task Summary',
|
||||
'',
|
||||
'Task completed successfully.',
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
realQuickTasks.length,
|
||||
0,
|
||||
`quick task SUMMARY with status: complete must NOT appear as open; ` +
|
||||
`got: ${JSON.stringify(realQuickTasks)}`
|
||||
);
|
||||
assert.equal(result.counts.quick_tasks, 0);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => {
|
||||
// Negative case: a SUMMARY that lacks status: still surfaces as [unknown].
|
||||
// This proves the scanner still catches real gaps — the fix must be on the writer side.
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260609-test-no-status';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
||||
[
|
||||
'---',
|
||||
'date: 2026-06-09',
|
||||
'slug: test-no-status',
|
||||
'---',
|
||||
'',
|
||||
'# Quick Task Summary',
|
||||
'',
|
||||
'Task done, but no status field.',
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
realQuickTasks.length,
|
||||
1,
|
||||
`quick task SUMMARY without status: must appear as open (unknown); ` +
|
||||
`got: ${JSON.stringify(realQuickTasks)}`
|
||||
);
|
||||
assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown');
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => {
|
||||
// Proves the missing-SUMMARY case still surfaces.
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260609-test-missing-summary';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
// No SUMMARY file at all.
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
|
||||
assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged');
|
||||
assert.equal(realQuickTasks[0].status, 'missing');
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => {
|
||||
// Scanner lowercases before comparing — verify case-insensitivity holds.
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const quickId = '260609-test-uppercase-complete';
|
||||
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
||||
'---\nstatus: COMPLETE\n---\n# Summary\nDone.\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realQuickTasks = result.items.quick_tasks.filter(
|
||||
i => !i.scan_error && !i._remainder_count
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
realQuickTasks.length,
|
||||
0,
|
||||
`quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` +
|
||||
`got: ${JSON.stringify(realQuickTasks)}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Secondary: template-contract checks ──────────────────────────────────
|
||||
// Assertions are scoped to the actual YAML frontmatter block, not the whole file,
|
||||
// so a stray `status: complete` in prose or examples cannot produce a false green.
|
||||
|
||||
test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => {
|
||||
// summary.md is a documentation template — its frontmatter lives inside a
|
||||
// ```markdown fence. extractFrontmatter() finds and returns that block.
|
||||
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
assert.ok(
|
||||
fm !== null,
|
||||
'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)'
|
||||
);
|
||||
assert.ok(
|
||||
/^status:\s*complete\s*$/m.test(fm),
|
||||
`gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` +
|
||||
`Frontmatter extracted:\n${fm}`
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => {
|
||||
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
assert.ok(
|
||||
fm !== null,
|
||||
'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block'
|
||||
);
|
||||
assert.ok(
|
||||
/^status:\s*complete\s*$/m.test(fm),
|
||||
`gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` +
|
||||
`Frontmatter extracted:\n${fm}`
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => {
|
||||
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
assert.ok(
|
||||
fm !== null,
|
||||
'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block'
|
||||
);
|
||||
assert.ok(
|
||||
/^status:\s*complete\s*$/m.test(fm),
|
||||
`gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` +
|
||||
`Frontmatter extracted:\n${fm}`
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => {
|
||||
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
assert.ok(
|
||||
fm !== null,
|
||||
'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block'
|
||||
);
|
||||
assert.ok(
|
||||
/^status:\s*complete\s*$/m.test(fm),
|
||||
`gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` +
|
||||
`Frontmatter extracted:\n${fm}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Writer-path contract checks ───────────────────────────────────────────
|
||||
// Guards quick.md and gsd-executor.md so a future edit removing `status: complete`
|
||||
// from the SUMMARY-creation instructions would fail the suite before the bug recurs.
|
||||
// (source-text-is-the-product: the deployed .md text IS the runtime contract for agents)
|
||||
|
||||
test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => {
|
||||
const content = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
assert.ok(
|
||||
/status:\s*complete/.test(content),
|
||||
'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' +
|
||||
'The <constraints> block must contain the `status: complete` requirement so a future edit cannot silently drop it.'
|
||||
);
|
||||
});
|
||||
|
||||
test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_MD, 'utf-8');
|
||||
assert.ok(
|
||||
/status[\s\S]{0,40}complete/.test(content),
|
||||
'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' +
|
||||
'The Frontmatter section must include `status: complete` so the executor always emits it.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +139,16 @@ describe('autonomous verification deferral contract', () => {
|
||||
/Gaps deferred` and proceed to iterate step/,
|
||||
'gap deferral must not silently proceed to the next phase',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/Skip deferred phases on autonomous re-entry/,
|
||||
'reruns must explicitly skip deferred verification phases',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/Deferred Verification \(Skipped on Re-entry\)/,
|
||||
'workflow should surface skipped deferred phases and their resume commands',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow runs normal transition post-processing after passed verification (#1526)', () => {
|
||||
@@ -197,6 +207,8 @@ describe('autonomous verification deferral contract', () => {
|
||||
);
|
||||
assert.match(discoverStep, /phase_complete !== true/);
|
||||
assert.match(discoverStep, /verification_status !== "passed"/);
|
||||
assert.match(discoverStep, /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/);
|
||||
assert.match(discoverStep, /drop any phase whose number appears in the deferred-phase map/);
|
||||
assert.doesNotMatch(discoverStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/);
|
||||
assert.doesNotMatch(discoverStep, /disk_status !== "complete"/);
|
||||
|
||||
@@ -207,5 +219,7 @@ describe('autonomous verification deferral contract', () => {
|
||||
);
|
||||
assert.match(iterateStep, /phase_complete !== true/);
|
||||
assert.match(iterateStep, /verification_status !== "passed"/);
|
||||
assert.match(iterateStep, /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/);
|
||||
assert.match(iterateStep, /drop deferred phases from the autonomous queue/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
compareSemverCore,
|
||||
isStableTripletSemver,
|
||||
} = require('../gsd-core/bin/lib/semver-compare.cjs');
|
||||
const { isInstalledAheadOfLatest } = require('../hooks/gsd-statusline.js');
|
||||
|
||||
describe('bug #10: semver policy consolidation', () => {
|
||||
test('shared comparator treats prerelease patch increment as newer than previous stable', () => {
|
||||
assert.ok(compareSemverCore('1.2.1-beta.1', '1.2.0') > 0);
|
||||
});
|
||||
|
||||
test('shared comparator ignores prerelease suffix for equal base versions', () => {
|
||||
assert.equal(compareSemverCore('1.2.0-rc.1', '1.2.0'), 0);
|
||||
});
|
||||
|
||||
test('stable-triplet validator excludes prerelease tags', () => {
|
||||
assert.equal(isStableTripletSemver('1.2.0-rc.1'), false);
|
||||
assert.equal(isStableTripletSemver('1.2.0'), true);
|
||||
});
|
||||
|
||||
test('statusline dev-install detection uses shared comparator semantics', () => {
|
||||
assert.equal(isInstalledAheadOfLatest('1.2.1-beta.1', '1.2.0'), true);
|
||||
assert.equal(isInstalledAheadOfLatest('1.2.0-rc.1', '1.2.0'), false);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #130: finishInstall calls configureOpencodePermissions unconditionally,
|
||||
* violating the GSD_TEST_MODE side-effect-free contract.
|
||||
*
|
||||
* configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which
|
||||
* must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode
|
||||
* config file (opencode.json) is NOT created when GSD_TEST_MODE is set.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Point HOME at a temp dir so configureOpencodePermissions can't write to
|
||||
// the real ~/.config/opencode/ even if the guard is missing.
|
||||
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-'));
|
||||
process.env.HOME = FAKE_HOME;
|
||||
process.env.USERPROFILE = FAKE_HOME;
|
||||
|
||||
// The opencode config dir that configureOpencodePermissions would use for a
|
||||
// global install when configDir=null: <HOME>/.config/opencode/
|
||||
// The file it writes is opencode.json (or opencode.jsonc if pre-existing).
|
||||
const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode');
|
||||
const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||
|
||||
// configDir is passed explicitly so the function targets our FAKE_HOME dir
|
||||
// regardless of how getGlobalDir resolves.
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`);
|
||||
|
||||
function callFinishInstall() {
|
||||
const original = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
SETTINGS_PATH,
|
||||
{},
|
||||
null,
|
||||
false,
|
||||
'opencode',
|
||||
true,
|
||||
OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => {
|
||||
test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => {
|
||||
// Confirm the file does not exist before the call
|
||||
assert.equal(
|
||||
fs.existsSync(OPENCODE_CONFIG_FILE),
|
||||
false,
|
||||
'opencode.json should not exist before finishInstall call',
|
||||
);
|
||||
|
||||
callFinishInstall();
|
||||
|
||||
// Assert the file was NOT created — the side-effect must be suppressed
|
||||
assert.equal(
|
||||
fs.existsSync(OPENCODE_CONFIG_FILE),
|
||||
false,
|
||||
`opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Regression test for #131: runNpm() must not fail when HOME points at an
|
||||
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
|
||||
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
|
||||
// an unwritable HOME from the environment (common in constrained Docker hosts),
|
||||
// the entire hook fails and all 6 subtests are cancelled.
|
||||
//
|
||||
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
|
||||
// npm_config_userconfig that point into a temp directory it owns, so that npm
|
||||
// never reads from or writes to the caller's HOME.
|
||||
//
|
||||
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
|
||||
// companion export that lets runSmoke() apply the same isolation — also redirects
|
||||
// HOME away from the caller's HOME. Without this, subtests A-F of
|
||||
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
|
||||
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
// The helpers under test.
|
||||
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Resolve a filesystem path to its canonical (symlink-free) form even if the
|
||||
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
|
||||
// Walks up to the nearest existing ancestor, resolves that, then re-appends
|
||||
// the trailing segments. This handles macOS /var → /private/var symlinks for
|
||||
// paths created under os.tmpdir() where the leaf directory may not exist yet.
|
||||
function safeRealpath(p) {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (_) {
|
||||
// Leaf does not exist — resolve the nearest existing ancestor then
|
||||
// reconstruct the original suffix so the result is still canonical.
|
||||
const segments = [];
|
||||
let cur = p;
|
||||
for (;;) {
|
||||
const parent = path.dirname(cur);
|
||||
if (parent === cur) {
|
||||
// Reached filesystem root — return original path unchanged.
|
||||
return p;
|
||||
}
|
||||
segments.unshift(path.basename(cur));
|
||||
cur = parent;
|
||||
try {
|
||||
return path.join(fs.realpathSync(cur), ...segments);
|
||||
} catch (__) {
|
||||
// Keep walking up.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
|
||||
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
|
||||
// Spawn a child Node process that sets HOME to a chmod-0500 directory, then
|
||||
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
|
||||
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
|
||||
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
|
||||
test('runNpm succeeds even when process HOME is unwritable', () => {
|
||||
// Create an unwritable dir to serve as a poisoned HOME.
|
||||
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
|
||||
try {
|
||||
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
|
||||
|
||||
// We exercise the real runNpm() path by running a tiny inline Node script
|
||||
// that requires helpers.cjs and calls runNpm(['--version']) with HOME set
|
||||
// to the unwritable dir. The script exits 0 on success, non-zero on throw.
|
||||
const script = `
|
||||
process.env.HOME = ${JSON.stringify(poisonedHome)};
|
||||
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
const out = runNpm(['--version']);
|
||||
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
|
||||
process.stdout.write(out);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
// npm --version returns something like "10.x.y"
|
||||
assert.match(
|
||||
stdout.trim(),
|
||||
/^\d+\.\d+/,
|
||||
`expected semver output from npm --version, got: ${stdout}`,
|
||||
);
|
||||
} finally {
|
||||
// Restore write permission before cleanup so the directory can be deleted.
|
||||
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
|
||||
cleanup(poisonedHome);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
|
||||
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
|
||||
// a different (temp) path so npm writes never touch the caller's $HOME.
|
||||
test('runNpm injects a HOME distinct from process.env.HOME', () => {
|
||||
// Capture what HOME runNpm actually passes to npm by asking npm to print
|
||||
// the value it sees for the $HOME env var. We do this via `npm config get
|
||||
// cache` which reveals the cache path — if it's under process.env.HOME,
|
||||
// the fix is absent; if it's under a tmp dir, the fix is present.
|
||||
|
||||
const script = `
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
// npm config get cache prints the effective cache directory.
|
||||
const out = runNpm(['config', 'get', 'cache']);
|
||||
process.stdout.write(out.trim());
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
|
||||
const effectiveCacheDir = stdout.trim();
|
||||
|
||||
// The effective npm cache must NOT be inside the calling process's HOME.
|
||||
// If it is, the fix was not applied and the Docker regression can still occur.
|
||||
const callerHome = os.homedir();
|
||||
assert.ok(
|
||||
!effectiveCacheDir.startsWith(callerHome),
|
||||
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
|
||||
);
|
||||
|
||||
// It must be somewhere under the system tmp dir, confirming isolation.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch when os.tmpdir() and the resolved cache
|
||||
// path differ only in symlink expansion. The cache sub-directory (.npm) may
|
||||
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realCacheDir = safeRealpath(effectiveCacheDir);
|
||||
assert.ok(
|
||||
realCacheDir.startsWith(sysTmp),
|
||||
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
|
||||
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
|
||||
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
|
||||
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
|
||||
// manifesting via the sibling runSmoke() path). (#131)
|
||||
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
|
||||
const env = isolatedNpmEnv();
|
||||
|
||||
// Must expose a HOME key.
|
||||
assert.ok(
|
||||
typeof env.HOME === 'string' && env.HOME.length > 0,
|
||||
'isolatedNpmEnv() must set HOME',
|
||||
);
|
||||
|
||||
// Must not be the caller's HOME.
|
||||
const callerHome = os.homedir();
|
||||
assert.notEqual(
|
||||
env.HOME,
|
||||
callerHome,
|
||||
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
|
||||
);
|
||||
|
||||
// Must live under the system tmpdir, confirming it is an isolated temp directory.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realHome = safeRealpath(env.HOME);
|
||||
assert.ok(
|
||||
realHome.startsWith(sysTmp),
|
||||
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
|
||||
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
|
||||
assert.ok(
|
||||
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
|
||||
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
|
||||
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_loglevel,
|
||||
'error',
|
||||
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_update_notifier,
|
||||
'false',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.NO_UPDATE_NOTIFIER,
|
||||
'1',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product #1367
|
||||
// Installed command `.md` files — their on-disk path determines the slash-command
|
||||
// namespace registered by Claude Code. Asserting the layout (flat vs. subdirectory)
|
||||
// IS a behavioral test of the deploy contract, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #1367 — project-local Claude Code install writes command files to
|
||||
* `.claude/commands/gsd/<cmd>.md` (subdirectory, bare names), causing Claude Code
|
||||
* to register them as `/gsd:<cmd>` (colon namespace). The fix changes the layout to
|
||||
* write flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code
|
||||
* registers `/gsd-<cmd>` (hyphen form, matching hooks, statusline, and cross-command
|
||||
* references everywhere in the framework).
|
||||
*
|
||||
* Root cause: `bin/install.js` (the `else` branch for claude local) wrote to a
|
||||
* `commands/gsd/` subdirectory using `copyWithPathReplacement`. Claude Code treats
|
||||
* the directory name as a namespace, so `commands/gsd/update.md` became `/gsd:update`.
|
||||
*
|
||||
* Fix: write each command as `gsd-<stem>.md` directly in `commands/` (flat layout).
|
||||
* This is the same approach used for OpenCode/Kilo (see `copyFlattenedCommands`).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js --claude --local --no-sdk` in cwd.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite — #1367 regression: flat gsd-<cmd>.md layout for claude local install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #1367 — Claude local install uses flat gsd-<cmd>.md command layout', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-'));
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('L0: commands/ directory exists after local claude install', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
`commands/ must be created by local claude install at ${commandsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L1: command files use flat gsd-<cmd>.md names (not bare names in a subdirectory)', () => {
|
||||
// The fix: commands land as .claude/commands/gsd-<cmd>.md (flat, hyphen-prefixed).
|
||||
// Claude Code reads the stem of each file in commands/ as the command name,
|
||||
// so gsd-update.md → /gsd-update (hyphen). The old layout (commands/gsd/update.md)
|
||||
// made Claude Code use the directory as a namespace → /gsd:update (colon).
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
|
||||
|
||||
const flatGsdFiles = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
|
||||
|
||||
assert.ok(
|
||||
flatGsdFiles.length > 0,
|
||||
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md, gsd-update.md). ` +
|
||||
`Found none. Install may still be writing to commands/gsd/<cmd>.md subdirectory ` +
|
||||
`which causes /gsd:<cmd> colon namespace in Claude Code.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L2: known commands land as flat gsd-<cmd>.md files', () => {
|
||||
// Spot-check: the three commands mentioned in the issue must be present
|
||||
// as flat hyphen-prefixed files.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const knownCommands = ['gsd-update.md', 'gsd-plan-phase.md', 'gsd-help.md'];
|
||||
for (const name of knownCommands) {
|
||||
const filePath = path.join(commandsDir, name);
|
||||
assert.ok(
|
||||
fs.existsSync(filePath),
|
||||
`${name} must exist as a flat file at commands/${name}. ` +
|
||||
`If missing, the flat layout is not being written correctly.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('L3: commands/gsd/ subdirectory does NOT exist (old colon-namespace layout)', () => {
|
||||
// The old layout wrote to commands/gsd/<cmd>.md. That directory must not
|
||||
// exist after a fresh install with the fix applied.
|
||||
const oldSubdir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
`commands/gsd/ subdir must NOT exist after install. ` +
|
||||
`Its presence means the old layout is still being used — Claude Code would ` +
|
||||
`register commands as /gsd:<cmd> (colon) instead of /gsd-<cmd> (hyphen).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L4: total flat command file count matches the staged source', () => {
|
||||
// There should be a substantial number of commands (not 0, not 1).
|
||||
// The exact count varies with profile but must be >= 20 for a full install.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const count = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'))
|
||||
.length;
|
||||
assert.ok(
|
||||
count >= 20,
|
||||
`commands/ must have >= 20 flat gsd-*.md files for a full install. ` +
|
||||
`Got ${count}. Install may be silently dropping commands.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L5: legacy migration — re-install on a pre-#1367 tree removes old commands/gsd/ subdir', () => {
|
||||
// Simulate a pre-#1367 install: create a commands/gsd/ subdirectory with a bare-name file.
|
||||
// Then re-run the installer and verify the old subdir is cleaned up.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const legacyDir = path.join(commandsDir, 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'update.md'), '# legacy update');
|
||||
|
||||
// Re-run install — should remove commands/gsd/ and write flat gsd-*.md
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyDir),
|
||||
`commands/gsd/ legacy subdir must be removed by re-install. ` +
|
||||
`The installer's legacy cleanup must remove old commands/gsd/ on upgrade.`,
|
||||
);
|
||||
// Flat form must still be present
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'gsd-update.md')),
|
||||
`gsd-update.md must exist as flat file after re-install.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The command markdown is loaded directly by runtime prompt assembly.
|
||||
// This test verifies that --auto is documented in progress.md and handled in next.md.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
describe('#14: /gsd:progress --next --auto flag must be documented and propagated', () => {
|
||||
test('progress.md <flags> section documents --auto flag', () => {
|
||||
const command = fs.readFileSync(
|
||||
path.join(ROOT, 'commands', 'gsd', 'progress.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
command.includes('--auto'),
|
||||
'progress.md must document the --auto flag in the <flags> section'
|
||||
);
|
||||
});
|
||||
|
||||
test('progress.md <process> block explicitly passes --auto through to next workflow', () => {
|
||||
const command = fs.readFileSync(
|
||||
path.join(ROOT, 'commands', 'gsd', 'progress.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Extract only the <process>…</process> block so this assertion is
|
||||
// scoped to the handoff wiring, not just any occurrence in the file.
|
||||
const processMatch = command.match(/<process>([\s\S]*?)<\/process>/);
|
||||
assert.ok(
|
||||
processMatch,
|
||||
'progress.md must contain a <process> block'
|
||||
);
|
||||
const processBlock = processMatch[1];
|
||||
|
||||
assert.ok(
|
||||
processBlock.includes('--auto'),
|
||||
'progress.md <process> block must explicitly mention --auto so it is not silently stripped at the --next handoff'
|
||||
);
|
||||
});
|
||||
|
||||
test('next.md show_and_execute step handles --auto to chain steps', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'next.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
workflow.includes('--auto'),
|
||||
'next.md must handle the --auto flag to chain step invocations automatically'
|
||||
);
|
||||
});
|
||||
|
||||
test('next.md --auto chaining re-invokes /gsd:progress --next after step completion', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'next.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// The workflow must contain instructions to re-invoke /gsd:progress --next --auto
|
||||
// after the determined step completes, enabling the chain.
|
||||
assert.ok(
|
||||
workflow.includes('--next --auto'),
|
||||
'next.md must instruct re-invocation of /gsd:progress --next --auto after step completion to enable chaining'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
|
||||
const direct = runGsdTools(['init.progress']);
|
||||
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
|
||||
|
||||
const meta = runGsdTools(['query', 'init.progress']);
|
||||
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(meta.output),
|
||||
JSON.parse(direct.output),
|
||||
'query-prefixed and direct invocations should return identical init.progress payloads'
|
||||
);
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion
|
||||
// option limits is a behavioral guard, not an implementation-detail assertion.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4;
|
||||
|
||||
function walkMarkdownFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findBalancedClose(text, openIndex, openCh, closeCh) {
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === openCh) depth++;
|
||||
else if (ch === closeCh) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
return text.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function collectOptionCapViolations(file, text) {
|
||||
const violations = [];
|
||||
const askRe = /\bAskUserQuestion\s*\(\s*\[/g;
|
||||
let askMatch;
|
||||
|
||||
while ((askMatch = askRe.exec(text)) !== null) {
|
||||
const askStart = askMatch.index;
|
||||
const arrayOpen = text.indexOf('[', askStart);
|
||||
if (arrayOpen === -1) continue;
|
||||
const arrayClose = findBalancedClose(text, arrayOpen, '[', ']');
|
||||
if (arrayClose === -1) continue;
|
||||
const askBlock = text.slice(arrayOpen, arrayClose + 1);
|
||||
const blockOffset = arrayOpen;
|
||||
|
||||
const optionsRe = /\boptions\s*:\s*\[/g;
|
||||
let optionsMatch;
|
||||
while ((optionsMatch = optionsRe.exec(askBlock)) !== null) {
|
||||
const openInBlock = optionsMatch.index + optionsMatch[0].length - 1;
|
||||
const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']');
|
||||
if (closeInBlock === -1) continue;
|
||||
const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1);
|
||||
const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length;
|
||||
if (labelCount > ASK_USER_QUESTION_OPTION_CAP) {
|
||||
const globalIdx = blockOffset + optionsMatch.index;
|
||||
violations.push({
|
||||
file,
|
||||
line: getLineNumber(text, globalIdx),
|
||||
count: labelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => {
|
||||
test('every AskUserQuestion options array in workflows has at most 4 options', () => {
|
||||
const files = walkMarkdownFiles(ROOT);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
violations.push(...collectOptionCapViolations(file, text));
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations.length,
|
||||
0,
|
||||
[
|
||||
`Found ${violations.length} AskUserQuestion options-array cap violation(s).`,
|
||||
`Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`,
|
||||
...violations.map((v) => {
|
||||
const rel = path.relative(path.join(__dirname, '..'), v.file);
|
||||
return ` ${rel}:${v.line} -> ${v.count} options`;
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local';
|
||||
const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local';
|
||||
|
||||
function findMarkdownFiles(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...findMarkdownFiles(full));
|
||||
else if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => {
|
||||
const files = findMarkdownFiles(WORKFLOWS_DIR);
|
||||
let legacyCount = 0;
|
||||
let currentCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
if (src.includes(LEGACY_HINT)) legacyCount += 1;
|
||||
if (src.includes(CURRENT_HINT)) currentCount += 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
legacyCount,
|
||||
0,
|
||||
`workflow fallback hints must not reference legacy package (${LEGACY_HINT})`
|
||||
);
|
||||
assert.ok(
|
||||
currentCount > 0,
|
||||
`expected at least one workflow fallback hint to use current package (${CURRENT_HINT})`
|
||||
);
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Regression test for #1736: local Claude install missing commands/gsd/
|
||||
*
|
||||
* After a fresh local install (`--claude --local`), all /gsd-* commands
|
||||
* except /gsd-help return "Unknown skill: gsd-quick" because
|
||||
* .claude/commands/gsd/ was not populated. Claude Code reads local project
|
||||
* commands from .claude/commands/ (one level up) using the file stem as the
|
||||
* command name.
|
||||
*
|
||||
* #1367 follow-up: the fix changed the layout from the old commands/gsd/<cmd>.md
|
||||
* (which caused /gsd:<cmd> colon namespace) to flat commands/gsd-<cmd>.md
|
||||
* (which produces /gsd-<cmd> hyphen form). This test has been updated to assert
|
||||
* the new flat layout while preserving the core invariant from #1736: commands
|
||||
* must be present and usable after a local install.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run
|
||||
// build-hooks.js concurrently. That script creates hooks/dist/ empty first,
|
||||
// then copies files — creating a window where this test sees an empty dir and
|
||||
// install() fails with "directory is empty" → process.exit(1).
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1736 + #1367: local install deploys commands in flat gsd-<cmd>.md layout ───
|
||||
|
||||
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.md layout, #1367)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared helper which has a 5s Windows-EBUSY retry budget
|
||||
// (20×250ms). The inline 1s budget here was insufficient on cold runners.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => {
|
||||
// #1736 invariant: commands must be deployed.
|
||||
// #1367 fix: commands land as flat gsd-<cmd>.md at commands/ (not commands/gsd/<cmd>.md).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/ directory must exist after local install'
|
||||
);
|
||||
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
flatFiles.length > 0,
|
||||
`.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}`
|
||||
);
|
||||
// The old commands/gsd/ subdirectory must NOT exist (#1367)
|
||||
const oldSubdir = path.join(commandsDir, 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
'.claude/commands/gsd/ subdir must NOT exist — flat gsd-<cmd>.md layout required (#1367)'
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/ must exist'
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
files.length > 0,
|
||||
`.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => {
|
||||
// Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form).
|
||||
// Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md');
|
||||
assert.ok(
|
||||
fs.existsSync(quickCmd),
|
||||
'.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1754
|
||||
*
|
||||
* The installer must NOT register .js hook entries in settings.json when the
|
||||
* corresponding .js file does not exist at the target path. The original bug:
|
||||
* on fresh installs where hooks/dist/ was missing from the npm package (as in
|
||||
* v1.32.0), the hook copy step produced no files, yet the registration step
|
||||
* ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash
|
||||
* hook error" on every tool invocation.
|
||||
*
|
||||
* The .sh hooks already had fs.existsSync() guards (added in #1817). This
|
||||
* test verifies the same defensive pattern exists for all .js hooks.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
const JS_HOOKS = [
|
||||
{ name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' },
|
||||
{ name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' },
|
||||
{ name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' },
|
||||
{ name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' },
|
||||
{ name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' },
|
||||
{ name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' },
|
||||
];
|
||||
|
||||
describe('bug #1754: .js hook registration guards', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
});
|
||||
|
||||
for (const { name, registrationAnchor } of JS_HOOKS) {
|
||||
describe(`${name} registration`, () => {
|
||||
test(`install.js checks file existence before registering ${name}`, () => {
|
||||
// Find the registration block by locating the "has...Hook" variable
|
||||
const anchorIdx = src.indexOf(registrationAnchor);
|
||||
assert.ok(
|
||||
anchorIdx !== -1,
|
||||
`${registrationAnchor} variable not found in install.js`
|
||||
);
|
||||
|
||||
// Extract a window around the registration block to find the guard
|
||||
const blockStart = anchorIdx;
|
||||
const blockEnd = Math.min(src.length, anchorIdx + 1200);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
// The block must contain an fs.existsSync check for the hook file
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
`install.js must call fs.existsSync on the target path before registering ${name} ` +
|
||||
`in settings.json. Without this guard, hooks are registered even when the .js file ` +
|
||||
`was never copied (the root cause of #1754).`
|
||||
);
|
||||
});
|
||||
|
||||
test(`install.js emits a warning when ${name} is missing`, () => {
|
||||
// The hook file name (without extension) should appear in a warning message
|
||||
const hookBaseName = name.replace('.js', '');
|
||||
const warnPattern = `Skipped`;
|
||||
const anchorIdx = src.indexOf(registrationAnchor);
|
||||
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
|
||||
|
||||
assert.ok(
|
||||
block.includes(warnPattern) && block.includes(hookBaseName),
|
||||
`install.js must emit a skip warning when ${name} is not found at the target path`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('all .js hooks use the same guard pattern as .sh hooks', () => {
|
||||
// Count existsSync calls in the hook registration section.
|
||||
// There should be guards for all JS hooks plus the existing SH hooks.
|
||||
// This test ensures new hooks added in the future follow the same pattern.
|
||||
// ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the
|
||||
// full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing.
|
||||
const registrationSection = src;
|
||||
|
||||
// Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js'))
|
||||
const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []);
|
||||
const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []);
|
||||
|
||||
assert.ok(
|
||||
jsGuards.length >= JS_HOOKS.length,
|
||||
`Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` +
|
||||
`Every .js hook registration must check file existence before registering.`
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
shGuards.length >= 3,
|
||||
`Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` +
|
||||
`found ${shGuards.length}.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1817
|
||||
*
|
||||
* The installer must NOT register .sh hook entries in settings.json when the
|
||||
* corresponding .sh file does not exist at the target path. The original bug:
|
||||
* v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy
|
||||
* step produced no files, yet the registration step ran unconditionally —
|
||||
* leaving users with hook errors on every tool invocation.
|
||||
*
|
||||
* Defensive guard: before registering each .sh hook in settings.json,
|
||||
* install.js must verify the target file exists. If it doesn't, skip
|
||||
* registration and emit a warning.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
const SH_HOOKS = [
|
||||
{ name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' },
|
||||
{ name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' },
|
||||
{ name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' },
|
||||
];
|
||||
|
||||
describe('bug #1817: .sh hook registration guards', () => {
|
||||
let src;
|
||||
|
||||
// Read once — all tests in this suite share the same source snapshot.
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
try {
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
} catch {
|
||||
src = '';
|
||||
}
|
||||
|
||||
for (const { name, settingsVar } of SH_HOOKS) {
|
||||
describe(`${name} registration`, () => {
|
||||
test(`install.js checks file existence before registering ${name}`, () => {
|
||||
// Find the block where this .sh hook is registered.
|
||||
// Each registration block is preceded by the command variable declaration
|
||||
// and followed by the next hook or end of registration section.
|
||||
const varIdx = src.indexOf(settingsVar);
|
||||
assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`);
|
||||
|
||||
// Extract ~900 chars around the variable to find the registration block
|
||||
const blockStart = Math.max(0, varIdx - 50);
|
||||
const blockEnd = Math.min(src.length, varIdx + 900);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
`install.js must call fs.existsSync on the target path before registering ${name} in settings.json. ` +
|
||||
`Without this guard, hooks are registered even when the .sh file was never copied ` +
|
||||
`(the root cause of #1817).`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #1818, updated for #3019.
|
||||
*
|
||||
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
|
||||
* and proceed with a destructive command — that turned AI-agent
|
||||
* hallucinations into accidental data loss (e.g. `phases clear --help`
|
||||
* deleting phase dirs because the flag was dropped).
|
||||
*
|
||||
* #3019 update: the same destructive-protection invariant still holds,
|
||||
* but the response shape changed. Previously --help → non-zero error
|
||||
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
|
||||
* the command. Both shapes satisfy the original invariant ("the
|
||||
* destructive command did not execute"); the new shape also restores
|
||||
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
|
||||
*
|
||||
* The tests therefore assert two things:
|
||||
* 1. The destructive command did NOT run (anti-hallucination invariant).
|
||||
* 2. The output contains the top-level usage (#3019 discoverability).
|
||||
*
|
||||
* --version remains rejected — it's never a valid gsd-tools flag and has
|
||||
* no discovery use-case.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
|
||||
|
||||
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// ── --help renders usage and does NOT run the destructive command ────────
|
||||
|
||||
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
|
||||
// Create a sentinel phase dir so we can assert it survives.
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
|
||||
|
||||
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true, 'help renders, no error exit');
|
||||
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
|
||||
// Anti-hallucination invariant: the destructive command did NOT run.
|
||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
|
||||
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
|
||||
});
|
||||
|
||||
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
|
||||
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
|
||||
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
|
||||
// The control output is just the slug; the help output is the usage.
|
||||
const slugOut = ok.output;
|
||||
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
|
||||
|
||||
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
|
||||
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
|
||||
});
|
||||
|
||||
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
|
||||
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
// success:true + isUsageOutput is sufficient: if the destructive path
|
||||
// had executed it would have emitted a phase-resolution error to stderr
|
||||
// (success:false), not the usage to stdout (success:true).
|
||||
});
|
||||
|
||||
test('state load --help renders usage', () => {
|
||||
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
// ── -h shorthand: same shape ─────────────────────────────────────────────
|
||||
|
||||
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
|
||||
});
|
||||
|
||||
test('generate-slug hello -h renders usage', () => {
|
||||
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
// ── --version is still rejected — no discovery use-case ──────────────────
|
||||
|
||||
test('generate-slug hello --version is rejected', () => {
|
||||
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.match(result.error, /--version/);
|
||||
});
|
||||
|
||||
// ── current-timestamp --help: same as the others ─────────────────────────
|
||||
|
||||
test('current-timestamp --help renders usage', () => {
|
||||
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1826
|
||||
*
|
||||
* `phases clear` must require an explicit --confirm flag before deleting any
|
||||
* phase directories. Without it, any accidental or hallucinated invocation
|
||||
* wipes the entire .planning/phases/ tree with no warning.
|
||||
*
|
||||
* Rules:
|
||||
* - Phase dirs present + no --confirm → non-zero exit, clear error message
|
||||
* - Phase dirs present + --confirm → deletes, exits 0, reports count
|
||||
* - No phase dirs + no --confirm → exits 0, cleared=0 (nothing to guard)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('bug #1826: phases clear --confirm guard', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('phases clear without --confirm is rejected when phase dirs exist', () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
||||
|
||||
assert.ok(!result.success, 'should exit non-zero when dirs exist and --confirm absent');
|
||||
assert.ok(
|
||||
result.error.includes('--confirm'),
|
||||
`error message must mention --confirm; got: ${result.error}`
|
||||
);
|
||||
|
||||
// Dirs must be untouched
|
||||
assert.ok(fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs must not be deleted');
|
||||
assert.ok(fs.existsSync(path.join(phasesDir, '02-api')), 'dirs must not be deleted');
|
||||
});
|
||||
|
||||
test('phases clear --confirm deletes dirs and reports count', () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools(['phases', 'clear', '--confirm'], tmpDir);
|
||||
|
||||
assert.ok(result.success, `should succeed with --confirm: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cleared, 2);
|
||||
assert.ok(!fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs should be removed');
|
||||
});
|
||||
|
||||
test('phases clear without --confirm succeeds when no phase dirs exist', () => {
|
||||
// .planning/phases/ exists but is empty — nothing to guard
|
||||
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
||||
|
||||
assert.ok(result.success, `should succeed with empty phases dir: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cleared, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1829
|
||||
*
|
||||
* model_profile: "inherit" in .planning/config.json was not recognised as a
|
||||
* valid profile. resolveModelInternal() silently fell back to "balanced",
|
||||
* causing all agents to use "sonnet" instead of inheriting the parent model.
|
||||
*
|
||||
* Root cause in core.cjs:
|
||||
* const profile = config.model_profile || 'balanced';
|
||||
* const agentModels = MODEL_PROFILES[agentType];
|
||||
* if (!agentModels) return 'sonnet';
|
||||
* const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
|
||||
* // agentModels['inherit'] is undefined → falls through to agentModels['balanced']
|
||||
*
|
||||
* Fix 1 (core.cjs): add early return — if (profile === 'inherit') return 'inherit';
|
||||
* Fix 2 (verify.cjs): add 'inherit' to validProfiles so it doesn't trigger W004.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { resolveModelInternal } = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeConfig(tmpDir, obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalProjectMd(tmpDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\n\nContent.\n\n## Core Value\n\nContent.\n\n## Requirements\n\nContent.\n'
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalRoadmap(tmpDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n### Phase 1: First Phase\n'
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalStateMd(tmpDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Session State\n\n## Current Position\n\nPhase: 1\n'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── resolveModelInternal — inherit profile ───────────────────────────────────
|
||||
|
||||
describe('bug #1829: model_profile "inherit" — resolveModelInternal', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('returns "inherit" for gsd-planner when model_profile is "inherit"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
|
||||
});
|
||||
|
||||
test('returns "inherit" for gsd-executor when model_profile is "inherit"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'inherit');
|
||||
});
|
||||
|
||||
test('returns "inherit" for gsd-phase-researcher when model_profile is "inherit"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-phase-researcher'), 'inherit');
|
||||
});
|
||||
|
||||
test('returns "inherit" for gsd-codebase-mapper when model_profile is "inherit"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'inherit');
|
||||
});
|
||||
|
||||
test('returns "inherit" for gsd-verifier when model_profile is "inherit"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-verifier'), 'inherit');
|
||||
});
|
||||
|
||||
test('returns "inherit" for unknown agent with inherit profile', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit');
|
||||
});
|
||||
|
||||
test('per-agent override takes precedence over inherit profile', () => {
|
||||
writeConfig(tmpDir, {
|
||||
model_profile: 'inherit',
|
||||
model_overrides: { 'gsd-executor': 'haiku' },
|
||||
});
|
||||
// Override wins even when profile is inherit
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku');
|
||||
// Other agents without override still inherit
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
|
||||
});
|
||||
|
||||
test('does not silently fall back to "sonnet" (the original bug)', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||
// Before the fix, this returned 'sonnet' (via balanced fallback)
|
||||
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||
assert.notStrictEqual(model, 'sonnet', 'inherit profile must not silently fall back to sonnet');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolve-model CLI — inherit profile ──────────────────────────────────────
|
||||
|
||||
describe('bug #1829: model_profile "inherit" — resolve-model CLI', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('CLI resolve-model returns "inherit" for gsd-executor with inherit profile', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'inherit' }, null, 2)
|
||||
);
|
||||
|
||||
const result = runGsdTools('resolve-model gsd-executor', tmpDir);
|
||||
assert.ok(result.success, `resolve-model failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.model, 'inherit');
|
||||
assert.strictEqual(parsed.profile, 'inherit');
|
||||
});
|
||||
|
||||
test('CLI resolve-model returns "inherit" for gsd-planner with inherit profile', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'inherit' }, null, 2)
|
||||
);
|
||||
|
||||
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||||
assert.ok(result.success, `resolve-model failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.model, 'inherit');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verify health — inherit profile is not a validation error ────────────────
|
||||
|
||||
describe('bug #1829: model_profile "inherit" — validate health does not warn W004', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first-phase'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('does not emit W004 for model_profile "inherit"', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'inherit',
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `validate health failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W004'),
|
||||
`inherit profile must not trigger W004: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('still emits W004 for genuinely invalid model_profile values', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'invalid-profile' }, null, 2)
|
||||
);
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `validate health failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W004'),
|
||||
`Invalid profile should trigger W004: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1834
|
||||
*
|
||||
* The installer must copy all three .sh hook files to the target hooks/
|
||||
* directory during installation. In v1.32.0, only .js hooks were deployed
|
||||
* because the install loop did not handle non-.js files from hooks/dist/.
|
||||
*
|
||||
* This test runs the actual installer (not a simulation) and verifies that
|
||||
* gsd-session-state.sh, gsd-validate-commit.sh, and gsd-phase-boundary.sh
|
||||
* are present and executable in the target hooks directory.
|
||||
*
|
||||
* Distinct from:
|
||||
* #1656 — .sh files missing from build-hooks.js HOOKS_TO_COPY
|
||||
* #1817 — settings.json registration ran even when .sh files were absent
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const SH_HOOKS = [
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
];
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup wrapper; try/catch swallows ENOENT so runInstaller teardown never fails the test
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer targeting a temp directory.
|
||||
* Uses CLAUDE_CONFIG_DIR to redirect the global install target.
|
||||
* Returns the path to the installed hooks directory.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
// --no-sdk: this test covers hook deployment only; skip SDK build to avoid
|
||||
// flakiness and keep the test fast (SDK install path has dedicated coverage
|
||||
// in install-smoke.yml).
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: configDir,
|
||||
},
|
||||
});
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. End-to-end install: .sh hooks are deployed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#1834: installer deploys .sh hooks alongside .js hooks', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-install-1834-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-session-state.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-session-state.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-session-state.sh must be installed to hooks/ — missing file causes SessionStart hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-validate-commit.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-validate-commit.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-validate-commit.sh must be installed to hooks/ — missing file causes PreToolUse hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-phase-boundary.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-phase-boundary.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-phase-boundary.sh must be installed to hooks/ — missing file causes PostToolUse hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('all three .sh hooks are present after a single install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
for (const hook of SH_HOOKS) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, hook)),
|
||||
`${hook} must be present in hooks/ after install`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('.sh hooks are executable after install', {
|
||||
skip: isWindows ? 'Windows does not support POSIX file permissions' : false,
|
||||
}, () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
for (const hook of SH_HOOKS) {
|
||||
const stat = fs.statSync(path.join(hooksDir, hook));
|
||||
assert.ok(
|
||||
(stat.mode & 0o111) !== 0,
|
||||
`${hook} must be executable (chmod +x) after install — missing +x causes hook invocation failures`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
// allow-test-rule: structural-implementation-guard
|
||||
// gsd-tools.cjs @file: resolution is a low-level stdout interception that cannot be
|
||||
// exercised end-to-end via runGsdTools without a real workflow that emits @file: output.
|
||||
// These structural tests guard the interception wiring until a behavioral integration
|
||||
// test suite for the full @file: path is added.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1891
|
||||
*
|
||||
* gsd-tools.cjs must transparently resolve @file: references in stdout
|
||||
* so that workflows never see the @file: prefix. This eliminates the
|
||||
* bash-specific `if [[ "$INIT" == @file:* ]]` check that breaks on
|
||||
* PowerShell and other non-bash shells.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
describe('bug #1891: @file: resolution in gsd-tools.cjs', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
|
||||
});
|
||||
|
||||
test('main() intercepts stdout and resolves @file: references', () => {
|
||||
// The non-pick path should have @file: resolution, just like the --pick path
|
||||
assert.ok(
|
||||
src.includes("captured.startsWith('@file:')") ||
|
||||
src.includes('captured.startsWith(\'@file:\')'),
|
||||
'main() should check for @file: prefix in captured output'
|
||||
);
|
||||
});
|
||||
|
||||
test('@file: resolution reads file content via readFileSync', () => {
|
||||
// Verify the resolution reads the actual file
|
||||
assert.ok(
|
||||
src.includes("readFileSync(captured.slice(6)") ||
|
||||
src.includes('readFileSync(captured.slice(6)'),
|
||||
'@file: resolution should read file at the path after the prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('stdout interception wraps runCommand in the non-pick path', () => {
|
||||
// The main function should resolve @file: output in BOTH --pick and
|
||||
// non-pick paths. This can be either two inline checks or a shared helper.
|
||||
const mainFunc = src.slice(src.indexOf('async function main()'));
|
||||
const resolveCalls = (mainFunc.match(/resolveAtFileOutput\(/g) || []).length;
|
||||
const inlineAtFileChecks = (mainFunc.match(/@file:/g) || []).length;
|
||||
assert.ok(
|
||||
resolveCalls >= 2 || inlineAtFileChecks >= 2,
|
||||
'Both --pick and normal paths should resolve @file: references'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
}
|
||||
|
||||
test('bridge collapse removes cjs-sdk-bridge and runtime-bridge-sync seam', () => {
|
||||
const bridgePath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'cjs-sdk-bridge.cjs');
|
||||
const sdkDir = path.join(ROOT, 'sdk');
|
||||
|
||||
assert.equal(fs.existsSync(bridgePath), false, 'cjs-sdk-bridge.cjs must be removed');
|
||||
assert.equal(fs.existsSync(sdkDir), false, 'sdk directory must be removed');
|
||||
|
||||
const routers = [
|
||||
'gsd-core/bin/lib/init-command-router.cjs',
|
||||
'gsd-core/bin/lib/roadmap-command-router.cjs',
|
||||
'gsd-core/bin/lib/state-command-router.cjs',
|
||||
'gsd-core/bin/lib/validate-command-router.cjs',
|
||||
'gsd-core/bin/lib/verify-command-router.cjs',
|
||||
'gsd-core/bin/lib/phases-command-router.cjs',
|
||||
];
|
||||
|
||||
for (const rel of routers) {
|
||||
const src = read(rel);
|
||||
assert.equal(
|
||||
src.includes('cjs-sdk-bridge.cjs'),
|
||||
false,
|
||||
`${rel} must not import cjs-sdk-bridge.cjs`,
|
||||
);
|
||||
}
|
||||
|
||||
const rootPkg = JSON.parse(read('package.json'));
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(rootPkg.dependencies || {}, 'synckit'),
|
||||
false,
|
||||
'package.json must not include synckit',
|
||||
);
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1906
|
||||
*
|
||||
* Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so
|
||||
* hooks resolve correctly regardless of the shell's current working directory.
|
||||
*
|
||||
* The original bug: local install hook commands used bare relative paths like
|
||||
* `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash
|
||||
* tool's cwd between calls, so a single `cd subdir && …` early in a session
|
||||
* permanently broke every hook for the rest of that session.
|
||||
*
|
||||
* The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so
|
||||
* path resolution is always anchored to the project root.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||||
const { projectLocalHookPrefix, projectShellCommandText } = projection;
|
||||
|
||||
describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => {
|
||||
before(() => {
|
||||
assert.equal(typeof projectLocalHookPrefix, 'function');
|
||||
assert.equal(typeof projectShellCommandText, 'function');
|
||||
});
|
||||
|
||||
test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => {
|
||||
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||||
assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude');
|
||||
});
|
||||
|
||||
test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => {
|
||||
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||||
const command = projectShellCommandText({
|
||||
runnerToken: '"/usr/local/bin/node"',
|
||||
argTokens: [`${prefix}/hooks/gsd-context-monitor.js`],
|
||||
runtime: 'claude',
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.equal(
|
||||
command,
|
||||
'"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #1908
|
||||
*
|
||||
* `--uninstall` did not remove `gsd-file-manifest.json` from the target
|
||||
* directory, leaving a stale metadata file after uninstall.
|
||||
*
|
||||
* Fix: `uninstall()` must call
|
||||
* fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true })
|
||||
* after cleaning up the rest of the GSD artefacts.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { uninstall } = require('../bin/install.js');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function createFakeInstall(prefix = 'gsd-uninstall-test-') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
|
||||
// Simulate the minimum directory/file layout produced by the installer:
|
||||
// gsd-core/ directory, agents/ directory, and the manifest file.
|
||||
fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
|
||||
|
||||
fs.mkdirSync(path.join(dir, 'agents'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub');
|
||||
|
||||
const manifest = {
|
||||
version: '1.34.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
files: {
|
||||
'gsd-core/workflows/execute-phase.md': 'abc123',
|
||||
'agents/gsd-executor.md': 'def456',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('uninstall — manifest cleanup (#1908)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFakeInstall();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json is removed after global uninstall', () => {
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
|
||||
// Pre-condition: manifest exists before uninstall
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
'Test setup failure: manifest file should exist before uninstall'
|
||||
);
|
||||
|
||||
// Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir()
|
||||
// resolves to our temp directory; pass isGlobal=true)
|
||||
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
try {
|
||||
uninstall(true, 'claude');
|
||||
} finally {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(manifestPath),
|
||||
[
|
||||
`${MANIFEST_NAME} must be removed by uninstall() but still exists at`,
|
||||
manifestPath,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json is removed after local uninstall', () => {
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
'Test setup failure: manifest file should exist before uninstall'
|
||||
);
|
||||
|
||||
// For a local install, getGlobalDir is not called — targetDir = cwd + dirName.
|
||||
// Simulate by creating .claude/ inside tmpDir and placing artefacts there.
|
||||
const localDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
|
||||
const localManifestPath = path.join(localDir, MANIFEST_NAME);
|
||||
fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2));
|
||||
|
||||
const savedCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
uninstall(false, 'claude');
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(localManifestPath),
|
||||
[
|
||||
`${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`,
|
||||
localManifestPath,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1924: gsd-update silently deletes user-generated files
|
||||
*
|
||||
* Running the installer (gsd-update / re-install) must not delete:
|
||||
* - gsd-core/USER-PROFILE.md (created by /gsd-profile-user)
|
||||
* - commands/gsd/dev-preferences.md (created by /gsd-profile-user)
|
||||
*
|
||||
* Root cause:
|
||||
* 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before
|
||||
* copying — no preserve allowlist. This wipes USER-PROFILE.md.
|
||||
* 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy
|
||||
* cleanup — no preserve. This wipes dev-preferences.md.
|
||||
*
|
||||
* Fix requirement:
|
||||
* - install() must preserve USER-PROFILE.md across the gsd-core/ wipe
|
||||
* - install() must preserve dev-preferences.md across the commands/gsd/ wipe
|
||||
*
|
||||
* Closes: #1924
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ─────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory.
|
||||
* Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer
|
||||
* (not just the export block). Uses --yes to suppress interactive prompts.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
// --no-sdk: this test covers user-artifact preservation only; skip SDK
|
||||
// build (covered by install-smoke.yml) to keep the test deterministic.
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test 1: USER-PROFILE.md is preserved across re-install ─────────────────
|
||||
|
||||
describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-userprofile-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md exists after initial install + user creation', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate /gsd-profile-user creating USER-PROFILE.md inside gsd-core/
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n');
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md should exist after being created by /gsd-profile-user'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved after re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user, creating USER-PROFILE.md
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
const originalContent = '# My Profile\n\nThis is my custom user profile content.\n';
|
||||
fs.writeFileSync(profilePath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(profilePath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'USER-PROFILE.md content must be identical after re-install'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved even when gsd-core/ is wiped and recreated', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const gsdDir = path.join(tmpDir, 'gsd-core');
|
||||
const profilePath = path.join(gsdDir, 'USER-PROFILE.md');
|
||||
|
||||
// Confirm gsd-core/ was created by install
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must exist after install');
|
||||
|
||||
// Write profile
|
||||
fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n');
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// gsd-core/ must still exist AND profile must be intact
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must still exist after re-install');
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must still exist after gsd-core/ was wiped and recreated'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: dev-preferences.md is preserved across re-install ───────────────
|
||||
|
||||
describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-devprefs-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => {
|
||||
// First install (creates skills/ structure for global Claude)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
||||
fs.writeFileSync(devPrefsPath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update).
|
||||
// In the layout-driven path (B2), legacy commands/gsd/ is removed and
|
||||
// dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973).
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Content is migrated to the new canonical skills location (#2973).
|
||||
// The old commands/gsd/ path is cleaned up; the skill file carries the content.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'migrated dev-preferences content must be identical to the original'
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy non-user GSD commands are still cleaned up during re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate a legacy GSD command file being left in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const legacyFile = path.join(commandsGsdDir, 'next.md');
|
||||
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
||||
|
||||
// But dev-preferences.md is also there (user-generated)
|
||||
const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n';
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
fs.writeFileSync(devPrefsPath, devPrefsContent);
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// In the layout-driven path (B2), commands/gsd/ is fully removed but
|
||||
// dev-preferences.md content is migrated to the new canonical skill location.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md'
|
||||
);
|
||||
|
||||
// The legacy GSD command (next.md) is NOT user-generated, must be removed
|
||||
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyFile),
|
||||
'legacy GSD command next.md in commands/gsd/ must be removed during cleanup'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: profile-user.md backup path is outside gsd-core/ ───────────
|
||||
|
||||
describe('#1924: profile-user.md backup path must be outside gsd-core/', () => {
|
||||
test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/gsd-core/USER-PROFILE.backup.md', () => {
|
||||
const workflowPath = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'profile-user.md'
|
||||
);
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The backup must NOT be inside gsd-core/ because that directory is wiped on update
|
||||
assert.ok(
|
||||
!content.includes('gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must NOT be inside gsd-core/ — that directory is wiped on gsd-update'
|
||||
);
|
||||
|
||||
// The backup should be at ~/.claude/USER-PROFILE.backup.md (outside gsd-core/)
|
||||
assert.ok(
|
||||
content.includes('USER-PROFILE.backup.md') &&
|
||||
!content.includes('/gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must be outside gsd-core/ (e.g. ~/.claude/USER-PROFILE.backup.md)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: preserveUserArtifacts helper exported from install.js ────────────
|
||||
|
||||
describe('#1924: preserveUserArtifacts helper exists in install.js', () => {
|
||||
test('install.js exports preserveUserArtifacts function', () => {
|
||||
// Set GSD_TEST_MODE so require() reaches the module.exports block
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
typeof mod.preserveUserArtifacts,
|
||||
'function',
|
||||
'install.js must export preserveUserArtifacts helper for testability'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for #1967 cache invalidation.
|
||||
*
|
||||
* The disk scan cache in buildStateFrontmatter must be invalidated on
|
||||
* writeStateMd to prevent stale reads if multiple state-mutating
|
||||
* operations occur within the same Node process. This matters for:
|
||||
* - SDK callers that require() gsd-tools.cjs as a module
|
||||
* - Future dispatcher extensions that handle compound operations
|
||||
* - Tests that import state.cjs directly
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const state = require('../gsd-core/bin/lib/state.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('buildStateFrontmatter cache invalidation (#1967)', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let phasesDir;
|
||||
let statePath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1967-cache-'));
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
|
||||
// Create a minimal config and STATE.md
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ project_code: 'TEST' })
|
||||
);
|
||||
|
||||
statePath = path.join(planningDir, 'STATE.md');
|
||||
fs.writeFileSync(statePath, [
|
||||
'# State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** executing',
|
||||
'**Total Phases:** 2',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
// Start with one phase directory containing one PLAN
|
||||
const phase1 = path.join(phasesDir, '01-foo');
|
||||
fs.mkdirSync(phase1);
|
||||
fs.writeFileSync(path.join(phase1, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writeStateMd invalidates cache so subsequent reads see new disk state', () => {
|
||||
// First write — populates cache via buildStateFrontmatter
|
||||
const content1 = fs.readFileSync(statePath, 'utf-8');
|
||||
state.writeStateMd(statePath, content1, tmpDir);
|
||||
|
||||
// Create a NEW phase directory AFTER the first write
|
||||
// Without cache invalidation, the second write would still see only 1 phase
|
||||
const phase2 = path.join(phasesDir, '02-bar');
|
||||
fs.mkdirSync(phase2);
|
||||
fs.writeFileSync(path.join(phase2, '02-1-PLAN.md'), '---\nphase: 2\nplan: 1\n---\n# Plan\n');
|
||||
fs.writeFileSync(path.join(phase2, '02-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\n');
|
||||
|
||||
// Second write in the SAME process — must see the new phase
|
||||
const content2 = fs.readFileSync(statePath, 'utf-8');
|
||||
state.writeStateMd(statePath, content2, tmpDir);
|
||||
|
||||
// Read back and parse frontmatter to verify it reflects 2 phases, not 1
|
||||
const result = fs.readFileSync(statePath, 'utf-8');
|
||||
const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd');
|
||||
|
||||
const fm = fmMatch[1];
|
||||
// Should show 2 total phases (the new disk state), not 1 (stale cache)
|
||||
const totalPhasesMatch = fm.match(/total_phases:\s*(\d+)/);
|
||||
assert.ok(totalPhasesMatch, 'frontmatter should contain total_phases');
|
||||
assert.strictEqual(
|
||||
parseInt(totalPhasesMatch[1], 10),
|
||||
2,
|
||||
'total_phases should reflect new disk state (2), not stale cache (1)'
|
||||
);
|
||||
|
||||
// Should show 1 completed phase (phase 2 has SUMMARY)
|
||||
const completedMatch = fm.match(/completed_phases:\s*(\d+)/);
|
||||
assert.ok(completedMatch, 'frontmatter should contain completed_phases');
|
||||
assert.strictEqual(
|
||||
parseInt(completedMatch[1], 10),
|
||||
1,
|
||||
'completed_phases should reflect new disk state (1 complete), not stale cache (0)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. On CRITICAL + active GSD project, the hook sets criticalRecorded in the
|
||||
* warn sentinel AND the state record-session command writes the "Stopped At"
|
||||
* field to STATE.md.
|
||||
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
|
||||
* the subprocess (sentinel guard prevents repeated overwrites).
|
||||
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
|
||||
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
||||
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
|
||||
*
|
||||
* Design note (#3726, #3775): the original test used a short wall-clock poll
|
||||
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
|
||||
* We keep one deterministic assertion (criticalRecorded sentinel is written
|
||||
* before hook exit), and use a bounded poll window for the detached writer's
|
||||
* STATE.md update. A separate test verifies direct record-session invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup, delay } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// Windows can hold a transient handle on the temp dir after a spawnSync child
|
||||
// exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry
|
||||
// (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a
|
||||
// bounded outer retry with async backoff via the shared delay() helper.
|
||||
// Re-adds the guard removed in #482. Refs #490.
|
||||
async function cleanupWithRetry(dir, attempts = 8) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try { cleanup(dir); return; }
|
||||
catch (err) {
|
||||
const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY');
|
||||
if (!transient || i === attempts - 1) throw err;
|
||||
await delay(100 * (i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the hook with a given session id and context percentage.
|
||||
* Writes a bridge metrics file first, then pipes the hook input via stdin.
|
||||
* Returns after the hook exits.
|
||||
*/
|
||||
function runHook(sessionId, remainingPct, cwd) {
|
||||
// Write the bridge metrics file the hook reads
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: 100 - remainingPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, HOME: process.env.HOME },
|
||||
});
|
||||
|
||||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gsd-tools state record-session synchronously.
|
||||
* Returns { exitCode, stdout, stderr }.
|
||||
* Used to verify the persistence seam deterministically without relying on
|
||||
* the fire-and-forget subprocess timing that caused flake (#3726).
|
||||
*/
|
||||
function runRecordSession(cwd, stoppedAt) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
);
|
||||
return {
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the warn sentinel file for a session.
|
||||
* Returns the parsed object, or null if the file does not exist.
|
||||
*/
|
||||
function readWarnData(sessionId) {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(warnPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#1974 context exhaustion auto-record', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let sessionId;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
|
||||
// Minimal STATE.md with Stopped At field
|
||||
statePath = path.join(planningDir, 'STATE.md');
|
||||
fs.writeFileSync(statePath, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** executing',
|
||||
'**Last session:** unset',
|
||||
'**Last Date:** unset',
|
||||
'**Stopped At:** None',
|
||||
'**Resume File:** None',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
// Minimal config.json required by gsd-tools
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
|
||||
|
||||
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// cleanupWithRetry wraps cleanup() with a bounded outer retry (async setTimeout
|
||||
// backoff, no Atomics.wait) to handle cases where windows-2022 CI load keeps
|
||||
// the temp dir EBUSY beyond rmSync's internal ~5s retry window. Refs #490.
|
||||
await cleanupWithRetry(tmpDir);
|
||||
// Clean up bridge files
|
||||
try {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('sets criticalRecorded sentinel on CRITICAL (synchronous assertion only)', () => {
|
||||
// Trigger CRITICAL — remaining <= 25
|
||||
// The detached record-session subprocess timing assertion (waitForStateMatch,
|
||||
// 45s poll) was removed per #453 (clock-seam): flaky under load. The
|
||||
// deterministic coverage for STATE.md persistence lives in the
|
||||
// 'state record-session command persists Stopped At when invoked directly'
|
||||
// test below, which uses spawnSync instead of a fire-and-forget subprocess.
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// Deterministic: hook writes criticalRecorded:true to warnPath SYNCHRONOUSLY
|
||||
// before the hook process exits, before the fire-and-forget subprocess runs.
|
||||
// Since runHook() uses spawnSync, this is guaranteed readable now.
|
||||
const warnData = readWarnData(sessionId);
|
||||
assert.ok(warnData, 'warn sentinel file must exist after CRITICAL fire');
|
||||
assert.strictEqual(
|
||||
warnData.criticalRecorded,
|
||||
true,
|
||||
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
|
||||
// Delete STATE.md to simulate non-GSD project
|
||||
fs.unlinkSync(statePath);
|
||||
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
|
||||
// The hook checks isGsdActive via fs.existsSync(STATE.md) before setting
|
||||
// criticalRecorded. If STATE.md is absent, criticalRecorded must NOT be set.
|
||||
const warnData = readWarnData(sessionId);
|
||||
// warnData may exist (hook still debounces) but criticalRecorded must be absent/falsy.
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'criticalRecorded must not be set when STATE.md is absent');
|
||||
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
|
||||
});
|
||||
|
||||
test('sentinel prevents repeated firing within same session', () => {
|
||||
// First CRITICAL fire — should set criticalRecorded synchronously.
|
||||
const result1 = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result1.exitCode, 0, `first hook fire should exit 0: ${result1.stderr}`);
|
||||
|
||||
const warnData1 = readWarnData(sessionId);
|
||||
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
|
||||
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
|
||||
|
||||
// Second CRITICAL fire — same session, criticalRecorded already true in
|
||||
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
|
||||
// line 29) so the hook processes the warning message path and exercises
|
||||
// the sentinel guard. Using 10 (2× DEBOUNCE_CALLS) ensures we clear the
|
||||
// debounce threshold regardless of any future DEBOUNCE_CALLS adjustment.
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
const warnDataPatched = { ...warnData1, callsSinceWarn: 10 };
|
||||
fs.writeFileSync(warnPath, JSON.stringify(warnDataPatched));
|
||||
|
||||
const result2 = runHook(sessionId, 18, tmpDir);
|
||||
assert.strictEqual(result2.exitCode, 0, `second hook fire should exit 0: ${result2.stderr}`);
|
||||
|
||||
// The warnData must still carry criticalRecorded:true — the guard was
|
||||
// active and the hook did not reset or clear it.
|
||||
const warnData2 = readWarnData(sessionId);
|
||||
assert.strictEqual(warnData2 && warnData2.criticalRecorded, true, 'sentinel must remain true after second fire');
|
||||
|
||||
// The hook's stdout must still emit a CRITICAL warning message (so the
|
||||
// agent sees context warnings) even though record-session was NOT re-fired.
|
||||
const output2 = result2.stdout ? (() => { try { return JSON.parse(result2.stdout); } catch { return null; } })() : null;
|
||||
assert.ok(
|
||||
output2 && output2.hookSpecificOutput && /CONTEXT CRITICAL/.test(output2.hookSpecificOutput.additionalContext),
|
||||
'second CRITICAL fire must still emit CONTEXT CRITICAL warning to the agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('state record-session command persists Stopped At when invoked directly', () => {
|
||||
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
|
||||
assert.strictEqual(
|
||||
recordResult.exitCode,
|
||||
0,
|
||||
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
|
||||
);
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
|
||||
});
|
||||
|
||||
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
|
||||
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
|
||||
const result = runHook(sessionId, 30, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// criticalRecorded must NOT be set on a WARNING-only fire
|
||||
const warnData = readWarnData(sessionId);
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'WARNING-only fire must not set criticalRecorded');
|
||||
});
|
||||
|
||||
// 'hook uses __dirname-based path (runtime-agnostic)' deleted per #453 (clock-seam):
|
||||
// source-grep of HOOK_PATH for path.join(__dirname is brittle. The behavioral equivalent
|
||||
// (hook successfully resolves gsd-tools.cjs from any working directory) is already covered
|
||||
// by the runHook() helper throughout this test file — it calls the hook from an arbitrary
|
||||
// tmpDir and all tests pass, proving __dirname-relative resolution works.
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2002
|
||||
*
|
||||
* offer_next in execute-phase.md must present conditional next steps
|
||||
* based on whether CONTEXT.md already exists for the next phase.
|
||||
* The previous flat list offered all options equally with no primary
|
||||
* recommendation, leaving agents without guidance on the correct first step.
|
||||
*
|
||||
* Fixed: offer_next now checks for {next}-CONTEXT.md in the phase directory.
|
||||
* - If CONTEXT.md is missing: primary suggestion is /gsd-discuss-phase
|
||||
* - If CONTEXT.md exists: primary suggestion is /gsd-plan-phase
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2002: offer_next checks CONTEXT.md before suggesting next step', () => {
|
||||
let content;
|
||||
|
||||
// Read once — all tests share the same file content
|
||||
test('setup: workflow file is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('offer_next section checks for CONTEXT.md existence', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The workflow must check for CONTEXT.md in the next phase directory
|
||||
assert.ok(
|
||||
content.includes('CONTEXT.md'),
|
||||
'offer_next must reference CONTEXT.md to determine primary next step'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-discuss-phase when CONTEXT.md does not exist', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have a conditional path where discuss-phase is the primary step
|
||||
// when CONTEXT.md is missing — look for proximity of "not exist"/"missing"/
|
||||
// "does not exist" and "gsd-discuss-phase" in the offer_next step
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
// Use 5000-char window — the step is ~60 lines of prose before the conditionals
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*does not exist|CONTEXT\.md.*not.*exist|If CONTEXT\.md does/i.test(offerNextSection) ||
|
||||
/gsd-discuss-phase.*recommended|recommended.*gsd-discuss-phase/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-discuss-phase as primary when CONTEXT.md does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-plan-phase when CONTEXT.md exists', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*exists|exists.*CONTEXT\.md|If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-plan-phase as primary when CONTEXT.md exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next section contains at least one conditional guard before listing commands', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
|
||||
// The fixed version must contain at least one "If CONTEXT.md" conditional
|
||||
// guard before presenting command options. The old flat list had no guard.
|
||||
assert.ok(
|
||||
/If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must contain at least one "If CONTEXT.md" conditional guard'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2004
|
||||
*
|
||||
* /gsd-pr-branch must not exclude milestone archive and structural planning
|
||||
* commits. The previous implementation filtered ALL .planning/-only commits,
|
||||
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
|
||||
* that are needed to preserve repository planning state after a merge.
|
||||
*
|
||||
* Fixed: pr-branch.md now distinguishes:
|
||||
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
|
||||
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
|
||||
* PROJECT.md, milestones/**) → INCLUDE
|
||||
* - Code commits (any non-.planning/ file) → INCLUDE
|
||||
* - Mixed commits (code + planning) → INCLUDE
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md'
|
||||
);
|
||||
|
||||
describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
||||
let content;
|
||||
|
||||
test('setup: pr-branch workflow is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
|
||||
});
|
||||
|
||||
test('workflow distinguishes structural vs transient planning commits', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must contain language distinguishing structural from transient/phase planning files
|
||||
assert.ok(
|
||||
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
|
||||
'pr-branch.md must distinguish structural planning commits from transient ones'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'pr-branch.md must reference STATE.md as a structural file to preserve'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('ROADMAP.md'),
|
||||
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('MILESTONES.md') || content.includes('milestones/'),
|
||||
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have at least a "structural" or "milestone" category beyond the original three
|
||||
assert.ok(
|
||||
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
|
||||
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
|
||||
'pr-branch.md must classify structural planning commits as INCLUDE'
|
||||
);
|
||||
});
|
||||
|
||||
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The original bug: `git rm -r --cached .planning/` nuked structural files.
|
||||
// The fix must either remove this wholesale rm or scope it to transient dirs.
|
||||
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
|
||||
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
|
||||
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
|
||||
.test(content);
|
||||
assert.ok(
|
||||
!hasUnscoped,
|
||||
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Regression guard — Bug #21
|
||||
*
|
||||
* Both STATE.md template files must include a YAML frontmatter block in their
|
||||
* "File Template" section so that an AI agent creating .planning/STATE.md from
|
||||
* the template produces a file that frontmatter consumers can read immediately
|
||||
* (before the first `state.*` mutation calls syncStateFrontmatter).
|
||||
*
|
||||
* Prior to the fix, the template's File Template section began with
|
||||
* `# Project State` (no frontmatter), leaving the init→first-write window
|
||||
* without `gsd_state_version`, `status`, or `progress` keys.
|
||||
*
|
||||
* Acceptance criteria:
|
||||
* 1. The template body extracted from each state.md file's File Template code
|
||||
* block must begin with `---`.
|
||||
* 2. The frontmatter must contain at minimum: `gsd_state_version` and `status`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
const TEMPLATE_PATHS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'templates', 'state.md'),
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the content of the first ```markdown ... ``` code block from a
|
||||
* template file. Returns the raw string (including any leading/trailing
|
||||
* whitespace within the block).
|
||||
*
|
||||
* @param {string} fileContent - Full text of the template file.
|
||||
* @returns {string} The extracted code block body.
|
||||
*/
|
||||
function extractFileTemplate(fileContent) {
|
||||
const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/);
|
||||
assert.ok(match, 'No ```markdown code block found in template file');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns the set of top-level keys present
|
||||
* in the first --- ... --- block at the start of `text`. Does not parse nested
|
||||
* keys — list-valued fields (e.g. `tags: [a, b]`) are recorded only by their
|
||||
* key name, not their value. Returns an empty Set when the text has no frontmatter.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function parseFrontmatterKeys(text) {
|
||||
const keys = new Set();
|
||||
if (!text.trimStart().startsWith('---')) return keys;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break; // frontmatter must be at the very start
|
||||
}
|
||||
if (trimmed === '---') break; // end of block
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
keys.add(trimmed.slice(0, colonIdx).trim());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns a plain object of top-level keys
|
||||
* and their scalar or nested-object values from the first --- ... --- block.
|
||||
* Handles one level of indented nesting (e.g. progress.total_plans).
|
||||
* Does not handle YAML lists or multi-line values.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
const result = {};
|
||||
if (!text.trimStart().startsWith('---')) return result;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
let currentKey = null;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break;
|
||||
}
|
||||
if (trimmed === '---') break;
|
||||
// Detect indented (nested) line: starts with whitespace
|
||||
if (line.match(/^\s+\S/) && currentKey !== null) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const subKey = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
const numVal = Number(rawVal);
|
||||
if (typeof result[currentKey] !== 'object') result[currentKey] = {};
|
||||
result[currentKey][subKey] = rawVal === '' ? null : (isNaN(numVal) ? rawVal : numVal);
|
||||
}
|
||||
} else {
|
||||
currentKey = null;
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
if (rawVal === '') {
|
||||
result[key] = {};
|
||||
currentKey = key;
|
||||
} else {
|
||||
const numVal = Number(rawVal);
|
||||
result[key] = isNaN(numVal) ? rawVal.replace(/^'|'$/g, '') : numVal;
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('bug #21 — STATE.md template must carry YAML frontmatter', () => {
|
||||
for (const templatePath of TEMPLATE_PATHS) {
|
||||
const label = path.relative(REPO_ROOT, templatePath);
|
||||
|
||||
test(`${label} — File Template block starts with frontmatter`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
|
||||
// The template body must open with a YAML frontmatter delimiter.
|
||||
assert.ok(
|
||||
body.trimStart().startsWith('---'),
|
||||
`${label}: File Template must start with '---' (YAML frontmatter), ` +
|
||||
`but starts with: ${JSON.stringify(body.slice(0, 60))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains gsd_state_version`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('gsd_state_version'),
|
||||
`${label}: frontmatter must include 'gsd_state_version', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains status`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('status'),
|
||||
`${label}: frontmatter must include 'status', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — progress sub-schema has zeroed total_plans and completed_plans`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const fm = parseFrontmatter(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
fm.progress && typeof fm.progress === 'object',
|
||||
`${label}: frontmatter must include a 'progress' sub-object`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.total_plans,
|
||||
0,
|
||||
`${label}: progress.total_plans must be 0 in the template`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.completed_plans,
|
||||
0,
|
||||
`${label}: progress.completed_plans must be 0 in the template`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #211: gsd_run launcher must probe
|
||||
* $HOME/.claude/gsd-core/bin/gsd-tools.cjs before emitting the hard error.
|
||||
*
|
||||
* Asserts:
|
||||
* (A) The canonical snippet file contains the ~/.claude fallback arm.
|
||||
* (B) A representative propagated workflow file contains the ~/.claude fallback arm.
|
||||
* (C) Behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on PATH,
|
||||
* a stub at $HOME/.claude/gsd-core/bin/gsd-tools.cjs is resolved and invoked.
|
||||
* (D) The resolution order is preserved: local -> PATH -> ~/.claude -> hard error.
|
||||
* When all three miss, exit non-zero.
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural/behavioral regression for the ~/.claude fallback arm in
|
||||
// the gsd_run launcher snippet -- asserts literal substring presence and exercises the
|
||||
// bash resolution path via execFileSync; there is no typed IR for "snippet contains arm X".
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
// Representative propagated workflow file (has a gsd_run call):
|
||||
const REPRESENTATIVE_FILE = path.join(WORKFLOWS_DIR, 'add-backlog.md');
|
||||
|
||||
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
|
||||
|
||||
describe('bug-211: launcher ~/.claude home fallback', () => {
|
||||
// --- (A) Snippet contains the arm ----------------------------------------
|
||||
test('(A) snippet file contains the $HOME/.claude fallback arm', () => {
|
||||
const content = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
assert.ok(
|
||||
content.includes(CLAUDE_HOME_PROBE),
|
||||
`_runtime-launcher.snippet.sh must contain "${CLAUDE_HOME_PROBE}" (the ~/.claude fallback arm). ` +
|
||||
`Found snippet content:\n${content.trim()}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- (B) Representative propagated file contains the arm ------------------
|
||||
test('(B) add-backlog.md (representative propagated file) contains the $HOME/.claude fallback arm', () => {
|
||||
const content = fs.readFileSync(REPRESENTATIVE_FILE, 'utf8');
|
||||
assert.ok(
|
||||
content.includes(CLAUDE_HOME_PROBE),
|
||||
`add-backlog.md must contain "${CLAUDE_HOME_PROBE}" after propagation. ` +
|
||||
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate the updated snippet.`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- (C) Behavioral: ~/.claude stub is resolved when local and PATH both miss
|
||||
test('(C) gsd_run resolves $HOME/.claude/gsd-core/bin/ stub when no local install and gsd-tools not on PATH', () => {
|
||||
// Build a fake $HOME with a stub at .claude/gsd-core/bin/gsd-tools.cjs
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-home-'));
|
||||
// RUNTIME_DIR points to a directory with no gsd-tools.cjs
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-rt-'));
|
||||
try {
|
||||
const claudeBinDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(claudeBinDir, { recursive: true });
|
||||
|
||||
// Stub gsd-tools.cjs that prints a marker
|
||||
const stubPath = path.join(claudeBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
stubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("CLAUDE_HOME_STUB:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-home-fb.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
// Build a PATH with no gsd-tools binary to force the ~/.claude arm.
|
||||
// Filter out directories that contain a gsd-tools executable. If node lives
|
||||
// in the same directory as gsd-tools, create a dedicated shim dir with a
|
||||
// symlink to node only (no gsd-tools there).
|
||||
const nodeBin = execFileSync('which', ['node'], { encoding: 'utf8' }).trim();
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try {
|
||||
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// If node's dir was filtered (it contained gsd-tools), create a shim dir
|
||||
// with just a node symlink so the stub's shebang (#!/usr/bin/env node) resolves.
|
||||
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
|
||||
if (!systemPaths.some((p) => {
|
||||
try { fs.accessSync(path.join(p, 'node'), fs.constants.X_OK); return true; }
|
||||
catch { return false; }
|
||||
})) {
|
||||
fs.mkdirSync(nodeShimDir, { recursive: true });
|
||||
fs.symlinkSync(nodeBin, path.join(nodeShimDir, 'node'));
|
||||
systemPaths.unshift(nodeShimDir);
|
||||
}
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
|
||||
});
|
||||
|
||||
// GSD_TOOLS must point into the fake ~/.claude dir
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
normStdout.includes('.claude/gsd-core/bin/'),
|
||||
`Expected GSD_TOOLS to resolve into .claude/gsd-core/bin/, got:\n${stdout.trim()}`,
|
||||
);
|
||||
// The stub must have been invoked
|
||||
assert.ok(
|
||||
stdout.includes('CLAUDE_HOME_STUB:ping,test'),
|
||||
`Expected stub output "CLAUDE_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeRuntime);
|
||||
}
|
||||
});
|
||||
|
||||
// --- (D) All three miss -> hard error -------------------------------------
|
||||
test('(D) hard error when local, PATH, and ~/.claude all miss', () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nohome-'));
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nort-'));
|
||||
// noToolsBin so PATH check finds nothing
|
||||
const noToolsBin = path.join(fakeHome, 'nobin');
|
||||
fs.mkdirSync(noToolsBin, { recursive: true });
|
||||
// NO .claude/gsd-core/bin stub created in fakeHome
|
||||
try {
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\ngsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-allfail.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try {
|
||||
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
|
||||
|
||||
let threw = false;
|
||||
let stderrOutput = '';
|
||||
try {
|
||||
execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
stderrOutput = err.stderr || '';
|
||||
}
|
||||
|
||||
assert.ok(threw, 'Expected non-zero exit when all three resolution arms miss');
|
||||
assert.ok(
|
||||
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
|
||||
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeRuntime);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// The shebang line must be `#!/usr/bin/env bash` (PATH-resolved) rather than
|
||||
// `#!/bin/bash` for cross-distro portability (NixOS, minimal Alpine do not
|
||||
// ship /bin/bash). This is an architectural constraint that cannot be verified
|
||||
// by executing the hooks — they run fine with either shebang on distros that
|
||||
// have /bin/bash, so only a source assertion catches a future regression.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2136 / #2206
|
||||
*
|
||||
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
|
||||
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
|
||||
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
|
||||
* syntax (//) — not bash comment syntax (#).
|
||||
*
|
||||
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
|
||||
* after a fresh install, because the detector saw hookVersion: 'unknown' for
|
||||
* all three bash hooks.
|
||||
*
|
||||
* This fix requires THREE parts working in concert:
|
||||
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
|
||||
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
|
||||
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
|
||||
*
|
||||
* Neither fix alone is sufficient:
|
||||
* - Headers + regex fix only (no install.js fix): installed hooks contain
|
||||
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
|
||||
* bash hook staleness permanently undetectable after future updates.
|
||||
* - Headers + install.js fix only (no regex fix): installed hooks are
|
||||
* stamped correctly but the detector still can't read bash "#" comments,
|
||||
* so they still land in the "unknown / stale" branch on every session.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
|
||||
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||
const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
||||
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
const SH_HOOKS = [
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
];
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function runInstaller(configDir) {
|
||||
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
|
||||
// build (covered by install-smoke.yml).
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
||||
});
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 1: Bash hook sources carry the version header placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
|
||||
for (const sh of SH_HOOKS) {
|
||||
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
|
||||
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
|
||||
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
|
||||
`installer can stamp it and gsd-check-update.js can detect staleness`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('version header is on line 2 (immediately after shebang)', () => {
|
||||
// Placing the header immediately after the shebang ensures it is always
|
||||
// found regardless of how much of the file is read. The shebang itself
|
||||
// must use `#!/usr/bin/env bash` (PATH-resolved) rather than `#!/bin/bash`
|
||||
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
|
||||
// do not ship /bin/bash by default.
|
||||
for (const sh of SH_HOOKS) {
|
||||
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
|
||||
assert.strictEqual(
|
||||
lines[0],
|
||||
'#!/usr/bin/env bash',
|
||||
`${sh} line 1 must be "#!/usr/bin/env bash" for cross-distro portability`
|
||||
);
|
||||
assert.ok(
|
||||
lines[1].startsWith('# gsd-hook-version:'),
|
||||
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
|
||||
// (Logic moved from inline -e template literal to dedicated worker file)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
src = fs.readFileSync(WORKER_FILE, 'utf8');
|
||||
});
|
||||
|
||||
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
|
||||
// The regex string in the source must contain the alternation for "#".
|
||||
// The worker uses plain JS (no template-literal escaping), so the form is
|
||||
// "(?:\/\/|#)" directly in source.
|
||||
const hasBashAlternative =
|
||||
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
|
||||
src.includes('(?://|#)'); // direct form in plain JS worker
|
||||
assert.ok(
|
||||
hasBashAlternative,
|
||||
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
|
||||
'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' +
|
||||
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex does not use the old JS-only form as the sole pattern', () => {
|
||||
// The old regex inside the template literal was the string:
|
||||
// /\\/\\/ gsd-hook-version:\\s*(.+)/
|
||||
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
|
||||
// That only matched JS "//" comments — never bash "#".
|
||||
// We verify that the old exact string no longer appears.
|
||||
assert.ok(
|
||||
!src.includes('\\/\\/ gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
|
||||
'escape form as the sole version matcher — it cannot match bash "#" comments'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex correctly matches both bash and JS hook version headers', () => {
|
||||
// Verify that the versionMatch line in the source uses a regex that matches
|
||||
// both bash "#" and JS "//" comment styles. We check the source contains the
|
||||
// expected alternation, then directly test the known required pattern.
|
||||
//
|
||||
// We do NOT try to extract and evaluate the regex from source (it contains ")"
|
||||
// which breaks simple extraction), so instead we confirm the source matches
|
||||
// our expectation and run the regex itself.
|
||||
assert.ok(
|
||||
src.includes('gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
|
||||
);
|
||||
|
||||
// The fixed regex that must be present: matches both comment styles
|
||||
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
|
||||
|
||||
assert.ok(
|
||||
fixedRegex.test('# gsd-hook-version: 1.36.0'),
|
||||
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
|
||||
);
|
||||
assert.ok(
|
||||
fixedRegex.test('// gsd-hook-version: 1.36.0'),
|
||||
'JS-style "// gsd-hook-version: X" must still match (no regression)'
|
||||
);
|
||||
assert.ok(
|
||||
!fixedRegex.test('gsd-hook-version: 1.36.0'),
|
||||
'line without a comment prefix must not match (prevents false positives)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-2136-install-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
|
||||
for (const sh of SH_HOOKS) {
|
||||
const hookPath = path.join(hooksDir, sh);
|
||||
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
|
||||
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version:'),
|
||||
`installed ${sh} must contain a "# gsd-hook-version:" header`
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('{{GSD_VERSION}}'),
|
||||
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
|
||||
`install.js must substitute it with the concrete package version`
|
||||
);
|
||||
|
||||
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
|
||||
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
|
||||
assert.match(
|
||||
versionMatch[1],
|
||||
/^\d+\.\d+\.\d+/,
|
||||
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
|
||||
// This is the definitive end-to-end proof: after install, run the actual
|
||||
// version-check logic (extracted from gsd-check-update.js) against the
|
||||
// installed hooks and verify none are flagged stale.
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
||||
const installedVersion = pkg.version;
|
||||
|
||||
// Build a subprocess that runs the staleness check logic in isolation.
|
||||
// We pass the installed version, hooks dir, and hook filenames as JSON
|
||||
// to avoid any injection risk.
|
||||
const checkScript = `
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function isNewer(a, b) {
|
||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] > pb[i]) return true;
|
||||
if (pa[i] < pb[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const hooksDir = ${JSON.stringify(hooksDir)};
|
||||
const installed = ${JSON.stringify(installedVersion)};
|
||||
const shHooks = ${JSON.stringify(SH_HOOKS)};
|
||||
// Use the same regex that the fixed gsd-check-update.js uses
|
||||
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
|
||||
|
||||
const staleHooks = [];
|
||||
for (const hookFile of shHooks) {
|
||||
const hookPath = path.join(hooksDir, hookFile);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
const m = content.match(versionRe);
|
||||
if (m) {
|
||||
const hookVersion = m[1].trim();
|
||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||
}
|
||||
} else {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(staleHooks));
|
||||
`;
|
||||
|
||||
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
|
||||
const staleHooks = JSON.parse(result);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
staleHooks,
|
||||
[],
|
||||
`Fresh install must produce zero stale bash hooks.\n` +
|
||||
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
|
||||
`This indicates either the version header was not stamped by install.js, ` +
|
||||
`or the detector regex cannot match bash "#" comment syntax.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const RESEARCHER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-phase-researcher.md');
|
||||
|
||||
function readResearcherPrompt() {
|
||||
return fs.readFileSync(RESEARCHER_PATH, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #214: phase researcher must survive OpenCode write-tool truncation', () => {
|
||||
test('Step 6 documents the large-file / truncation fallback write contract', () => {
|
||||
const prompt = readResearcherPrompt();
|
||||
|
||||
assert.match(
|
||||
prompt,
|
||||
/truncat/i,
|
||||
'Step 6 must name the truncation failure mode.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/incrementa/i,
|
||||
'Step 6 must instruct incremental construction on large files.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/<!-- gsd:write-continue -->/,
|
||||
'Step 6 must define the continuation sentinel for incremental writes.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/do NOT retry the same oversized call/i,
|
||||
'Step 6 must forbid identical retry of the oversized write (doom-loop guard).'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/do NOT silently fall back to returning content/i,
|
||||
'Step 6 must forbid silent fallback to returning content.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/`Read` the file, then `Edit`/i,
|
||||
'Step 6 must require Read before Edit (OpenCode edit requires a prior Read).'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/no trailing sentinel/i,
|
||||
'Step 6 must instruct removing the sentinel on the final section.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/write the whole file in a single `Write` call/i,
|
||||
'Step 6 must keep single-Write as the default path (no regression for non-truncating runtimes).'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Every agent that writes a large file in a single Write call must carry the
|
||||
// same truncation-resilient write contract added for bug #214. OpenCode shares
|
||||
// OUTPUT_TOKEN_MAX=32000 with the thinking budget (upstream opencode#18108), so
|
||||
// an oversized single `write` tool call is truncated mid-payload, yielding
|
||||
// `JSON Parse error: Expected '}'`, and OpenCode then doom-loops. gsd-phase-researcher
|
||||
// is locked by its own bug-214 test; this locks the other large-file writers.
|
||||
const WRITER_AGENTS = [
|
||||
'gsd-research-synthesizer',
|
||||
'gsd-planner',
|
||||
'gsd-executor',
|
||||
'gsd-domain-researcher',
|
||||
'gsd-project-researcher',
|
||||
'gsd-ui-researcher',
|
||||
];
|
||||
|
||||
function readAgent(name) {
|
||||
return fs.readFileSync(path.join(REPO_ROOT, 'agents', `${name}.md`), 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #214: large-file writer agents must survive write-tool truncation', () => {
|
||||
for (const name of WRITER_AGENTS) {
|
||||
describe(name, () => {
|
||||
const prompt = readAgent(name);
|
||||
|
||||
test('keeps single-Write as the default path', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/in a single `Write` call/i,
|
||||
`${name}: must keep single-Write as the default (no regression for non-truncating runtimes).`
|
||||
);
|
||||
});
|
||||
|
||||
test('names the truncation failure mode', () => {
|
||||
assert.match(prompt, /truncat/i, `${name}: must name the truncation failure mode.`);
|
||||
});
|
||||
|
||||
test('instructs incremental construction on large files', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/incrementa/i,
|
||||
`${name}: must instruct incremental construction on large files.`
|
||||
);
|
||||
});
|
||||
|
||||
test('defines the continuation sentinel', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/<!-- gsd:write-continue -->/,
|
||||
`${name}: must define the continuation sentinel for incremental writes.`
|
||||
);
|
||||
});
|
||||
|
||||
test('forbids identical retry of the oversized write (doom-loop guard)', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/do NOT retry the same oversized call/i,
|
||||
`${name}: must forbid identical retry of the oversized write.`
|
||||
);
|
||||
});
|
||||
|
||||
test('requires Read before Edit', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/`Read` the file, then `Edit`/i,
|
||||
`${name}: must require Read before Edit (OpenCode edit requires a prior Read).`
|
||||
);
|
||||
});
|
||||
|
||||
test('instructs removing the sentinel on the final section', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/no trailing sentinel/i,
|
||||
`${name}: must instruct removing the sentinel on the final section.`
|
||||
);
|
||||
});
|
||||
|
||||
test('forbids silent fallback to returning content', () => {
|
||||
assert.match(
|
||||
prompt,
|
||||
/do NOT silently fall back to returning content/i,
|
||||
`${name}: must forbid silent fallback to returning content.`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SYNTHESIZER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-research-synthesizer.md');
|
||||
|
||||
function readSynthesizerPrompt() {
|
||||
return fs.readFileSync(SYNTHESIZER_PATH, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #222: research synthesizer must write SUMMARY.md via Write tool', () => {
|
||||
test('step 6 has explicit hard-rule block forbidding return-message content fallback', () => {
|
||||
const prompt = readSynthesizerPrompt();
|
||||
|
||||
assert.match(
|
||||
prompt,
|
||||
/canonical output of this agent[\s\S]*existing on disk after you return/i,
|
||||
'Step 6 must define SUMMARY.md-on-disk as canonical output.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Hard rules \(must follow\):/i,
|
||||
'Step 6 must contain explicit hard rules block.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Use the `Write` tool[\s\S]*there are no restrictions/i,
|
||||
'Rule 1 must force Write tool usage and reject hallucinated restrictions.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT return the SUMMARY\.md content in your response/i,
|
||||
'Rule 2 must forbid returning SUMMARY content in the response body.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT ask permission to write/i,
|
||||
'Rule 3 must forbid write-permission asks for this agent.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT use `Bash\(cat << 'EOF'\)` or heredoc/i,
|
||||
'Rule 4 must forbid heredoc/Bash file creation fallback.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/If the Write tool errors[\s\S]*Do not silently fall back to returning content/i,
|
||||
'Rule 5 must force explicit error reporting for Write failures.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns SUMMARY.md inline', () => {
|
||||
const WORKFLOWS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-project.md'),
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-milestone.md'),
|
||||
];
|
||||
|
||||
for (const wf of WORKFLOWS) {
|
||||
const name = path.basename(wf);
|
||||
|
||||
test(`${name} has the #222 synthesizer SUMMARY.md self-heal guard`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
|
||||
// Marker tying the guard to the issue
|
||||
assert.match(text, /#222[^\n]*self-heal|self-heal[^\n]*#222/i,
|
||||
`${name} must contain a #222-tagged self-heal guard after the synthesizer returns.`);
|
||||
|
||||
// Verifies the file exists AND is substantive/non-empty
|
||||
assert.match(text, /SUMMARY\.md[\s\S]{0,120}?(non-empty|substantive|exists)/i,
|
||||
`${name} must verify .planning/research/SUMMARY.md exists AND is substantive — non-empty.`);
|
||||
|
||||
// Truncation/validator guard: references the continuation sentinel OR the verify-summary CLI
|
||||
assert.match(text, /gsd:write-continue|verify-summary/i,
|
||||
`${name} must guard against truncated/invalid SUMMARY.md (sentinel or verify-summary).`);
|
||||
|
||||
// Self-heal must commit ALL research artifacts, not just SUMMARY.md
|
||||
assert.match(text, /--files \.planning\/research\//,
|
||||
`${name} self-heal must commit ALL research artifacts, not just SUMMARY.md.`);
|
||||
|
||||
// Persists inline-returned document via Write rather than trusting the agent
|
||||
assert.match(text, /returned[\s\S]{0,200}?document[\s\S]{0,200}?Write tool/i,
|
||||
`${name} must instruct the orchestrator to persist inline-returned document with the Write tool.`);
|
||||
|
||||
// Must not proceed to roadmapper against a missing or incomplete SUMMARY.md
|
||||
assert.match(text, /gsd-roadmapper[\s\S]{0,200}?(missing|incomplete|do NOT)/i,
|
||||
`${name} must block spawning gsd-roadmapper when SUMMARY.md is missing or incomplete.`);
|
||||
|
||||
// Must name the FULL SUMMARY template markers so the orchestrator persists the real
|
||||
// document, not the brief structured return (resolves the HIGH finding).
|
||||
assert.match(text, /# Project Research Summary[\s\S]{0,260}?## Sources/,
|
||||
`${name}: self-heal must name the full SUMMARY.md template markers (# Project Research Summary … ## Sources).`);
|
||||
// Must reference the brief confirmation marker it must NOT mistake for file content.
|
||||
assert.match(text, /## SYNTHESIS COMPLETE/,
|
||||
`${name}: self-heal must distinguish the brief ## SYNTHESIS COMPLETE confirmation from the real document.`);
|
||||
});
|
||||
|
||||
test(`${name} runs the #222 self-heal AFTER the synthesizer and BEFORE gsd-roadmapper`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
const synthIdx = text.indexOf('subagent_type="gsd-research-synthesizer"');
|
||||
const healIdx = text.indexOf('Synthesizer output self-heal (#222)');
|
||||
const roadIdx = text.indexOf('subagent_type="gsd-roadmapper"');
|
||||
assert.ok(synthIdx >= 0, `${name}: synthesizer dispatch not found`);
|
||||
assert.ok(healIdx >= 0, `${name}: #222 self-heal block not found`);
|
||||
assert.ok(roadIdx >= 0, `${name}: gsd-roadmapper dispatch not found`);
|
||||
assert.ok(healIdx > synthIdx, `${name}: self-heal must come AFTER the synthesizer dispatch`);
|
||||
assert.ok(roadIdx > healIdx, `${name}: self-heal must come BEFORE the gsd-roadmapper dispatch`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
// allow-test-rule: structural-implementation-guard
|
||||
// Bug #224 is a platform-specific (Node 24 + Windows) flake in `--pick` where
|
||||
// stdout interception can produce non-deterministic failures. We lock the seam
|
||||
// contract structurally until we have a deterministic Windows reproduction
|
||||
// harness in CI.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
describe('bug #224: --pick stdout capture contract', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
|
||||
});
|
||||
|
||||
test('--pick output still succeeds for current-timestamp command', () => {
|
||||
const result = runGsdTools(['current-timestamp', '--pick', 'timestamp']);
|
||||
assert.strictEqual(result.success, true, result.error || 'expected command to succeed');
|
||||
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/, 'expected ISO timestamp output');
|
||||
});
|
||||
|
||||
test('stdout interception for fd=1 returns a byte count (never undefined)', () => {
|
||||
const mainStart = src.indexOf('async function main()');
|
||||
assert.ok(mainStart !== -1, 'main() must exist');
|
||||
const mainSrc = src.slice(mainStart);
|
||||
|
||||
assert.ok(
|
||||
mainSrc.includes('Buffer.byteLength(') || mainSrc.includes('return data.length'),
|
||||
'stdout interception must return written-byte counts for fd=1 captures'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Regression test for #2248: local Claude install clobbers profile-level statusLine
|
||||
*
|
||||
* When installing with `--claude --local`, the repo-level `.claude/settings.json`
|
||||
* takes precedence over the user's profile-level `~/.claude/settings.json` in
|
||||
* Claude Code. Writing `statusLine` to repo settings during a local install
|
||||
* silently overrides any profile-level statusLine the user configured.
|
||||
*
|
||||
* Fix: local installs skip writing `statusLine` to settings.json unless
|
||||
* `--force-statusline` is passed.
|
||||
*
|
||||
* Note: `install()` only copies files. `finishInstall()` writes settings.json.
|
||||
* The production code calls both from `installAllRuntimes()`. Tests must mirror
|
||||
* that two-phase pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, finishInstall } = require(INSTALL_SRC);
|
||||
const { cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2248: local install must NOT write statusLine to repo settings.json ────
|
||||
|
||||
describe('#2248: local Claude install does not clobber profile-level statusLine', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Phase 1: copy files (mirrors installAllRuntimes)
|
||||
const result = install(false, 'claude');
|
||||
|
||||
// Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize)
|
||||
// #338: local Claude installs now write to settings.local.json, not settings.json.
|
||||
// shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install
|
||||
const { stdout } = captureConsole(() => {
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
false // isGlobal=false -> local install
|
||||
);
|
||||
});
|
||||
assert.match(
|
||||
stdout,
|
||||
/Skipping statusLine for local install/,
|
||||
'Local install must explain that it skipped statusLine unless --force-statusline is passed'
|
||||
);
|
||||
|
||||
// #338: local installs write to settings.local.json, not settings.json
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after local Claude install (#338)'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.statusLine,
|
||||
undefined,
|
||||
'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)'
|
||||
);
|
||||
|
||||
// settings.json must not be touched by a fresh local install
|
||||
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(sharedSettingsPath),
|
||||
false,
|
||||
'.claude/settings.json must NOT be created by a fresh local Claude install (#338)'
|
||||
);
|
||||
});
|
||||
|
||||
test('global install still writes statusLine to settings.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
|
||||
// Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir
|
||||
const configDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
t.after(() => {
|
||||
if (origEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 1: copy files
|
||||
const result = install(true, 'claude');
|
||||
|
||||
// Phase 2: configure settings.json
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
true // isGlobal=true
|
||||
);
|
||||
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
assert.ok(
|
||||
fs.existsSync(settingsPath),
|
||||
'~/.claude/settings.json must exist after global install'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
assert.ok(
|
||||
settings.statusLine !== undefined,
|
||||
'Global install should write statusLine to settings.json'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Regression tests for issue #2256 — per-agent model_overrides transport
|
||||
* for Codex and OpenCode runtimes.
|
||||
*
|
||||
* The bug: model_overrides set in per-project `.planning/config.json` were
|
||||
* never read by the Codex / OpenCode install paths, which only probed
|
||||
* `~/.gsd/defaults.json`. As a result, the configured per-agent model was
|
||||
* dropped and child agents inherited the runtime's default model.
|
||||
*
|
||||
* These tests lock in the fix: per-project overrides must be honored, and
|
||||
* per-project keys must win over global when both are present.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const {
|
||||
readGsdEffectiveModelOverrides,
|
||||
generateCodexAgentToml,
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
getCodexSkillAdapterHeader,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`);
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
describe('bug #2256 — readGsdEffectiveModelOverrides', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
// On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that
|
||||
// need to redirect ~ must override both — otherwise the SUT reads
|
||||
// the real user's home and the fixture is invisible.
|
||||
origUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
if (isWindows) process.env.USERPROFILE = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (isWindows) {
|
||||
if (origUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUserProfile;
|
||||
}
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('returns null when neither source defines model_overrides', () => {
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('reads overrides from ~/.gsd/defaults.json (global only)', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' });
|
||||
});
|
||||
|
||||
test('reads overrides from per-project .planning/config.json', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' });
|
||||
});
|
||||
|
||||
test('per-project overrides win over global on conflict', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' },
|
||||
});
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'project-model' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
// Per-project wins on conflict; non-conflicting global keys are preserved.
|
||||
assert.deepStrictEqual(result, {
|
||||
'gsd-codebase-mapper': 'project-model',
|
||||
'gsd-planner': 'opus',
|
||||
});
|
||||
});
|
||||
|
||||
test('walks up from nested targetDir to find .planning/', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-planner': 'project-opus' },
|
||||
});
|
||||
const nested = path.join(projectDir, '.codex');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const result = readGsdEffectiveModelOverrides(nested);
|
||||
assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex adapter embeds per-project override', () => {
|
||||
const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
|
||||
test('generateCodexAgentToml embeds model when override provided', () => {
|
||||
const toml = generateCodexAgentToml(
|
||||
'gsd-codebase-mapper',
|
||||
agentContent,
|
||||
{ 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
);
|
||||
assert.match(toml, /^model = "gpt-5-mini"$/m);
|
||||
});
|
||||
|
||||
test('generateCodexAgentToml omits model when no override', () => {
|
||||
const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null);
|
||||
assert.doesNotMatch(toml, /^model\s*=/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — OpenCode adapter embeds per-project override', () => {
|
||||
test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, {
|
||||
isAgent: true,
|
||||
modelOverride: 'claude-haiku-4-5',
|
||||
});
|
||||
assert.match(out, /^model: claude-haiku-4-5$/m);
|
||||
assert.match(out, /^mode: subagent$/m);
|
||||
});
|
||||
|
||||
test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null });
|
||||
assert.doesNotMatch(out, /^model:/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex skill adapter header documents transport', () => {
|
||||
test('Task(model=...) line no longer says "omit" without explanation', () => {
|
||||
const header = getCodexSkillAdapterHeader('gsd-plan-phase');
|
||||
// Header must mention that per-agent model_overrides are embedded in agent
|
||||
// TOML so spawn_agent picks them up automatically — the old text said
|
||||
// "Codex uses per-role config, not inline model selection" which left
|
||||
// users thinking their model_overrides were silently ignored.
|
||||
assert.match(header, /model_overrides/);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2268
|
||||
*
|
||||
* cmdInitProgress used a sliding-window pattern that set is_next_to_discuss
|
||||
* only on the FIRST undiscussed phase. Multiple independent undiscussed phases
|
||||
* could not be discussed in parallel — the manager only ever recommended one
|
||||
* discuss action at a time.
|
||||
*
|
||||
* Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user
|
||||
* can pick any of them.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function writeRoadmap(tmpDir, phases) {
|
||||
const sections = phases.map(p => {
|
||||
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`;
|
||||
return section;
|
||||
}).join('\n');
|
||||
const checklist = phases.map(p => {
|
||||
const mark = p.complete ? 'x' : ' ';
|
||||
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
|
||||
}).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`
|
||||
);
|
||||
}
|
||||
|
||||
function writeState(tmpDir) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
|
||||
}
|
||||
|
||||
let tmpDir;
|
||||
|
||||
describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => {
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('two undiscussed phases: both marked is_next_to_discuss', () => {
|
||||
writeState(tmpDir);
|
||||
writeRoadmap(tmpDir, [
|
||||
{ number: '1', name: 'Foundation' },
|
||||
{ number: '2', name: 'Cloud Deployment' },
|
||||
]);
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable');
|
||||
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable');
|
||||
});
|
||||
|
||||
test('two undiscussed phases: both get discuss recommendations', () => {
|
||||
writeState(tmpDir);
|
||||
writeRoadmap(tmpDir, [
|
||||
{ number: '1', name: 'Foundation' },
|
||||
{ number: '2', name: 'Cloud Deployment' },
|
||||
]);
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
|
||||
assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases');
|
||||
|
||||
const phases = discussActions.map(a => a.phase).sort();
|
||||
assert.deepStrictEqual(phases, ['1', '2']);
|
||||
});
|
||||
|
||||
test('five undiscussed phases: all five marked is_next_to_discuss', () => {
|
||||
writeState(tmpDir);
|
||||
writeRoadmap(tmpDir, [
|
||||
{ number: '1', name: 'Alpha' },
|
||||
{ number: '2', name: 'Beta' },
|
||||
{ number: '3', name: 'Gamma' },
|
||||
{ number: '4', name: 'Delta' },
|
||||
{ number: '5', name: 'Epsilon' },
|
||||
]);
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
for (const phase of output.phases) {
|
||||
assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('discussed phase stays false; undiscussed sibling is true', () => {
|
||||
writeState(tmpDir);
|
||||
writeRoadmap(tmpDir, [
|
||||
{ number: '1', name: 'Foundation' },
|
||||
{ number: '2', name: 'API Layer' },
|
||||
]);
|
||||
// scaffold CONTEXT.md to mark phase 1 as discussed
|
||||
const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context');
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss');
|
||||
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss');
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2344
|
||||
*
|
||||
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
|
||||
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
|
||||
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
|
||||
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
|
||||
* Code — producing noise in long-running sessions.
|
||||
*
|
||||
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
|
||||
* emitting the advisory.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
function runHook(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDECODE: '',
|
||||
CLAUDE_CODE_ENTRYPOINT: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
CLAUDE_PROJECT_DIR: '',
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '1' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_SESSION_ID: 'test-session-123' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2346
|
||||
*
|
||||
* Multiple GSD agents (gsd-ui-checker, gsd-planner) entered unbounded Read
|
||||
* loops — re-reading the same file hundreds of times in a single run. Root
|
||||
* cause: no explicit no-re-read rule or tool-budget cap in the agent prompts.
|
||||
* gsd-pattern-mapper was fixed in #2312; this covers the remaining agents.
|
||||
*
|
||||
* Fix: add <critical_rules> block to each affected agent with:
|
||||
* 1. No-re-read constraint
|
||||
* 2. Large-file strategy (Grep first, then targeted offset/limit Read)
|
||||
* 3. Stop-on-sufficient-evidence rule (where applicable)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The <critical_rules> block in agent .md files IS the fix — it is the AI instruction that
|
||||
// prevents unbounded Read loops. There is no behavioral equivalent without a live LLM run.
|
||||
describe('bug #2346: agent read loop guards', () => {
|
||||
|
||||
describe('gsd-ui-checker', () => {
|
||||
const agentPath = path.join(AGENTS_DIR, 'gsd-ui-checker.md');
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
|
||||
test('agent file exists', () => {
|
||||
assert.ok(fs.existsSync(agentPath), 'agents/gsd-ui-checker.md must exist');
|
||||
});
|
||||
|
||||
test('has <critical_rules> block', () => {
|
||||
assert.ok(
|
||||
content.includes('<critical_rules>'),
|
||||
'gsd-ui-checker.md must have a <critical_rules> block to prevent unbounded read loops (#2346)'
|
||||
);
|
||||
});
|
||||
|
||||
test('critical_rules contains no-re-read constraint', () => {
|
||||
const rulesStart = content.indexOf('<critical_rules>');
|
||||
const rulesEnd = content.indexOf('</critical_rules>', rulesStart);
|
||||
assert.ok(rulesStart !== -1 && rulesEnd !== -1, '<critical_rules> block must be complete');
|
||||
const rulesBlock = content.slice(rulesStart, rulesEnd);
|
||||
assert.ok(
|
||||
rulesBlock.includes('re-read') || rulesBlock.includes('re read'),
|
||||
'critical_rules must include a no-re-read rule'
|
||||
);
|
||||
});
|
||||
|
||||
test('critical_rules appears before success_criteria', () => {
|
||||
const rulesIdx = content.indexOf('<critical_rules>');
|
||||
const successIdx = content.indexOf('<success_criteria>');
|
||||
assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist');
|
||||
assert.ok(
|
||||
rulesIdx < successIdx,
|
||||
'<critical_rules> must appear before <success_criteria>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-planner', () => {
|
||||
const agentPath = path.join(AGENTS_DIR, 'gsd-planner.md');
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
|
||||
test('agent file exists', () => {
|
||||
assert.ok(fs.existsSync(agentPath), 'agents/gsd-planner.md must exist');
|
||||
});
|
||||
|
||||
test('has <critical_rules> block', () => {
|
||||
assert.ok(
|
||||
content.includes('<critical_rules>'),
|
||||
'gsd-planner.md must have a <critical_rules> block to prevent unbounded read loops (#2346)'
|
||||
);
|
||||
});
|
||||
|
||||
test('critical_rules contains no-re-read constraint', () => {
|
||||
const rulesStart = content.indexOf('<critical_rules>');
|
||||
const rulesEnd = content.indexOf('</critical_rules>', rulesStart);
|
||||
assert.ok(rulesStart !== -1 && rulesEnd !== -1, '<critical_rules> block must be complete');
|
||||
const rulesBlock = content.slice(rulesStart, rulesEnd);
|
||||
assert.ok(
|
||||
rulesBlock.includes('re-read') || rulesBlock.includes('re read'),
|
||||
'critical_rules must include a no-re-read rule'
|
||||
);
|
||||
});
|
||||
|
||||
test('critical_rules appears before success_criteria', () => {
|
||||
const rulesIdx = content.indexOf('<critical_rules>');
|
||||
const successIdx = content.lastIndexOf('<success_criteria>');
|
||||
assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist');
|
||||
assert.ok(
|
||||
rulesIdx < successIdx,
|
||||
'<critical_rules> must appear before <success_criteria>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2351
|
||||
*
|
||||
* gsd-intel-updater used hardcoded canonical paths (`agents/*.md`,
|
||||
* `commands/gsd/*.md`, `hooks/*.js`, etc.) that assumed the standard
|
||||
* `.claude/` runtime layout. Under a `.kilo` install, the runtime root is
|
||||
* `.kilo/`, and the command directory is `command/` (not `commands/gsd/`).
|
||||
* Globs against the old paths returned no results, producing semantically
|
||||
* empty intel files (`"entries": {}`).
|
||||
*
|
||||
* Fix: add runtime layout detection and a mapping table so the agent
|
||||
* resolves paths against the correct root.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-intel-updater.md');
|
||||
|
||||
describe('bug #2351: intel updater kilo layout support', () => {
|
||||
let content;
|
||||
|
||||
test('agent file exists', () => {
|
||||
assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-intel-updater.md must exist');
|
||||
content = fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('scope section includes layout detection step', () => {
|
||||
content = content || fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
const hasDetection =
|
||||
content.includes('ls -d .kilo') ||
|
||||
content.includes('Runtime layout detection') ||
|
||||
content.includes('detected layout') ||
|
||||
content.includes('layout detection');
|
||||
assert.ok(
|
||||
hasDetection,
|
||||
'gsd-intel-updater.md must instruct the agent to detect the runtime layout ' +
|
||||
'(.kilo vs .claude) before resolving canonical paths (#2351)'
|
||||
);
|
||||
});
|
||||
|
||||
test('scope section maps .kilo/agents path', () => {
|
||||
content = content || fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('.kilo/agents'),
|
||||
'scope section must include the .kilo/agents/*.md path so agent count is correct under kilo layout'
|
||||
);
|
||||
});
|
||||
|
||||
test('scope section maps .kilo/command path (not commands/gsd)', () => {
|
||||
content = content || fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('.kilo/command'),
|
||||
'scope section must include .kilo/command path — kilo uses "command/" not "commands/gsd/"'
|
||||
);
|
||||
});
|
||||
|
||||
test('scope section maps .kilo/hooks path', () => {
|
||||
content = content || fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('.kilo/hooks'),
|
||||
'scope section must include .kilo/hooks path for hook file counts'
|
||||
);
|
||||
});
|
||||
|
||||
test('scope section retains standard layout paths for .claude installs', () => {
|
||||
content = content || fs.readFileSync(AGENT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('agents/*.md') || content.includes('Standard `.claude` layout'),
|
||||
'scope section must still document the standard .claude layout paths for non-kilo installs'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Regression test for #2376: @$HOME not correctly mapped in OpenCode on Windows.
|
||||
*
|
||||
* On Windows, $HOME is not expanded by PowerShell/cmd.exe, so OpenCode cannot
|
||||
* resolve @$HOME/... file references in installed command files.
|
||||
*
|
||||
* Fix: install.js must use the absolute path (not $HOME-relative) when installing
|
||||
* for OpenCode. (Generalized to all platforms in #2831 — OpenCode `@file`
|
||||
* references are not shell-expanded on any platform.)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
let computePathPrefix;
|
||||
|
||||
before(() => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// Re-require fresh in case other tests already loaded it.
|
||||
delete require.cache[require.resolve('../bin/install.js')];
|
||||
({ computePathPrefix } = require('../bin/install.js'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
});
|
||||
|
||||
describe('bug-2376: OpenCode on Windows must use absolute path, not $HOME', () => {
|
||||
test('computePathPrefix is exported by install.js', () => {
|
||||
assert.equal(typeof computePathPrefix, 'function');
|
||||
});
|
||||
|
||||
test('OpenCode on Windows: pathPrefix is absolute (no $HOME substitution)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.config/opencode',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, 'C:/Users/user/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('Claude Code on Windows: pathPrefix still uses $HOME (unaffected)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.claude',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '$HOME/.claude/');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2384.
|
||||
*
|
||||
* During execute-phase, the orchestrator merges per-plan worktree branches into
|
||||
* main. The pre-merge deletion check (git diff --diff-filter=D HEAD...WT_BRANCH)
|
||||
* only catches files deleted on the worktree branch. A post-merge audit is also
|
||||
* required to catch deletions that made it into the merge commit (e.g., files
|
||||
* that were in the common ancestor but deleted by the merged worktree) and to
|
||||
* provide a revert safety net.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command, which implements pre-merge deletion checks
|
||||
* (diff --diff-filter=D) internally via executeWorktreeWaveCleanupPlan.
|
||||
* The manual post-merge shell audit (MERGE_DEL_COUNT, git reset --hard) has
|
||||
* been removed from the workflow — it was part of the SDK-absence fallback.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse execute-phase.md into a structured contract object.
|
||||
* Returns typed boolean fields so tests can assert on structure
|
||||
* rather than raw text.
|
||||
*/
|
||||
function parseExecutePhaseContract(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
return {
|
||||
// Does the workflow call the worktree.cleanup-wave SDK command?
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK?
|
||||
passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')),
|
||||
};
|
||||
}
|
||||
|
||||
describe('execute-phase.md — post-merge deletion audit (#2384)', () => {
|
||||
const contract = parseExecutePhaseContract(EXECUTE_PHASE);
|
||||
|
||||
test('execute-phase delegates to worktree.cleanup-wave (which handles deletion audit)', () => {
|
||||
// After #3797: worktree.cleanup-wave in worktree-safety.cjs performs
|
||||
// diff --diff-filter=D checks (blocks branches with deletions) before merge.
|
||||
// The workflow delegates to the SDK rather than duplicating the check inline.
|
||||
assert.ok(
|
||||
contract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase cleanup-wave uses || exit 1 (fail-closed for blocked deletions)', () => {
|
||||
// If worktree.cleanup-wave detects deletions, it exits 1 (blocked).
|
||||
// The || exit 1 in the workflow propagates that refusal rather than swallowing it.
|
||||
assert.ok(
|
||||
contract.cleanupWaveFailClosed,
|
||||
'execute-phase.md must use || exit 1 so deletion-blocked cleanups surface to the orchestrator',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase still has pre-merge deletion check (via guard before worktree.cleanup-wave)', () => {
|
||||
// The primary deletion guard is now in worktree-safety.cjs (SDK).
|
||||
// The workflow must still enforce WAVE_WORKTREE_MANIFEST so the SDK
|
||||
// has the info it needs to validate branches.
|
||||
assert.ok(
|
||||
contract.passesWaveManifest,
|
||||
'execute-phase.md must pass WAVE_WORKTREE_MANIFEST to worktree.cleanup-wave',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2388: plan-phase silently renames feature branch
|
||||
* when phase slug has changed since the branch was created.
|
||||
*
|
||||
* Fix: plan-phase.md must include an explicit instruction not to create,
|
||||
* rename, or switch git branches during the planning workflow.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('bug-2388: plan-phase must not rename or create git branches', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'plan-phase.md should exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md contains explicit no-branch-rename instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must say "do not" and mention branch in the context of phase slug/rename
|
||||
const hasBranchGuard = (
|
||||
/do not.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}do not/i.test(content) ||
|
||||
/NEVER.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}NEVER/i.test(content)
|
||||
);
|
||||
assert.ok(
|
||||
hasBranchGuard,
|
||||
'plan-phase.md must include an explicit instruction not to create or rename git branches'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md mentions phase rename does not affect branch name', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should explain that a phase rename in ROADMAP.md is plan-level, not git-level
|
||||
const hasPlanLevelExplanation = (
|
||||
content.includes('phase rename') ||
|
||||
content.includes('phase_slug') ||
|
||||
content.includes('branch identity') ||
|
||||
content.includes('branch name')
|
||||
);
|
||||
assert.ok(
|
||||
hasPlanLevelExplanation,
|
||||
'plan-phase.md should clarify that phase slug changes do not change the git branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md does not contain git checkout -b instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The workflow should not instruct the LLM to run git checkout -b
|
||||
assert.ok(
|
||||
!content.includes('git checkout -b'),
|
||||
'plan-phase.md must not instruct LLM to create a new branch via git checkout -b'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2396: hardcoded host-level test commands bypass
|
||||
* container-only project Makefiles.
|
||||
*
|
||||
* Fix: execute-phase.md, verify-phase.md, and audit-fix.md must check for
|
||||
* Makefile with a test target (and other wrappers) before falling through
|
||||
* to hardcoded language-sniffed commands.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md');
|
||||
|
||||
function assertMakefileCheckBeforeNpmTest(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Must check for Makefile with test target
|
||||
const hasMakefileCheck = /Makefile.*grep.*test:|grep.*test:.*Makefile/s.test(content) ||
|
||||
(content.includes('Makefile') && content.includes('"^test:"'));
|
||||
assert.ok(
|
||||
hasMakefileCheck,
|
||||
`${label}: must check for Makefile with test: target before falling through to hardcoded commands`
|
||||
);
|
||||
|
||||
// make test must appear before npm test in the file
|
||||
const makeTestIdx = content.indexOf('make test');
|
||||
const npmTestIdx = content.indexOf('npm test');
|
||||
assert.ok(makeTestIdx !== -1, `${label}: must contain "make test"`);
|
||||
assert.ok(npmTestIdx !== -1, `${label}: must still contain "npm test" as fallback`);
|
||||
assert.ok(
|
||||
makeTestIdx < npmTestIdx,
|
||||
`${label}: "make test" must appear before "npm test" (Makefile takes priority)`
|
||||
);
|
||||
}
|
||||
|
||||
function assertConfigGetBeforeMakefile(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Must check workflow.test_command config before Makefile sniff.
|
||||
// Verify within each bash code block: the workflow.test_command lookup
|
||||
// appears before the Makefile grep in the same block.
|
||||
assert.ok(
|
||||
content.includes('workflow.test_command'),
|
||||
`${label}: must check workflow.test_command config before Makefile/language sniff`
|
||||
);
|
||||
|
||||
// Extract bash blocks to check ordering within each block.
|
||||
// Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile"
|
||||
// (which appears in comments before the config-get call).
|
||||
const bashBlockRe = /```bash([\s\S]*?)```/g;
|
||||
let match;
|
||||
let anyBlockCorrectlyOrdered = false;
|
||||
while ((match = bashBlockRe.exec(content)) !== null) {
|
||||
const block = match[1];
|
||||
if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) {
|
||||
const configIdx = block.indexOf('workflow.test_command');
|
||||
const makefileIdx = block.indexOf('[ -f "Makefile"');
|
||||
if (configIdx < makefileIdx) {
|
||||
anyBlockCorrectlyOrdered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
anyBlockCorrectlyOrdered,
|
||||
`${label}: within a bash block, workflow.test_command config check must appear before Makefile test ([ -f "Makefile" ])`
|
||||
);
|
||||
}
|
||||
|
||||
describe('bug-2396: Makefile test target must take priority over hardcoded commands', () => {
|
||||
test('execute-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('verify-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(VERIFY_PHASE_PATH), 'verify-phase.md should exist');
|
||||
});
|
||||
|
||||
test('audit-fix.md exists', () => {
|
||||
assert.ok(fs.existsSync(AUDIT_FIX_PATH), 'audit-fix.md should exist');
|
||||
});
|
||||
|
||||
test('execute-phase.md: Makefile check precedes npm test (post-merge gate)', () => {
|
||||
assertMakefileCheckBeforeNpmTest(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
|
||||
test('execute-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2399: commit_docs:true is ignored in plan-phase
|
||||
*
|
||||
* The plan-phase workflow generates plan artifacts but never commits them even
|
||||
* when commit_docs is true. A step between 13b and 14 must commit the PLAN.md
|
||||
* files and updated STATE.md when commit_docs is set.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('plan-phase commit_docs support (#2399)', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'gsd-core/workflows/plan-phase.md must exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md has a commit step for plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must contain a commit call that references PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('PLAN.md') && content.includes('commit'),
|
||||
'plan-phase.md must include a commit step that references PLAN.md files'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step is gated on commit_docs', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The commit step must be conditional on commit_docs
|
||||
assert.ok(
|
||||
content.includes('commit_docs'),
|
||||
'plan-phase.md must reference commit_docs to gate the plan commit step'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step references STATE.md', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should commit STATE.md alongside PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'plan-phase.md commit step should include STATE.md to capture planning completion state'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md has a step 13c that commits plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
const step13b = content.indexOf('## 13b.');
|
||||
const step14 = content.indexOf('## 14.');
|
||||
// Look for the step 13c section (or any commit step between 13b and 14)
|
||||
const step13c = content.indexOf('## 13c.');
|
||||
|
||||
assert.ok(step13b !== -1, '## 13b. section must exist');
|
||||
assert.ok(step14 !== -1, '## 14. section must exist');
|
||||
assert.ok(step13c !== -1, '## 13c. step must exist (commit plans step)');
|
||||
assert.ok(
|
||||
step13c > step13b && step13c < step14,
|
||||
`Step 13c (at ${step13c}) must appear between step 13b (at ${step13b}) and step 14 (at ${step14})`
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md uses gsd-sdk query commit for the plan commit', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must use gsd-sdk query commit (not raw git) so commit_docs guard in gsd-tools is respected
|
||||
assert.ok(
|
||||
content.includes('gsd-sdk query commit') || content.includes('gsd-tools') || content.includes('gsd-sdk'),
|
||||
'plan-phase.md plan commit step must use gsd-sdk query commit (not raw git commit)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2410 — /gsd:manager background execute-phase Task fails with
|
||||
* "Stream idle timeout" on multi-plan phases.
|
||||
*
|
||||
* Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]`
|
||||
* heartbeat lines at every wave boundary AND every plan boundary so the
|
||||
* Claude API SSE stream never idles long enough to trigger the platform
|
||||
* timeout. This test validates the workflow contract that backs that fix.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md');
|
||||
|
||||
describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow references the stream idle timeout symptom by name', () => {
|
||||
assert.ok(
|
||||
/Stream idle timeout/.test(workflow),
|
||||
'workflow should name the API error it is preventing'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('#2410'),
|
||||
'workflow should cite the tracking issue for future maintainers'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow defines a [checkpoint] heartbeat line format', () => {
|
||||
assert.ok(
|
||||
workflow.includes('[checkpoint]'),
|
||||
'workflow should document the [checkpoint] marker prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow),
|
||||
'workflow should emit a wave-start [checkpoint] marker before spawning agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow),
|
||||
'workflow should emit a wave-complete [checkpoint] marker after spot-checks'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow),
|
||||
'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow),
|
||||
'workflow should emit a plan-complete [checkpoint] marker after executor returns'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow handles plan failure and checkpoint-gate heartbeats too', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow),
|
||||
'workflow should emit a plan-failed [checkpoint] marker on executor error'
|
||||
);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow),
|
||||
'workflow should emit a heartbeat when a plan returns a human-gate checkpoint'
|
||||
);
|
||||
});
|
||||
|
||||
test('heartbeats include a monotonic plans-done counter', () => {
|
||||
// The {P}/{Q} counter lets grep-based recovery tools reconstruct progress
|
||||
// from a truncated transcript if the agent dies mid-phase.
|
||||
assert.ok(
|
||||
/\{P\}\/\{Q\} plans done/.test(workflow),
|
||||
'heartbeats should include a {P}/{Q} phase-wide completed-plan counter'
|
||||
);
|
||||
});
|
||||
|
||||
test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => {
|
||||
const describeIdx = workflow.indexOf("Describe what's being built");
|
||||
const heartbeatIdx = workflow.indexOf(
|
||||
'[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting'
|
||||
);
|
||||
assert.ok(describeIdx !== -1, 'workflow should still have the describe step');
|
||||
assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present');
|
||||
// The instruction to emit the heartbeat appears in step 2, which is the
|
||||
// step titled "Describe what's being built". The actual sentinel text we
|
||||
// look for is the inline literal template — it must be emitted BEFORE any
|
||||
// tool calls in that step.
|
||||
const step2 = workflow.slice(
|
||||
describeIdx,
|
||||
workflow.indexOf('3. **Spawn executor agents', describeIdx)
|
||||
);
|
||||
assert.ok(
|
||||
step2.includes('[checkpoint]'),
|
||||
'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat'
|
||||
);
|
||||
assert.ok(
|
||||
/before any further reasoning or spawning/i.test(step2) ||
|
||||
/before any tool call/i.test(step2) ||
|
||||
/no tool call/i.test(step2),
|
||||
'step 2 should make clear the heartbeat is an assistant-text line, not a tool call'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-start heartbeat is inside the spawn step', () => {
|
||||
const spawnIdx = workflow.indexOf('3. **Spawn executor agents');
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx);
|
||||
assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist');
|
||||
const step3 = workflow.slice(spawnIdx, waitIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3),
|
||||
'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => {
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents');
|
||||
const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx);
|
||||
assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist');
|
||||
const step4 = workflow.slice(waitIdx, hookIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4),
|
||||
'plan-complete heartbeat should be emitted in step 4 (wait for agents)'
|
||||
);
|
||||
|
||||
const reportIdx = workflow.indexOf('6. **Report completion');
|
||||
const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx);
|
||||
assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist');
|
||||
const step6 = workflow.slice(reportIdx, failureIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6),
|
||||
'wave-complete heartbeat should be emitted in step 6 (report completion)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2410: checkpoint heartbeat format is user-documented', () => {
|
||||
const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8');
|
||||
|
||||
test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => {
|
||||
const managerIdx = commandsDoc.indexOf('### `/gsd-manager`');
|
||||
assert.ok(managerIdx !== -1, '/gsd-manager section should exist');
|
||||
const section = commandsDoc.slice(managerIdx, managerIdx + 4000);
|
||||
assert.ok(
|
||||
/\[checkpoint\]/.test(section),
|
||||
'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers'
|
||||
);
|
||||
assert.ok(
|
||||
/Stream idle timeout/i.test(section),
|
||||
'COMMANDS.md should explain what the heartbeats prevent'
|
||||
);
|
||||
assert.ok(
|
||||
/#2410/.test(section),
|
||||
'COMMANDS.md should reference the tracking issue'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install
|
||||
*
|
||||
* The Antigravity path converter handles ~/.claude/ (with trailing slash) but
|
||||
* misses bare ~/.claude (without trailing slash), leaving unreplaced references
|
||||
* that cause the installer to warn about leaked paths.
|
||||
*
|
||||
* Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and
|
||||
* gsd-core/workflows/update.md (comment with e.g. ~/.claude).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { convertClaudeToAntigravityContent } = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => {
|
||||
describe('global install', () => {
|
||||
test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => {
|
||||
const input = 'configDir = ~/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/antigravity'),
|
||||
`Expected ~/.gemini/antigravity in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => {
|
||||
const input = 'export DIR=$HOME/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('$HOME/.gemini/antigravity'),
|
||||
`Expected $HOME/.gemini/antigravity in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('$HOME/.claude'),
|
||||
`Expected $HOME/.claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('handles bare ~/.claude followed by comma (comment context)', () => {
|
||||
const input = '# e.g. ~/.claude, ~/.config/opencode';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced in comment context, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('still replaces ~/.claude/ (with trailing slash) correctly', () => {
|
||||
const input = 'See ~/.claude/gsd-core/workflows/';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/antigravity/gsd-core/workflows/'),
|
||||
`Expected path with trailing slash to be replaced, got: ${result}`
|
||||
);
|
||||
assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`);
|
||||
});
|
||||
|
||||
test('does not double-replace ~/.claude/ paths', () => {
|
||||
const input = 'See ~/.claude/gsd-core/';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
// Result should contain exactly one occurrence of the replacement path
|
||||
const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length;
|
||||
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local install', () => {
|
||||
test('replaces ~/.claude (bare, no trailing slash) with .agents', () => {
|
||||
const input = 'configDir = ~/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
assert.ok(
|
||||
result.includes('.agents'),
|
||||
`Expected .agents in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => {
|
||||
const input = 'export DIR=$HOME/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
assert.ok(
|
||||
result.includes('.agents'),
|
||||
`Expected .agents in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('$HOME/.claude'),
|
||||
`Expected $HOME/.claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does not double-replace ~/.claude/ paths', () => {
|
||||
const input = 'See ~/.claude/gsd-core/';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
// .agents/ should appear exactly once
|
||||
const count = (result.match(/\.agents\//g) || []).length;
|
||||
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installed files contain no bare ~/.claude references after conversion', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
// The scanner regex used by the installer to detect leaked paths
|
||||
const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g;
|
||||
|
||||
function convertFile(filePath, isGlobal) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return convertClaudeToAntigravityContent(content, isGlobal);
|
||||
}
|
||||
|
||||
test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => {
|
||||
const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md');
|
||||
if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist
|
||||
const converted = convertFile(debuggerPath, true);
|
||||
const matches = converted.match(leakedPathRegex);
|
||||
assert.strictEqual(
|
||||
matches, null,
|
||||
`gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}`
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md has no leaked ~/.claude after global Antigravity conversion', () => {
|
||||
const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md');
|
||||
if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist
|
||||
const converted = convertFile(updatePath, true);
|
||||
const matches = converted.match(leakedPathRegex);
|
||||
assert.strictEqual(
|
||||
matches, null,
|
||||
`update.md still contains leaked .claude paths after Antigravity conversion: ${matches}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Bug #2419: gsd-project-researcher agent type not found
|
||||
*
|
||||
* When gsd-new-project spawns gsd-project-researcher subagents, it fails with
|
||||
* "agent type not found" if the user has a local-only install (agents in
|
||||
* .claude/agents/ of a different project, not the global ~/.claude/agents/).
|
||||
*
|
||||
* Fix: new-project.md and new-milestone.md must parse agents_installed from
|
||||
* the init JSON and warn the user (rather than silently failing) when agents
|
||||
* are missing.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const NEW_PROJECT_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-project.md');
|
||||
const NEW_MILESTONE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-milestone.md');
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
describe('gsd-project-researcher agent registration (#2419)', () => {
|
||||
test('gsd-project-researcher.md exists in agents source dir', () => {
|
||||
const agentFile = path.join(AGENTS_DIR, 'gsd-project-researcher.md');
|
||||
assert.ok(
|
||||
fs.existsSync(agentFile),
|
||||
'agents/gsd-project-researcher.md must exist in the source agents directory'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-project-researcher.md has correct name in frontmatter', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-project-researcher.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('name: gsd-project-researcher'),
|
||||
'agents/gsd-project-researcher.md must have name: gsd-project-researcher in frontmatter'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project.md parses agents_installed from init JSON', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('agents_installed'),
|
||||
'new-project.md must parse agents_installed from the init JSON to detect missing agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project.md warns user when agents_installed is false', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('agents_installed') && content.includes('agent type not found') ||
|
||||
content.includes('agents_installed') && content.includes('missing') ||
|
||||
content.includes('agents_installed') && content.includes('not installed'),
|
||||
'new-project.md must warn the user when agents are not installed (agents_installed is false)'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project.md reports required-agent and skill-payload diagnostics separately', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(content.includes('required_agents_installed'),
|
||||
'new-project.md must parse required_agents_installed from init JSON');
|
||||
assert.ok(content.includes('missing_required_agents'),
|
||||
'new-project.md must report missing required new-project agents separately');
|
||||
assert.ok(content.includes('agent_skill_payloads_available'),
|
||||
'new-project.md must distinguish skill payload availability from agent definitions');
|
||||
assert.ok(content.includes('agents_dir'),
|
||||
'new-project.md must show which agents directory was checked');
|
||||
});
|
||||
|
||||
test('new-milestone.md parses agents_installed from init JSON', () => {
|
||||
const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('agents_installed'),
|
||||
'new-milestone.md must parse agents_installed from the init JSON to detect missing agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-milestone.md warns user when agents_installed is false', () => {
|
||||
const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('agents_installed') && (
|
||||
content.includes('agent type not found') ||
|
||||
content.includes('missing') ||
|
||||
content.includes('not installed')
|
||||
),
|
||||
'new-milestone.md must warn the user when agents are not installed (agents_installed is false)'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project.md lists gsd-project-researcher in available_agent_types', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
const agentTypesMatch = content.match(/<available_agent_types>([\s\S]*?)<\/available_agent_types>/);
|
||||
assert.ok(agentTypesMatch, 'new-project.md must have <available_agent_types> section');
|
||||
assert.ok(
|
||||
agentTypesMatch[1].includes('gsd-project-researcher'),
|
||||
'new-project.md <available_agent_types> must list gsd-project-researcher'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-milestone.md lists gsd-project-researcher in available_agent_types', () => {
|
||||
const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8');
|
||||
const agentTypesMatch = content.match(/<available_agent_types>([\s\S]*?)<\/available_agent_types>/);
|
||||
assert.ok(agentTypesMatch, 'new-milestone.md must have <available_agent_types> section');
|
||||
assert.ok(
|
||||
agentTypesMatch[1].includes('gsd-project-researcher'),
|
||||
'new-milestone.md <available_agent_types> must list gsd-project-researcher'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2421: gsd-planner emits grep-count acceptance gates that count comment text
|
||||
*
|
||||
* The planner must instruct agents to use comment-aware grep patterns in
|
||||
* <automated> verify blocks. Without this, descriptive comments in file
|
||||
* headers count against the gate and force authors to reword them — the
|
||||
* "self-invalidating grep gate" anti-pattern.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLANNER_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||
|
||||
describe('gsd-planner grep gate hygiene (#2421)', () => {
|
||||
test('gsd-planner.md exists in agents source dir', () => {
|
||||
assert.ok(fs.existsSync(PLANNER_PATH), 'agents/gsd-planner.md must exist');
|
||||
});
|
||||
|
||||
test('gsd-planner.md contains Grep gate hygiene rule', () => {
|
||||
const content = fs.readFileSync(PLANNER_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Grep gate hygiene') || content.includes('grep gate hygiene'),
|
||||
'gsd-planner.md must contain a "Grep gate hygiene" rule to prevent self-invalidating grep gates'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-planner.md explains self-invalidating grep gate anti-pattern', () => {
|
||||
const content = fs.readFileSync(PLANNER_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('self-invalidating'),
|
||||
'gsd-planner.md must describe the "self-invalidating" grep gate anti-pattern'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-planner.md provides comment-stripping grep example', () => {
|
||||
const content = fs.readFileSync(PLANNER_PATH, 'utf-8');
|
||||
// Must show a pattern that excludes comment lines (grep -v or grep -vE)
|
||||
assert.ok(
|
||||
content.includes('grep -v') || content.includes('grep -vE') || content.includes('-v '),
|
||||
'gsd-planner.md must provide a comment-stripping grep example (grep -v or grep -vE)'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-planner.md warns against bare zero-count grep gates on whole files', () => {
|
||||
const content = fs.readFileSync(PLANNER_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('== 0') || content.includes('zero-count') || content.includes('zero count'),
|
||||
'gsd-planner.md must warn against bare zero-count grep gates without comment exclusion'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-planner.md grep gate hygiene rule appears after Nyquist Rule', () => {
|
||||
const content = fs.readFileSync(PLANNER_PATH, 'utf-8');
|
||||
const nyquistIdx = content.indexOf('Nyquist Rule');
|
||||
const grepGateIdx = content.indexOf('grep gate hygiene') !== -1
|
||||
? content.indexOf('grep gate hygiene')
|
||||
: content.indexOf('Grep gate hygiene');
|
||||
|
||||
assert.ok(nyquistIdx !== -1, 'Nyquist Rule must be present in gsd-planner.md');
|
||||
assert.ok(grepGateIdx !== -1, 'Grep gate hygiene must be present in gsd-planner.md');
|
||||
assert.ok(
|
||||
grepGateIdx > nyquistIdx,
|
||||
`Grep gate hygiene rule (at ${grepGateIdx}) must appear after Nyquist Rule (at ${nyquistIdx})`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Bug #2424: reapply-patches pristine-baseline detection uses first-add commit
|
||||
*
|
||||
* The three-way merge baseline detection previously used `git log --diff-filter=A`
|
||||
* which returns the commit that FIRST added the file. On repos that have been
|
||||
* through multiple GSD update cycles, this returns a stale, many-versions-old
|
||||
* baseline — not the version immediately prior to the current update.
|
||||
*
|
||||
* Fix: Option A must prefer `pristine_hashes` from backup-meta.json to locate
|
||||
* the correct baseline commit by SHA-256 matching, with a fallback to the
|
||||
* first-add heuristic only when no pristine hash is recorded.
|
||||
*
|
||||
* #2790: reapply-patches.md (which contained the inline Option A / Option B workflow)
|
||||
* was consolidated into update.md as the --reapply flag. The behavioral contract
|
||||
* (pristine_hashes preference, fallback to first-add) is maintained in the
|
||||
* update.md workflow's --reapply path. These tests now verify the consolidation.
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// gsd-core/workflows/update.md is the installed runtime workflow —
|
||||
// its text IS the deployed behavioral contract.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// #2790: reapply-patches.md (command with inline workflow) was deleted.
|
||||
// The --reapply functionality is now in update.md.
|
||||
const UPDATE_MD = path.join(__dirname, '..', 'commands', 'gsd', 'update.md');
|
||||
|
||||
/**
|
||||
* Parse a field from YAML frontmatter between --- markers.
|
||||
* Returns null if the frontmatter or field is absent.
|
||||
*/
|
||||
function parseFrontmatterField(content, field) {
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const fm = fmMatch[1];
|
||||
const quoted = fm.match(new RegExp(`^${field}:\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*$`, 'm'));
|
||||
if (quoted) return quoted[1];
|
||||
const plain = fm.match(new RegExp(`^${field}:\\s+(.+)$`, 'm'));
|
||||
if (plain) return plain[1].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('reapply-patches pristine baseline detection (#2424)', () => {
|
||||
test('reapply-patches.md command is deleted (absorbed into update.md --reapply, #2790)', () => {
|
||||
const oldPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md');
|
||||
assert.ok(!fs.existsSync(oldPath), 'reapply-patches.md should be absent (absorbed into update.md --reapply)');
|
||||
});
|
||||
|
||||
test('update.md argument-hint declares --reapply as consolidated entry point', () => {
|
||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
||||
const argHint = parseFrontmatterField(content, 'argument-hint');
|
||||
assert.ok(
|
||||
argHint && argHint.includes('--reapply'),
|
||||
`update.md argument-hint must declare --reapply flag; got: ${argHint || '(none)'}`
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md workflow references backup-meta.json for pristine-hash baseline', () => {
|
||||
// #2790: The behavioral contract (pristine_hashes from backup-meta.json as primary
|
||||
// baseline source) is implemented in the update.md workflow (gsd-core/workflows/update.md),
|
||||
// not the command file. The command delegates via --reapply flag.
|
||||
// Verify the underlying workflow has this content.
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||
const workflowContent = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
workflowContent.includes('backup-meta.json'),
|
||||
'gsd-core/workflows/update.md must reference backup-meta.json (pristine_hashes baseline source)'
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md exists as consolidated entry point', () => {
|
||||
assert.ok(fs.existsSync(UPDATE_MD), 'update.md must exist as consolidated entry point');
|
||||
});
|
||||
|
||||
test('update.md argument-hint declares full consolidated flag surface (--sync | --reapply)', () => {
|
||||
// Validates that both flags absorbed from the deleted micro-skills are declared
|
||||
// in the command contract, not just --reapply alone.
|
||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
||||
const argHint = parseFrontmatterField(content, 'argument-hint');
|
||||
assert.ok(
|
||||
argHint && argHint.includes('--sync') && argHint.includes('--reapply'),
|
||||
`update.md argument-hint must declare both --sync and --reapply; got: ${argHint || '(none)'}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Bug #2432: quick.md PLAN.md timing — worktree executor can't read PLAN.md
|
||||
*
|
||||
* The orchestrator must commit PLAN.md to the base branch BEFORE spawning the
|
||||
* worktree-isolated executor. Without this, the executor's first Read resolves
|
||||
* to a main-repo absolute path (not a worktree path), which primes CC's path
|
||||
* cache and causes subsequent Edit/Write calls to silently target the main repo
|
||||
* instead of the worktree (CC issue #36182 amplifier).
|
||||
*
|
||||
* Fix: Step 5.6 commits PLAN.md pre-dispatch when USE_WORKTREES is active.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => {
|
||||
let content;
|
||||
|
||||
test('quick.md exists', () => {
|
||||
assert.ok(fs.existsSync(QUICK_MD), 'gsd-core/workflows/quick.md must exist');
|
||||
content = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
});
|
||||
|
||||
test('Step 5.6 exists between Step 5.5 and Step 6', () => {
|
||||
const step55 = content.indexOf('Step 5.5');
|
||||
const step56 = content.indexOf('Step 5.6');
|
||||
const step6 = content.indexOf('Step 6:');
|
||||
assert.ok(step55 !== -1, 'Step 5.5 must exist');
|
||||
assert.ok(step56 !== -1, 'Step 5.6 must exist');
|
||||
assert.ok(step6 !== -1, 'Step 6 must exist');
|
||||
assert.ok(step56 > step55, 'Step 5.6 must appear after Step 5.5');
|
||||
assert.ok(step56 < step6, 'Step 5.6 must appear before Step 6');
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on USE_WORKTREES', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('USE_WORKTREES'),
|
||||
'Step 5.6 must be gated on USE_WORKTREES — only commit pre-dispatch in worktree mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on commit_docs', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('commit_docs'),
|
||||
'Step 5.6 must respect commit_docs config — skip pre-dispatch commit when commit_docs is false'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 stages and commits PLAN.md', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('PLAN.md'),
|
||||
'Step 5.6 must reference PLAN.md in the pre-dispatch commit'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('git add') || step56Block.includes('git commit'),
|
||||
'Step 5.6 must include git add/commit to stage and commit PLAN.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 uses --no-verify to avoid hook interference', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('--no-verify'),
|
||||
'Step 5.6 pre-dispatch commit must use --no-verify to avoid hook interference'
|
||||
);
|
||||
});
|
||||
|
||||
test('executor prompt references PLAN.md via relative (worktree-rooted) path', () => {
|
||||
// QUICK_DIR is always set to ".planning/quick/..." (relative) so ${QUICK_DIR}/...PLAN.md
|
||||
// resolves relative to the worktree root, not the main repo absolute path.
|
||||
// Verify the executor prompt uses QUICK_DIR variable (not a hardcoded absolute path).
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
// Find the files_to_read block near the executor spawn
|
||||
const filesBlock = content.lastIndexOf('<files_to_read>', executorTask);
|
||||
const filesBlockEnd = content.indexOf('</files_to_read>', filesBlock);
|
||||
const filesContent = content.slice(filesBlock, filesBlockEnd);
|
||||
assert.ok(
|
||||
filesContent.includes('QUICK_DIR') || filesContent.includes('.planning/quick'),
|
||||
'executor files_to_read must reference PLAN.md via relative QUICK_DIR path'
|
||||
);
|
||||
assert.ok(
|
||||
!filesContent.match(/\/home\/|\/Users\/|\/root\//),
|
||||
'executor files_to_read must NOT contain hardcoded absolute paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 records both parent base and plan commit for worktree dispatch', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start));
|
||||
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_PARENT'),
|
||||
'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_COMMIT'),
|
||||
'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step6Block.includes('EXPECTED_BASE_ALTERNATE'),
|
||||
'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => {
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
const promptStart = content.lastIndexOf('prompt="', executorTask);
|
||||
const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart);
|
||||
const executorPrompt = content.slice(promptStart, promptEnd);
|
||||
|
||||
assert.ok(
|
||||
executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'),
|
||||
'executor prompt must materialize PLAN.md from git show <plan-commit>:<plan-path> when absent (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
executorPrompt.indexOf('git show') < executorPrompt.indexOf('<files_to_read>'),
|
||||
'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2451
|
||||
*
|
||||
* The GSD context monitor hook over-reports usage by ~13 percentage points
|
||||
* compared to Claude Code's native /context command. The root cause:
|
||||
*
|
||||
* gsd-statusline.js writes two values to the bridge file:
|
||||
* - remaining_percentage: raw remaining from CC (e.g. 35%)
|
||||
* - used_pct: normalized "usable" percentage (e.g. 78%) — accounts for
|
||||
* the 16.5% autocompact buffer by scaling: (100 - remaining - buffer) /
|
||||
* (100 - buffer) * 100
|
||||
*
|
||||
* gsd-context-monitor.js displays used_pct (78%) in warning messages.
|
||||
* But CC's native /context shows raw used = 100 - remaining = 65%.
|
||||
* The 13-point gap is exactly the buffer normalization overhead.
|
||||
*
|
||||
* Fix: the bridge must write used_pct as the raw value (Math.round(100 -
|
||||
* remaining)), not the buffer-normalized value. The statusline progress bar
|
||||
* continues to use the normalized value for its own display; only the bridge
|
||||
* value that feeds the context monitor needs to be raw/CC-consistent.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
||||
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
|
||||
/**
|
||||
* Run the statusline hook with a synthetic payload and return the full
|
||||
* bridge JSON object written to /tmp/claude-ctx-{sessionId}.json.
|
||||
*/
|
||||
function runStatuslineHook(remainingPct, totalTokens = 1_000_000, acwEnv = null) {
|
||||
const sessionId = `test-2451-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const payload = JSON.stringify({
|
||||
model: { display_name: 'Claude' },
|
||||
workspace: { current_dir: os.tmpdir() },
|
||||
session_id: sessionId,
|
||||
context_window: {
|
||||
remaining_percentage: remainingPct,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const env = { ...process.env };
|
||||
if (acwEnv != null) {
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
|
||||
} else {
|
||||
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input: payload,
|
||||
env,
|
||||
timeout: 4000,
|
||||
});
|
||||
} catch { /* non-zero exit is fine; we only need the bridge file */ }
|
||||
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8'));
|
||||
fs.unlinkSync(bridgePath);
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the context monitor hook with a pre-written bridge file and return
|
||||
* the parsed additionalContext string from its stdout.
|
||||
*/
|
||||
function runMonitorHook(remainingPct, usedPct) {
|
||||
const sessionId = `test-2451-mon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: usedPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({ session_id: sessionId, cwd: os.tmpdir() });
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
stdout = e.stdout || '';
|
||||
} finally {
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
|
||||
try { fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
if (!stdout) return null;
|
||||
const out = JSON.parse(stdout);
|
||||
return out?.hookSpecificOutput?.additionalContext || null;
|
||||
}
|
||||
|
||||
// ─── Bridge file used_pct accuracy ──────────────────────────────────────────
|
||||
|
||||
describe('bug #2451: bridge used_pct matches CC native reporting', () => {
|
||||
test('used_pct is raw (100 - remaining), not buffer-normalized', () => {
|
||||
// CC reports remaining_percentage=35 → CC native "used" = 100-35 = 65%
|
||||
// Buffer-normalized would give: (100 - (35-16.5)/(100-16.5)*100) ≈ 78%
|
||||
// The bridge used_pct must be 65 (raw), not 78 (normalized).
|
||||
const bridge = runStatuslineHook(35);
|
||||
assert.strictEqual(
|
||||
bridge.used_pct,
|
||||
65,
|
||||
`used_pct should be 65 (raw: 100 - 35) but got ${bridge.used_pct}. ` +
|
||||
'Buffer normalization must NOT be applied to the bridge used_pct, ' +
|
||||
'otherwise context monitor messages over-report usage by ~13 points ' +
|
||||
'compared to CC native /context (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
|
||||
test('used_pct is raw for high remaining (low usage scenario)', () => {
|
||||
// remaining=80 → raw used = 20
|
||||
const bridge = runStatuslineHook(80);
|
||||
assert.strictEqual(bridge.used_pct, 20,
|
||||
`used_pct should be 20 (raw: 100-80) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('used_pct is raw for near-critical remaining', () => {
|
||||
// remaining=20 → raw used = 80
|
||||
const bridge = runStatuslineHook(20);
|
||||
assert.strictEqual(bridge.used_pct, 80,
|
||||
`used_pct should be 80 (raw: 100-20) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('remaining_percentage in bridge matches raw CC value', () => {
|
||||
// The bridge remaining_percentage should be the exact raw value from CC
|
||||
const bridge = runStatuslineHook(42);
|
||||
assert.strictEqual(bridge.remaining_percentage, 42,
|
||||
'bridge remaining_percentage must be the raw CC value (no normalization)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Context monitor message accuracy ───────────────────────────────────────
|
||||
|
||||
describe('bug #2451: context monitor warning messages show CC-consistent percentages', () => {
|
||||
test('WARNING message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=30 → raw used=70; bridge stores used_pct=70
|
||||
// Monitor message must say "Usage at 70%", not a buffer-inflated value
|
||||
const msg = runMonitorHook(30, 70);
|
||||
assert.ok(msg, 'hook should emit a warning when remaining=30 (below WARNING_THRESHOLD=35)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 70%/,
|
||||
`Warning message should say "Usage at 70%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('CRITICAL message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=20 → raw used=80
|
||||
const msg = runMonitorHook(20, 80);
|
||||
assert.ok(msg, 'hook should emit a critical warning when remaining=20 (below CRITICAL_THRESHOLD=25)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 80%/,
|
||||
`Critical message should say "Usage at 80%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gap between hook used_pct and raw CC value is at most 1 (rounding)', () => {
|
||||
// With the fix, the only acceptable deviation is ±1 due to Math.round
|
||||
const rawRemaining = 35;
|
||||
const bridge = runStatuslineHook(rawRemaining);
|
||||
const ccNativeUsed = 100 - rawRemaining; // 65
|
||||
const gap = Math.abs(bridge.used_pct - ccNativeUsed);
|
||||
assert.ok(
|
||||
gap <= 1,
|
||||
`Gap between hook used_pct (${bridge.used_pct}) and CC native used (${ccNativeUsed}) ` +
|
||||
`is ${gap} points — must be ≤1 (rounding). Larger gaps indicate buffer normalization ` +
|
||||
'is still being applied to bridge used_pct (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Regression test for #2470.
|
||||
*
|
||||
* update.md is installed into every runtime directory including .gemini, .codex,
|
||||
* .opencode, etc. The installer's scanForLeakedPaths() uses the regex
|
||||
* /(?:~|\$HOME)\/\.claude\b/g to detect unresolved .claude path references after
|
||||
* copyWithPathReplacement() runs. The replacer handles "~/.claude/" (trailing slash)
|
||||
* but not "~/.claude" (bare, no trailing slash) — so any bare reference in
|
||||
* update.md would slip through and trigger the installer warning for non-Claude runtimes.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||
|
||||
describe('update.md — no bare ~.claude path references (#2470)', () => {
|
||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
||||
|
||||
test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => {
|
||||
// This is the exact pattern from the installer's scanForLeakedPaths():
|
||||
// /(?:~|\$HOME)\/\.claude\b/g
|
||||
// The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude
|
||||
// so we must not have bare references in the source file.
|
||||
const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g);
|
||||
assert.strictEqual(
|
||||
matches,
|
||||
null,
|
||||
`update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2492: Add gates to ensure discuss-phase decisions are translated to
|
||||
* plans (plan-phase, BLOCKING) and verified against shipped artifacts
|
||||
* (verify-phase, NON-BLOCKING).
|
||||
*
|
||||
* These workflow files are loaded as prompts by the corresponding subagents.
|
||||
* The tests below verify that the prompt text contains the gate steps and
|
||||
* the config-toggle skip clauses — losing them silently would regress the
|
||||
* fix.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
const VERIFY_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const SCHEMA_MANIFEST_JSON = path.join(__dirname, '..', 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
|
||||
|
||||
describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
|
||||
|
||||
test('contains a Decision Coverage Gate step', () => {
|
||||
assert.ok(
|
||||
/Decision Coverage Gate/i.test(md),
|
||||
'plan-phase.md must define a Decision Coverage Gate step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-plan handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-plan'),
|
||||
'plan-phase.md must call gsd-sdk query check.decision-coverage-plan',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'plan-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
|
||||
test('decision gate appears AFTER the existing Requirements Coverage Gate', () => {
|
||||
// Anchored heading regexes — avoid prose-substring traps (review F8/F9).
|
||||
const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m);
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate');
|
||||
});
|
||||
|
||||
test('decision gate appears BEFORE plans are committed', () => {
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m);
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit');
|
||||
});
|
||||
|
||||
test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => {
|
||||
// The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`).
|
||||
// The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`,
|
||||
// otherwise the BLOCKING gate is invoked with an empty path and silently skips.
|
||||
const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)');
|
||||
assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON');
|
||||
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
|
||||
// Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition.
|
||||
const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400);
|
||||
assert.ok(
|
||||
snippet.includes('${CONTEXT_PATH}'),
|
||||
'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4',
|
||||
);
|
||||
assert.ok(
|
||||
!snippet.includes('${context_path}'),
|
||||
'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase blocking gate exits non-zero on failure (review F15)', () => {
|
||||
// The gate is documented as BLOCKING. To actually block, the shell snippet must
|
||||
// exit with non-zero status when `passed` is false. Without exit-1 the workflow
|
||||
// continues silently past the failure.
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1);
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
// Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group.
|
||||
const hasJqGuard =
|
||||
/jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet);
|
||||
const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet);
|
||||
assert.ok(
|
||||
hasJqGuard && hasExitOne,
|
||||
'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase gate accepts top-level .passed field from CLI output (#275)', () => {
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
assert.ok(
|
||||
/\.(?:passed)\s*==\s*true\s*\|\|\s*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet),
|
||||
'plan-phase gate must explicitly check top-level .passed with a compatibility fallback to .data.passed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(VERIFY_PHASE, 'utf-8');
|
||||
|
||||
test('contains a verify_decisions step', () => {
|
||||
assert.ok(
|
||||
/verify_decisions/.test(md),
|
||||
'verify-phase.md must define a verify_decisions step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-verify handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-verify'),
|
||||
'verify-phase.md must call gsd-sdk query check.decision-coverage-verify',
|
||||
);
|
||||
});
|
||||
|
||||
test('declares the decision gate as non-blocking / warning only', () => {
|
||||
const lower = md.toLowerCase();
|
||||
assert.ok(
|
||||
lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'),
|
||||
'verify-phase.md must declare the decision gate is non-blocking',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'verify-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime wiring for #2492 gates', () => {
|
||||
test('schema manifest includes context_coverage_gate', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(SCHEMA_MANIFEST_JSON, 'utf-8'));
|
||||
assert.ok(
|
||||
manifest.validKeys.includes('workflow.context_coverage_gate'),
|
||||
'workflow.context_coverage_gate must be present in config-schema manifest',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Tests for bug #2501: resurrection-detection block in execute-phase.md must
|
||||
* check git history before deleting new .planning/ files.
|
||||
*
|
||||
* Root cause: the original logic deleted ANY .planning/ file that was absent
|
||||
* from PRE_MERGE_FILES, which includes brand-new files (e.g. SUMMARY.md)
|
||||
* that the executor just created. A true "resurrection" is a file that was
|
||||
* previously tracked on main, deliberately deleted, and then re-introduced by
|
||||
* a worktree merge. Detecting that requires a git history check, not just a
|
||||
* pre-merge tree membership check.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command. Resurrection detection is handled internally
|
||||
* by the SDK. The inline WAS_DELETED shell check has been removed from the
|
||||
* workflow — it was part of the SDK-absence fallback which is no longer needed
|
||||
* since the preflight block exits if neither local nor global SDK is available.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('execute-phase.md — resurrection-detection guard (#2501)', () => {
|
||||
let content;
|
||||
|
||||
// Load once; each test reads from the cached string.
|
||||
test('file is readable', () => {
|
||||
content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('cleanup delegates to SDK (handles resurrection detection internally)', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// After #3797: execute-phase.md delegates to worktree.cleanup-wave, which
|
||||
// handles pre-merge deletion checks internally. The SDK checks diff --diff-filter=D
|
||||
// before merging, blocking branches that contain file deletions (#2384/#2501).
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave (#2501/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase does not use the buggy PRE_MERGE_FILES form', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// The buggy pattern from before #2501 — deletion conditioned on absence
|
||||
// from PRE_MERGE_FILES snapshot. Must remain absent.
|
||||
const hasBuggyGuard =
|
||||
content.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(content);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #2501)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2502: insert-phase does not update STATE.md's
|
||||
* next-phase recommendation after inserting a decimal phase.
|
||||
*
|
||||
* Root cause: insert-phase.md's update_project_state step only added a
|
||||
* "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase"
|
||||
* / next-run recommendation to point at the newly inserted phase.
|
||||
*
|
||||
* Fix: insert-phase.md must include a step that updates STATE.md's next-phase
|
||||
* pointer (current_phase / next recommended run) to the newly inserted phase.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSERT_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md'
|
||||
);
|
||||
|
||||
describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => {
|
||||
test('insert-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist');
|
||||
});
|
||||
|
||||
test('insert-phase.md contains a STATE.md next-phase update instruction', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
// Must reference STATE.md and the concept of updating the next/current phase pointer
|
||||
const mentionsStateUpdate = (
|
||||
/STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) ||
|
||||
/(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
mentionsStateUpdate,
|
||||
'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase'
|
||||
);
|
||||
});
|
||||
|
||||
test('insert-phase.md update_project_state step covers next-phase pointer', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
const stepMatch = content.match(/<step name="update_project_state">([\s\S]*?)<\/step>/i);
|
||||
assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step');
|
||||
const stepContent = stepMatch[1];
|
||||
|
||||
const hasNextPhasePointerUpdate = (
|
||||
/\bcurrent[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext recommended run\b/i.test(stepContent)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
hasNextPhasePointerUpdate,
|
||||
'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2504
|
||||
*
|
||||
* When UAT testing is mandated and a phase has no user-facing elements
|
||||
* (e.g., code foundations, database schema, internal APIs), the agent
|
||||
* invented artificial UAT steps — things like "manually run git commits",
|
||||
* "manually invoke methods", "manually check database state" — and left
|
||||
* work half-finished specifically to create things for a human to do.
|
||||
*
|
||||
* Fix: The verify-phase workflow's identify_human_verification step must
|
||||
* explicitly handle phases with no user-facing elements by auto-passing UAT
|
||||
* with a logged rationale instead of inventing manual steps.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VERIFY_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a named section from a markdown/workflow document.
|
||||
* Returns the text from `heading` up to (but not including) the next `## ` heading,
|
||||
* or to end-of-file if no subsequent heading exists.
|
||||
*/
|
||||
function extractSection(content, heading) {
|
||||
const start = content.indexOf(heading);
|
||||
if (start === -1) return '';
|
||||
const nextHeading = content.indexOf('\n## ', start + 1);
|
||||
return nextHeading === -1 ? content.slice(start) : content.slice(start, nextHeading);
|
||||
}
|
||||
|
||||
describe('bug #2504: UAT auto-pass for foundation/infrastructure phases', () => {
|
||||
test('verify-phase workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(VERIFY_PHASE_PATH),
|
||||
'gsd-core/workflows/verify-phase.md should exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('identify_human_verification step handles phases with no user-facing elements', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The step must explicitly call out the infrastructure/foundation case
|
||||
const hasInfrastructureHandling =
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing') ||
|
||||
section.includes('no user facing') ||
|
||||
section.includes('internal API') ||
|
||||
section.includes('internal APIs') ||
|
||||
section.includes('database schema') ||
|
||||
section.includes('code foundation');
|
||||
|
||||
assert.ok(
|
||||
hasInfrastructureHandling,
|
||||
'verify-phase.md identify_human_verification step must explicitly handle ' +
|
||||
'infrastructure/foundation phases that have no user-facing elements. Without ' +
|
||||
'this, agents invent artificial manual steps to satisfy UAT requirements ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes auto-pass or skip UAT language for non-user-facing phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasAutoPass =
|
||||
section.includes('auto-pass') ||
|
||||
section.includes('auto pass') ||
|
||||
section.includes('automatically pass') ||
|
||||
section.includes('skip UAT') ||
|
||||
section.includes('skip the UAT') ||
|
||||
section.includes('UAT does not apply') ||
|
||||
section.includes('UAT not applicable') ||
|
||||
section.includes('no UAT required');
|
||||
|
||||
assert.ok(
|
||||
hasAutoPass,
|
||||
'verify-phase.md identify_human_verification step must contain language about ' +
|
||||
'auto-passing or skipping UAT for phases without user-facing elements. Agents ' +
|
||||
'must not invent manual steps when there is nothing user-facing to test ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prohibits inventing artificial manual steps for infrastructure phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The workflow must tell the agent NOT to invent steps when there's nothing to test.
|
||||
// Look for explicit prohibition or the inverse: "do not invent" or "must not create"
|
||||
// or equivalent framing like "only require human testing when..."
|
||||
const hasProhibition =
|
||||
section.includes('do not invent') ||
|
||||
section.includes('must not invent') ||
|
||||
section.includes('never invent') ||
|
||||
section.includes('Do not invent') ||
|
||||
section.includes('Must not invent') ||
|
||||
section.includes('Never invent') ||
|
||||
section.includes('only require human') ||
|
||||
section.includes('only add human') ||
|
||||
(section.includes('only flag') && section.includes('user-facing')) ||
|
||||
// Or via "N/A" framing
|
||||
(section.includes('N/A') && (
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing')
|
||||
));
|
||||
|
||||
assert.ok(
|
||||
hasProhibition,
|
||||
'verify-phase.md identify_human_verification step must explicitly prohibit ' +
|
||||
'inventing artificial manual UAT steps for infrastructure phases. The current ' +
|
||||
'wording causes agents to create fake "manually run git commits" steps to ' +
|
||||
'satisfy UAT mandates (root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes a concept of N/A or not-applicable UAT state', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasNaState =
|
||||
section.includes('N/A') ||
|
||||
section.includes('not applicable') ||
|
||||
section.includes('not_applicable') ||
|
||||
section.includes('no_uat') ||
|
||||
section.includes('uat_not_applicable') ||
|
||||
section.includes('infrastructure phase') ||
|
||||
section.includes('foundation phase');
|
||||
|
||||
assert.ok(
|
||||
hasNaState,
|
||||
'verify-phase.md identify_human_verification step must include some concept of ' +
|
||||
'a "not applicable" or N/A UAT state for phases with no user-facing elements. ' +
|
||||
'This prevents agents from blocking phase completion on invented manual steps ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2506
|
||||
*
|
||||
* /gsd-settings presents Quality/Balanced/Budget model profiles without any
|
||||
* warning that on non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles
|
||||
* select Claude model tiers and have no effect on actual agent model selection.
|
||||
*
|
||||
* Fix: settings.md must include a non-Claude runtime note instructing users to
|
||||
* use "Inherit" or configure model_overrides manually, and the Inherit option
|
||||
* description must explicitly call out non-Claude runtimes.
|
||||
*
|
||||
* Closes: #2506
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
|
||||
|
||||
describe('bug #2506: settings.md non-Claude runtime warning for model profiles', () => {
|
||||
let content;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('settings.md contains a non-Claude runtime note for model profiles', () => {
|
||||
assert.ok(
|
||||
content.includes('non-Claude runtime') || content.includes('non-Claude runtimes'),
|
||||
'settings.md must include a note about non-Claude runtimes and model profiles'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Claude note explains profiles are no-ops without model_overrides', () => {
|
||||
assert.ok(
|
||||
content.includes('model_overrides') || content.includes('no effect'),
|
||||
'note must explain profiles have no effect on non-Claude runtimes without model_overrides'
|
||||
);
|
||||
});
|
||||
|
||||
test('Inherit option description explicitly mentions non-Claude runtimes', () => {
|
||||
// The Inherit option in AskUserQuestion must call out non-Claude runtimes
|
||||
const inheritOptionMatch = content.match(/label:\s*"Inherit"[^}]*description:\s*"([^"]+)"/s);
|
||||
assert.ok(inheritOptionMatch, 'Inherit option with label/description must exist in settings.md');
|
||||
const desc = inheritOptionMatch[1];
|
||||
assert.ok(
|
||||
desc.includes('non-Claude') || desc.includes('Codex') || desc.includes('Gemini'),
|
||||
`Inherit option description must mention non-Claude runtimes; got: "${desc}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for bug #2516
|
||||
*
|
||||
* When `.planning/config.json` has `model_profile: "inherit"`, the
|
||||
* `init.execute-phase` query returns `executor_model: "inherit"`. The
|
||||
* execute-phase workflow was passing this literal string directly to the
|
||||
* Task tool via `model="{executor_model}"`, causing Task to fall back to
|
||||
* its default model instead of inheriting the orchestrator model.
|
||||
*
|
||||
* Fix: the workflow must document that when `executor_model` is `"inherit"`,
|
||||
* the `model=` parameter must be OMITTED from Task() calls entirely.
|
||||
* Omitting `model=` causes Claude Code to inherit the current orchestrator
|
||||
* model automatically.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2516: executor_model "inherit" must not be passed literally to Task()', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains instructions for handling the "inherit" case', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritInstruction =
|
||||
content.includes('"inherit"') &&
|
||||
(content.includes('omit') || content.includes('Omit') || content.includes('omitting') || content.includes('Omitting'));
|
||||
assert.ok(
|
||||
hasInheritInstruction,
|
||||
'execute-phase.md must document that when executor_model is "inherit", ' +
|
||||
'the model= parameter must be omitted from Task() calls. ' +
|
||||
'Found "inherit" mention: ' + content.includes('"inherit"') + '. ' +
|
||||
'Found omit mention: ' + (content.includes('omit') || content.includes('Omit'))
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow does not instruct passing model="inherit" literally to Task', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
// The workflow must not have an unconditional model="{executor_model}" template
|
||||
// that would pass "inherit" through. It should document conditional logic.
|
||||
const hasConditionalModelParam =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('Only set `model=`') ||
|
||||
content.includes('only set `model=`') ||
|
||||
content.includes('Only set model=') ||
|
||||
content.includes('omit the `model=`') ||
|
||||
content.includes('omit the model=') ||
|
||||
content.includes('omit `model=`') ||
|
||||
content.includes('omit model=')
|
||||
);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hasLiteralInheritInTask = lines.some(line => {
|
||||
if (!/model\s*=\s*["']inherit["']/.test(line)) return false;
|
||||
// Exclude instructional/explanatory lines that document what NOT to do
|
||||
return !/\b(not|NOT|don'?t|do not|DO NOT|never|NEVER)\b/.test(line);
|
||||
});
|
||||
assert.ok(
|
||||
!hasLiteralInheritInTask,
|
||||
'execute-phase workflow must not pass literal "inherit" string to Task() model parameter'
|
||||
);
|
||||
assert.ok(
|
||||
hasConditionalModelParam && !hasLiteralInheritInTask,
|
||||
'execute-phase.md must conditionally omit model= when executor_model is "inherit", never pass it literally. ' +
|
||||
'The unconditional model="{executor_model}" template would pass the literal ' +
|
||||
'string "inherit" to Task(), which falls back to the default model instead ' +
|
||||
'of the orchestrator model (root cause of #2516).'
|
||||
);
|
||||
// Guard against a future contributor adding an unconditional model="{executor_model}"
|
||||
// template alongside the conditional docs — that would pass "inherit" literally to Task().
|
||||
const hasUnsafeTemplate = lines.some(line => {
|
||||
if (!/model\s*=\s*['"]\{executor_model\}['"]/.test(line)) return false;
|
||||
return !/\b(not|NOT|do not|DO NOT|don'?t|never|NEVER|omit)\b/i.test(line);
|
||||
});
|
||||
assert.ok(!hasUnsafeTemplate,
|
||||
'execute-phase.md must not contain an unconditional model="{executor_model}" template — ' +
|
||||
'it would pass "inherit" literally to Task() when executor_model is "inherit"'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents that omitting model= causes inheritance from orchestrator', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritanceExplanation =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('orchestrator model') ||
|
||||
content.includes('orchestrator\'s model') ||
|
||||
content.includes('inherits the') ||
|
||||
content.includes('inherit the current')
|
||||
);
|
||||
assert.ok(
|
||||
hasInheritanceExplanation,
|
||||
'execute-phase.md must explain that omitting model= causes Claude Code to ' +
|
||||
'inherit the current orchestrator model — this is the mechanism that makes ' +
|
||||
'"inherit" work correctly.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2520
|
||||
*
|
||||
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
|
||||
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
|
||||
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
|
||||
* v2.1.116. Claude Code applies a separate env filter when spawning
|
||||
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
|
||||
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
|
||||
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
|
||||
* the hook's stdin JSON payload (documented part of Claude Code's hook
|
||||
* input schema).
|
||||
*
|
||||
* Fix: use `data.session_id` as the primary Claude Code signal, with
|
||||
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
|
||||
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
|
||||
* future-proofing.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
/**
|
||||
* Spawn the hook with an env that mirrors the actual Claude Code hook
|
||||
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
|
||||
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
|
||||
* overrides can be supplied via `envOverrides`.
|
||||
*/
|
||||
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const baseEnv = { ...process.env };
|
||||
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
|
||||
delete baseEnv.CLAUDECODE;
|
||||
delete baseEnv.CLAUDE_SESSION_ID;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
|
||||
// Claude Code CLI 2.1.116).
|
||||
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||
CLAUDE_CODE_SSE_PORT: '51291',
|
||||
CLAUDE_PROJECT_DIR: process.cwd(),
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
|
||||
// env fallbacks the helper normally provides. Without this the env
|
||||
// fallback would rescue the skip even if session_id detection broke,
|
||||
// hiding a regression of the primary signal.
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{
|
||||
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
|
||||
},
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
'',
|
||||
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2523
|
||||
*
|
||||
* workflows/quick.md Step 8 ("Build file list") listed PLAN.md, SUMMARY.md,
|
||||
* STATE.md, and mode-conditional CONTEXT.md / RESEARCH.md / VERIFICATION.md —
|
||||
* but omitted deferred-items.md. When an executor logs out-of-scope findings
|
||||
* to ${QUICK_DIR}/${quick_id}-deferred-items.md during task execution, that
|
||||
* file was left untracked after the final commit even with commit_docs: true.
|
||||
*
|
||||
* Fix: add a file-existence-gated entry for deferred-items.md to Step 8.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('bug #2523: quick-task final commit includes deferred-items.md', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/quick.md must exist');
|
||||
});
|
||||
|
||||
test('Step 8 file list references deferred-items.md', () => {
|
||||
const step8Idx = content.indexOf('Step 8: Final commit');
|
||||
assert.notEqual(step8Idx, -1, 'Step 8 section must exist in quick.md');
|
||||
|
||||
const step8Section = content.slice(step8Idx, step8Idx + 2000);
|
||||
assert.ok(
|
||||
step8Section.includes('deferred-items.md'),
|
||||
'Step 8 file list must include deferred-items.md. ' +
|
||||
'Without this, any out-of-scope findings logged by the executor to ' +
|
||||
'${QUICK_DIR}/${quick_id}-deferred-items.md are left untracked after commit (bug #2523).'
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred-items.md entry is conditional on file existence', () => {
|
||||
const deferredIdx = content.indexOf('deferred-items.md');
|
||||
assert.notEqual(deferredIdx, -1, 'deferred-items.md must be mentioned in the workflow');
|
||||
|
||||
const surroundingContext = content.slice(Math.max(0, deferredIdx - 200), deferredIdx + 200);
|
||||
const isConditional =
|
||||
surroundingContext.toLowerCase().includes('exist') ||
|
||||
surroundingContext.toLowerCase().includes('if ');
|
||||
assert.ok(
|
||||
isConditional,
|
||||
'The deferred-items.md entry must be gated on file existence (not a mode flag). ' +
|
||||
'deferred-items.md can be created in any quick-task run, regardless of mode.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for config key bugs:
|
||||
* #2530 — workflow._auto_chain_active is internal state, must not be in VALID_CONFIG_KEYS
|
||||
* #2531 — hooks.workflow_guard is used by hook and documented but missing from VALID_CONFIG_KEYS
|
||||
* #2532 — workflow.ui_review is used in autonomous.md but missing from config validation
|
||||
* #2533 — workflow.max_discuss_passes is used in discuss-phase.md but missing from VALID_CONFIG_KEYS
|
||||
* #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints
|
||||
* #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be
|
||||
* accepted by isValidConfigKey (written by workflows) without being user-visible
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
VALID_CONFIG_KEYS,
|
||||
isCentralConfigKey,
|
||||
isValidConfigKey,
|
||||
} = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
|
||||
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
describe('VALID_CONFIG_KEYS correctness', () => {
|
||||
test('#2530: workflow._auto_chain_active must not be in VALID_CONFIG_KEYS (internal state)', () => {
|
||||
assert.ok(
|
||||
!VALID_CONFIG_KEYS.has('workflow._auto_chain_active'),
|
||||
'workflow._auto_chain_active is internal runtime state and must not be user-settable'
|
||||
);
|
||||
});
|
||||
|
||||
test('#2531: hooks.workflow_guard must be in VALID_CONFIG_KEYS (used by hook, documented)', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('hooks.workflow_guard'),
|
||||
'hooks.workflow_guard is read by gsd-workflow-guard.js hook and documented in CONFIGURATION.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('#2532: workflow.ui_review must remain valid but is no longer centrally owned', () => {
|
||||
assert.strictEqual(
|
||||
isValidConfigKey('workflow.ui_review'),
|
||||
true,
|
||||
'workflow.ui_review is still user-facing config and must validate'
|
||||
);
|
||||
assert.strictEqual(
|
||||
isCentralConfigKey('workflow.ui_review'),
|
||||
false,
|
||||
'workflow.ui_review is owned by the UI capability after ADR-857 Phase 6 cutover'
|
||||
);
|
||||
});
|
||||
|
||||
test('#2533: workflow.max_discuss_passes must be in VALID_CONFIG_KEYS (used in discuss-phase.md)', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.max_discuss_passes'),
|
||||
'workflow.max_discuss_passes is read in discuss-phase.md via gsd-sdk query config-get'
|
||||
);
|
||||
});
|
||||
|
||||
test('#3162: resolve_model_ids must be in VALID_CONFIG_KEYS (documented user-facing key)', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('resolve_model_ids'),
|
||||
'resolve_model_ids is documented in CONFIGURATION.md and read by core.cjs/session-runner.ts'
|
||||
);
|
||||
});
|
||||
|
||||
test('#3162: workflow._auto_chain_active must be accepted by isValidConfigKey (written by workflows)', () => {
|
||||
assert.strictEqual(
|
||||
isValidConfigKey('workflow._auto_chain_active'),
|
||||
true,
|
||||
'workflow._auto_chain_active is written by plan-phase, execute-phase, discuss-phase, transition workflows via config-set'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 Phase 6 capability config ownership', () => {
|
||||
test('migrated capability config keys are valid through the registry, not central schema residue', () => {
|
||||
const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort();
|
||||
assert.ok(capabilityKeys.length > 0, 'expected generated registry config schema keys');
|
||||
|
||||
for (const key of capabilityKeys) {
|
||||
assert.strictEqual(isValidConfigKey(key), true, `${key} must remain accepted by config validation`);
|
||||
assert.strictEqual(isCentralConfigKey(key), false, `${key} must be capability-owned, not central`);
|
||||
assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, `${key} must not remain in central VALID_CONFIG_KEYS`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONFIG_KEY_SUGGESTIONS migration hints (#2535)', () => {
|
||||
let tmpDir;
|
||||
|
||||
test('config-set sub_repos emits "Did you mean planning.sub_repos?" suggestion', (t) => {
|
||||
tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
const result = runGsdTools(['config-set', 'sub_repos', '[]'], tmpDir);
|
||||
assert.ok(!result.success, 'config-set sub_repos should fail');
|
||||
const combined = result.error + result.output;
|
||||
assert.ok(
|
||||
combined.includes('Did you mean') && combined.includes('planning.sub_repos'),
|
||||
`Expected "Did you mean planning.sub_repos?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
||||
);
|
||||
});
|
||||
|
||||
test('config-set plan_checker emits "Did you mean workflow.plan_check?" suggestion', (t) => {
|
||||
tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
const result = runGsdTools(['config-set', 'plan_checker', 'true'], tmpDir);
|
||||
assert.ok(!result.success, 'config-set plan_checker should fail');
|
||||
const combined = result.error + result.output;
|
||||
assert.ok(
|
||||
combined.includes('Did you mean') && combined.includes('workflow.plan_check'),
|
||||
`Expected "Did you mean workflow.plan_check?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
|
||||
/**
|
||||
* Slash-command namespace invariant (#3443) — SCOPED ACTIVE VARIANT.
|
||||
*
|
||||
* History:
|
||||
* #3443 re-establishes `/gsd:<cmd>` as canonical in Claude-facing source text.
|
||||
* The source repo is authored for Claude command registration under
|
||||
* `.claude/commands/gsd/` (namespaced slash commands), while non-Claude runtimes
|
||||
* perform install-time conversion (for example `/gsd:<cmd>` -> `/gsd-<cmd>`).
|
||||
*
|
||||
* Two-tier model (current — see CONTEXT.md § "Slash-command form: directory-level matrix"):
|
||||
* • Claude-facing SOURCE TEXT (commands/, agents/, workflows/, references/,
|
||||
* templates/, hooks/, .clinerules): uses `/gsd:<cmd>` (colon).
|
||||
* THIS test enforces the colon invariant over those directories.
|
||||
* • Runtime-emitter contexts (runtime-slash.cjs, phase-lifecycle-policy.ts,
|
||||
* *.generated.cjs, bug-3584 test file): use `/gsd-<cmd>` (hyphen) per
|
||||
* bug-3584's contract. Those files are EXCLUDED from this scan.
|
||||
*
|
||||
* Scoped invariant enforced here:
|
||||
* No `/gsd-<cmd>` pattern in Claude-facing source files, EXCLUDING the
|
||||
* runtime-emitter contexts listed in RUNTIME_EMITTER_EXCLUDES below.
|
||||
*
|
||||
* Canonical reference for the runtime-emitter (hyphen-form) contract:
|
||||
* tests/bug-3584-runtime-slash-emitters.test.cjs
|
||||
*
|
||||
* DO NOT expand RUNTIME_EMITTER_EXCLUDES without also updating the bug-3584
|
||||
* test and CONTEXT.md § "Slash-command form: directory-level matrix".
|
||||
*
|
||||
* See also: PR #154 first-pass incident (agent applied outdated invariant,
|
||||
* broke bug-3584 contract); PR #164 Codex adversarial review (surfaced the
|
||||
* need to re-activate this test with explicit exclusions).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
// Runtime-emitter contexts: these files intentionally emit `/gsd-<cmd>` (hyphen)
|
||||
// as part of the bug-3584 runtime contract. They must NOT be scanned by this
|
||||
// invariant — doing so caused PR #154 first-pass to revert correct hyphen form
|
||||
// to colon form, breaking bug-3584-runtime-slash-emitters.test.cjs.
|
||||
//
|
||||
// Expand this list only if a new runtime-emitter module is introduced AND the
|
||||
// bug-3584 test is updated to cover it.
|
||||
|
||||
const SEARCH_DIRS = [
|
||||
// NOTE: gsd-core/bin/lib is intentionally EXCLUDED from SEARCH_DIRS.
|
||||
// runtime-slash.cjs and *.generated.cjs live there and use the hyphen form
|
||||
// per bug-3584's runtime-emitter contract. The full bin/lib tree is
|
||||
// runtime-emitter territory — scanning it would cause false positives.
|
||||
path.join(ROOT, 'gsd-core', 'workflows'),
|
||||
path.join(ROOT, 'gsd-core', 'references'),
|
||||
path.join(ROOT, 'gsd-core', 'templates'),
|
||||
COMMANDS_DIR,
|
||||
path.join(ROOT, 'agents'),
|
||||
path.join(ROOT, 'hooks'),
|
||||
];
|
||||
|
||||
const TOP_LEVEL_FILES = [
|
||||
path.join(ROOT, '.clinerules'),
|
||||
];
|
||||
|
||||
// Re-use SKIP_DIRS from the production script so the test's directory walker
|
||||
// stays in lockstep with the fixer's. EXTENSIONS legitimately diverges (the
|
||||
// guard scans only `.md`/`.cjs`/`.js` per the no-source-grep standard, while
|
||||
// the fixer also rewrites `.ts`/`.tsx`), so it is not shared.
|
||||
const { SKIP_DIRS } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
|
||||
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
|
||||
|
||||
function collectFiles(dir, results = []) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (SKIP_DIRS.has(e.name)) continue;
|
||||
collectFiles(full, results);
|
||||
}
|
||||
else if (EXTENSIONS.has(path.extname(e.name))) results.push(full);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const cmdNames = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => f.replace(/\.md$/, ''))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
const retiredPattern = new RegExp(`/gsd-(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`);
|
||||
|
||||
const allFiles = SEARCH_DIRS.flatMap(d => collectFiles(d));
|
||||
const topLevelFiles = TOP_LEVEL_FILES.filter((file) => fs.existsSync(file));
|
||||
const allUserFacingFiles = allFiles.concat(topLevelFiles);
|
||||
|
||||
describe('slash-command namespace invariant (#3443)', () => {
|
||||
test('commands/gsd/ directory contains known command files', () => {
|
||||
assert.ok(cmdNames.length > 0, 'commands/gsd/ must contain .md files');
|
||||
assert.ok(cmdNames.includes('plan-phase'), 'plan-phase must be a known command');
|
||||
assert.ok(cmdNames.includes('execute-phase'), 'execute-phase must be a known command');
|
||||
});
|
||||
|
||||
// SCOPED ACTIVE INVARIANT (2026-05-23 re-activation after Codex adversarial review of PR #164).
|
||||
//
|
||||
// Scan is scoped to Claude-facing source directories only (SEARCH_DIRS above).
|
||||
// gsd-core/bin/lib/ is excluded entirely — runtime-slash.cjs and
|
||||
// *.generated.cjs there use hyphen form per bug-3584's runtime-emitter contract.
|
||||
//
|
||||
// If this test fails: check CONTEXT.md § "Slash-command form: directory-level matrix"
|
||||
// before deciding whether to update the file or add to RUNTIME_EMITTER_EXCLUDES.
|
||||
test('no /gsd-<cmd> retired syntax in Claude-facing source files (scoped — excludes runtime-emitter contexts)', () => {
|
||||
const violations = [];
|
||||
for (const file of allUserFacingFiles) {
|
||||
const src = fs.readFileSync(file, 'utf-8');
|
||||
const lines = src.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (retiredPattern.test(lines[i])) {
|
||||
violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Found ${violations.length} retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead:\n${violations.slice(0, 10).join('\n')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('command filenames use canonical hyphenated command slugs', () => {
|
||||
const underscoreFiles = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md') && f.includes('_'));
|
||||
assert.deepStrictEqual(
|
||||
underscoreFiles,
|
||||
[],
|
||||
'command filenames feed generated skill/autocomplete names and must not contain underscores',
|
||||
);
|
||||
});
|
||||
|
||||
describe('fix-slash-commands transformer behavior', () => {
|
||||
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
// Use the live command names so the transformer matches the same surface
|
||||
// the production CLI rewrites.
|
||||
const liveCmdNames = cmdNames;
|
||||
|
||||
test('rewrites /gsd-<cmd> to /gsd:<cmd>', () => {
|
||||
const out = transformContent('See /gsd-plan-phase for details.', liveCmdNames);
|
||||
assert.ok(out.includes('/gsd:plan-phase'), `expected /gsd:plan-phase, got: ${out}`);
|
||||
assert.ok(!out.includes('/gsd-plan-phase'), `dash form must not survive, got: ${out}`);
|
||||
});
|
||||
|
||||
test('rewrites multiple occurrences in one pass', () => {
|
||||
const out = transformContent('Run /gsd-plan-phase then /gsd-execute-phase.', liveCmdNames);
|
||||
assert.ok(out.includes('/gsd:plan-phase'));
|
||||
assert.ok(out.includes('/gsd:execute-phase'));
|
||||
assert.ok(!out.match(/\/gsd-[a-z]/), `no dash form may remain, got: ${out}`);
|
||||
});
|
||||
|
||||
test('does not rewrite canonical colon form (idempotent)', () => {
|
||||
const input = '/gsd:plan-phase is the canonical name.';
|
||||
assert.strictEqual(transformContent(input, liveCmdNames), input,
|
||||
'transformer must be a no-op when input is already canonical');
|
||||
});
|
||||
|
||||
test('does not rewrite gsd-sdk or gsd-tools (not slash commands)', () => {
|
||||
const input = 'Run /gsd-sdk query and /gsd-tools init.';
|
||||
assert.strictEqual(transformContent(input, liveCmdNames), input,
|
||||
'transformer must leave non-command identifiers alone');
|
||||
});
|
||||
|
||||
test('respects word boundary — does not rewrite /gsd-plan-phase-extra', () => {
|
||||
const out = transformContent('/gsd-plan-phase-extra', liveCmdNames);
|
||||
assert.strictEqual(out, '/gsd-plan-phase-extra',
|
||||
'word-boundary lookahead must prevent partial matches');
|
||||
});
|
||||
});
|
||||
|
||||
test('transformer leaves non-command identifiers untouched', () => {
|
||||
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const sample = 'Use /gsd-sdk query and node bin/gsd-tools.cjs';
|
||||
assert.strictEqual(
|
||||
transformContent(sample, cmdNames),
|
||||
sample,
|
||||
'gsd-sdk and gsd-tools are not slash commands and must remain untouched'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Regression test for issue #2545.
|
||||
*
|
||||
* The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements
|
||||
* only matched when a literal slash followed, so bare `~/.claude` references
|
||||
* (end of line, quotes, punctuation) were left unreplaced. Those leaks then
|
||||
* triggered the installer's "Found N unreplaced .claude path reference(s)"
|
||||
* warning, which scans for `(?:~|$HOME)/\.claude\b`.
|
||||
*
|
||||
* Fix: replace with a word-boundary pattern so both forms are caught in a
|
||||
* single pass, matching the approach already used by the Antigravity, OpenCode,
|
||||
* Kilo, and Codex converters.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { convertClaudeToCopilotContent } = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => {
|
||||
test('global install replaces bare ~/.claude at end of line', () => {
|
||||
const input = 'configDir = ~/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ true);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
assert.match(out, /~\/\.copilot\b/);
|
||||
});
|
||||
|
||||
test('global install replaces bare $HOME/.claude at end of line', () => {
|
||||
const input = 'configDir = $HOME/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ true);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
assert.match(out, /\$HOME\/\.copilot\b/);
|
||||
});
|
||||
|
||||
test('global install replaces bare ~/.claude before punctuation', () => {
|
||||
const input = 'paths include `~/.claude`, `~/.copilot`';
|
||||
const out = convertClaudeToCopilotContent(input, true);
|
||||
assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out));
|
||||
});
|
||||
|
||||
test('local install replaces bare ~/.claude', () => {
|
||||
const input = 'configDir = ~/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ false);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not double-replace trailing-slash form', () => {
|
||||
const input = '@~/.claude/gsd-core/foo.md\n';
|
||||
const out = convertClaudeToCopilotContent(input, true);
|
||||
assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/);
|
||||
assert.ok(!/\.copilot\/\.copilot/.test(out));
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Bugs #2549, #2550, #2552: discuss-phase context bloat and cache invalidation.
|
||||
*
|
||||
* #2549: load_prior_context must cap prior CONTEXT.md reads (was O(phases))
|
||||
* #2550: scout_codebase must select maps by phase type (was always all 7)
|
||||
* #2552: scout_codebase must not instruct split reads of the same file
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const DISCUSS_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type
|
||||
// table and split-reads warning live in references/scout-codebase.md.
|
||||
const SCOUT_REF = path.join(
|
||||
__dirname, '..', 'gsd-core', 'references', 'scout-codebase.md',
|
||||
);
|
||||
|
||||
function readDiscussContext() {
|
||||
// Both files are required after the discuss-phase/modes split — fail loudly if either is missing
|
||||
// rather than silently weakening the regression coverage.
|
||||
for (const p of [DISCUSS_PHASE, SCOUT_REF]) {
|
||||
assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`);
|
||||
}
|
||||
return [DISCUSS_PHASE, SCOUT_REF].map(p => fs.readFileSync(p, 'utf-8')).join('\n');
|
||||
}
|
||||
|
||||
describe('discuss-phase context fixes (#2549, #2550, #2552)', () => {
|
||||
let src;
|
||||
test('discuss-phase.md source exists', () => {
|
||||
assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(SCOUT_REF),
|
||||
'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split',
|
||||
);
|
||||
src = readDiscussContext();
|
||||
});
|
||||
|
||||
// ─── #2549: load_prior_context cap ──────────────────────────────────────
|
||||
test('#2549: load_prior_context must NOT instruct reading ALL prior CONTEXT.md files', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('For each CONTEXT.md where phase number < current phase'),
|
||||
'load_prior_context must not unboundedly read all prior CONTEXT.md files',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2549: load_prior_context must reference a bounded read (3 phases or DECISIONS-INDEX)', () => {
|
||||
// Read ONLY the parent file — `src.includes('3')` against the
|
||||
// concatenated source can be satisfied by unrelated occurrences of "3"
|
||||
// in scout-codebase.md (e.g., "3-5 most relevant files"), masking a
|
||||
// regression where the parent drops the bounded-read instruction.
|
||||
const parent = fs.readFileSync(DISCUSS_PHASE, 'utf-8');
|
||||
const hasBound = /\b(?:most recent|latest|last|up to)\s+3\b[\s\S]{0,160}\bprior CONTEXT\.md\b/i.test(parent);
|
||||
const hasIndex = parent.includes('DECISIONS-INDEX.md');
|
||||
assert.ok(
|
||||
hasBound || hasIndex,
|
||||
'load_prior_context must reference a bounded read (e.g., most recent 3 phases) or DECISIONS-INDEX.md',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── #2550: scout_codebase phase-type selection ──────────────────────────
|
||||
test('#2550: scout_codebase must not instruct reading all 7 codebase maps', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type)'),
|
||||
'scout_codebase must not use the old vague "most relevant" instruction without a selection table',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2550: scout_codebase must include a phase-type-to-maps selection table', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
// The table maps phase types to specific map selections
|
||||
assert.ok(
|
||||
src.includes('Phase type') && src.includes('Read these maps'),
|
||||
'scout_codebase must include a phase-type to map-selection table',
|
||||
);
|
||||
// Key phase types must be covered
|
||||
assert.ok(src.includes('UI') || src.includes('frontend'), 'Table must cover UI/frontend phases');
|
||||
assert.ok(src.includes('Backend') || src.includes('API'), 'Table must cover backend phases');
|
||||
assert.ok(src.includes('Testing'), 'Table must cover testing phases');
|
||||
assert.ok(src.includes('Mixed'), 'Table must have a fallback for mixed/unclear phases');
|
||||
});
|
||||
|
||||
// ─── #2552: no split reads ───────────────────────────────────────────────
|
||||
test('#2552: scout_codebase must explicitly prohibit split reads of the same file', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
const prohibitsSplit = src.includes('split reads') || src.includes('split read');
|
||||
assert.ok(
|
||||
prohibitsSplit,
|
||||
'scout_codebase must explicitly warn against split reads (same file, two offsets) that break prompt cache',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2554:
|
||||
* state disk-scan excludes decimal phase dirs (e.g. "00.1") from progress counts.
|
||||
*
|
||||
* Root cause: getMilestonePhaseFilter normalized phase IDs with `replace(/^0+/, '')`,
|
||||
* which over-strips on decimals: "00.1" → ".1", while the disk-side extractor
|
||||
* applied to "00.1-<slug>" yields "0.1" — so the dir is excluded from the milestone.
|
||||
*
|
||||
* Fix: strip leading zeros only when followed by a digit (`replace(/^0+(?=\d)/, '')`),
|
||||
* preserving the zero before the decimal point.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||||
|
||||
describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('matches decimal phase directory like "00.1-<slug>" against ROADMAP phase "00.1"', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'## Roadmap v1.0: Current',
|
||||
'',
|
||||
'### Phase 0: Foundation',
|
||||
'**Goal:** foundation',
|
||||
'',
|
||||
'### Phase 00.1: Inserted urgent work',
|
||||
'**Goal:** inserted',
|
||||
'',
|
||||
'### Phase 1: Feature',
|
||||
'**Goal:** feature',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
|
||||
// Phase 00.1 inserted between Phase 0 and Phase 1 must match its on-disk dir.
|
||||
assert.strictEqual(
|
||||
filter('00.1-app-namespace-rename'),
|
||||
true,
|
||||
'decimal phase dir "00.1-<slug>" must be counted in the milestone'
|
||||
);
|
||||
|
||||
// Neighbours should still match (no regression).
|
||||
assert.strictEqual(filter('0-foundation'), true);
|
||||
assert.strictEqual(filter('1-feature'), true);
|
||||
});
|
||||
|
||||
test('preserves existing behavior for zero-padded integer phases', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'## Roadmap v1.0: Current',
|
||||
'',
|
||||
'### Phase 01: One',
|
||||
'**Goal:** g',
|
||||
'',
|
||||
'### Phase 10: Ten',
|
||||
'**Goal:** g',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
assert.strictEqual(filter('01-one'), true);
|
||||
assert.strictEqual(filter('10-ten'), true);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR.
|
||||
*
|
||||
* $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI does
|
||||
* not set it. On Windows, Gemini's own variable-substitution + path-join logic
|
||||
* produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing
|
||||
* every local project hook to fail at SessionStart.
|
||||
*
|
||||
* Fix: localPrefix is now runtime-conditional. Gemini/Antigravity use bare
|
||||
* dirName (relative path) since they always run project hooks with the project
|
||||
* dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||||
const { projectLocalHookPrefix, projectShellCommandText } = projection;
|
||||
|
||||
describe('bug #2557: Gemini/Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR)', () => {
|
||||
test('Gemini local prefix is bare dirName', () => {
|
||||
assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini');
|
||||
});
|
||||
|
||||
test('Antigravity local prefix is bare dirName', () => {
|
||||
assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents' }), '.agents');
|
||||
});
|
||||
|
||||
test('non-Gemini local prefix remains $CLAUDE_PROJECT_DIR anchored', () => {
|
||||
assert.equal(
|
||||
projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }),
|
||||
'"$CLAUDE_PROJECT_DIR"/.claude',
|
||||
);
|
||||
});
|
||||
|
||||
test('Gemini local command projection does not contain "$CLAUDE_PROJECT_DIR"', () => {
|
||||
const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' });
|
||||
const command = projectShellCommandText({
|
||||
runnerToken: '"/usr/local/bin/node"',
|
||||
argTokens: [`${prefix}/hooks/gsd-check-update.js`],
|
||||
runtime: 'gemini',
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(
|
||||
!command.includes('$CLAUDE_PROJECT_DIR'),
|
||||
'Gemini local command must not include $CLAUDE_PROJECT_DIR',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2559: Stale document references in Research phase
|
||||
*
|
||||
* The gsd-phase-researcher and gsd-project-researcher agents instruct
|
||||
* WebSearch queries to always include "current year" (or a hardcoded
|
||||
* year). This biases results toward stale dated content as time passes
|
||||
* (e.g., a 2024 query run in 2026 returns stale results).
|
||||
*
|
||||
* Fix: Remove year-injection instructions from research agent
|
||||
* WebSearch guidance so searches return current results.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PHASE_RESEARCHER = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'agents',
|
||||
'gsd-phase-researcher.md'
|
||||
);
|
||||
const PROJECT_RESEARCHER = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'agents',
|
||||
'gsd-project-researcher.md'
|
||||
);
|
||||
|
||||
const FILES = [
|
||||
{ label: 'gsd-phase-researcher.md', path: PHASE_RESEARCHER },
|
||||
{ label: 'gsd-project-researcher.md', path: PROJECT_RESEARCHER },
|
||||
];
|
||||
|
||||
describe('research agents do not inject year into web searches (#2559)', () => {
|
||||
for (const { label, path: filePath } of FILES) {
|
||||
test(`${label} contains no CURRENT_YEAR placeholder`, () => {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
assert.ok(
|
||||
!/CURRENT_YEAR/.test(content),
|
||||
`${label} must not contain CURRENT_YEAR placeholder (causes stale-year injection)`
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} contains no hardcoded year in web search instructions`, () => {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const match = content.match(/\b20(2[3-9]|[3-9]\d)\b/);
|
||||
assert.ok(
|
||||
!match,
|
||||
`${label} must not contain hardcoded year (found "${match && match[0]}") — biases searches toward stale content`
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} does not instruct searches to include year or current year`, () => {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Match phrases like "include current year", "year in searches",
|
||||
// "[current year]", "with year", etc.
|
||||
const patterns = [
|
||||
/include\s+(?:the\s+)?current\s+year/i,
|
||||
/current\s+year/i,
|
||||
/year\s+in\s+(?:searches|queries)/i,
|
||||
/\[current year\]/i,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
assert.ok(
|
||||
!pat.test(content),
|
||||
`${label} must not instruct year injection (matched /${pat.source}/)`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,660 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #260 — gsd-worktree-path-guard.js
|
||||
*
|
||||
* Executor agents spawned with isolation="worktree" sometimes issue Edit/Write
|
||||
* calls with absolute paths rooted at the MAIN repository instead of the
|
||||
* worktree. The prose guard in gsd-executor.md step 0b is skipped under load,
|
||||
* so we enforce the constraint at the tooling layer with a PreToolUse hook.
|
||||
*
|
||||
* This file verifies all guard behaviours:
|
||||
* 1. No-op in the main repo (.git is a directory)
|
||||
* 2. Relative path always passes
|
||||
* 3. Non-Edit/Write tools always pass
|
||||
* 4. Absolute path inside worktree root passes
|
||||
* 5. Absolute path outside worktree root is BLOCKED (exit 2)
|
||||
* 6. Sibling path that merely shares a prefix is BLOCKED (/ boundary check)
|
||||
* 7. install.js has an fs.existsSync guard for gsd-worktree-path-guard.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync, execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js');
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
/**
|
||||
* Resolve symlinks in a path so that we compare the same canonical form
|
||||
* that `git rev-parse --show-toplevel` returns. On macOS /tmp is a symlink
|
||||
* to /private/tmp, which causes path prefix checks to fail without this.
|
||||
*/
|
||||
function realp(p) {
|
||||
try { return fs.realpathSync(p); } catch { return p; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain git repo (main repo — .git is a directory).
|
||||
*/
|
||||
function makeMainRepo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-main-'));
|
||||
git(dir, ['init', '-q']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'Test User']);
|
||||
git(dir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
||||
git(dir, ['add', 'README.md']);
|
||||
git(dir, ['commit', '-q', '-m', 'chore: init']);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree off mainRepo and return its path.
|
||||
* In the worktree, .git is a FILE (the gitdir pointer).
|
||||
* @param {string} mainRepo - path to the main repo
|
||||
* @param {string} [branchName] - branch name to use (default: 'worktree-agent-test')
|
||||
*/
|
||||
function makeWorktree(mainRepo, branchName) {
|
||||
const branch = branchName || 'worktree-agent-test';
|
||||
const wtDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-wt-'));
|
||||
fs.rmdirSync(wtDir); // git worktree add creates the dir itself
|
||||
git(mainRepo, ['worktree', 'add', '-q', '-b', branch, wtDir]);
|
||||
return wtDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the hook with a given payload, returning the spawnSync result.
|
||||
*/
|
||||
function runHook(cwd, payload) {
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
cwd,
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mainRepo;
|
||||
let worktreeDir;
|
||||
|
||||
before(() => {
|
||||
mainRepo = realp(makeMainRepo());
|
||||
worktreeDir = realp(makeWorktree(mainRepo));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Remove worktree registration before deleting the directory
|
||||
try { git(mainRepo, ['worktree', 'remove', '--force', worktreeDir]); } catch { /* ignore */ }
|
||||
cleanup(mainRepo);
|
||||
cleanup(worktreeDir);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #260: gsd-worktree-path-guard.js', () => {
|
||||
|
||||
// 1. No-op in main repo
|
||||
describe('no-op in main repo', () => {
|
||||
test('Edit call in main repo (.git is a directory) exits 0', () => {
|
||||
const payload = {
|
||||
cwd: mainRepo,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(mainRepo, payload);
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0 in main repo, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no stdout in main repo no-op');
|
||||
});
|
||||
|
||||
test('Write call in main repo exits 0', () => {
|
||||
const payload = {
|
||||
cwd: mainRepo,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
|
||||
};
|
||||
const result = runHook(mainRepo, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Relative path always passes
|
||||
describe('relative path', () => {
|
||||
test('Edit with relative file_path exits 0 even in worktree', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: 'src/foo.ts' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0, `Relative path should always pass. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
|
||||
test('Write with relative file_path exits 0 in worktree', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: 'dist/bundle.js' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Non-Edit/Write tools always pass
|
||||
describe('non-Edit/Write tools', () => {
|
||||
test('Bash tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test('Read tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: path.join(mainRepo, 'README.md') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test('Grep tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'foo', path: mainRepo },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Absolute path inside worktree passes
|
||||
describe('path inside worktree', () => {
|
||||
test('Edit with absolute path inside worktree root exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0, `Path inside worktree should pass. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
|
||||
test('Edit targeting exactly the worktree root exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: worktreeDir },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Absolute path outside worktree is BLOCKED
|
||||
describe('path outside worktree is blocked', () => {
|
||||
test('Edit targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2, `Expected exit 2 (block), got ${result.status}. stderr: ${result.stderr}`);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
});
|
||||
|
||||
test('Write targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('block output includes the offending path in reason', () => {
|
||||
const offendingPath = path.join(mainRepo, 'src', 'leak.ts');
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: offendingPath },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(
|
||||
parsed.reason && parsed.reason.includes(offendingPath),
|
||||
`block reason should include the offending path. Got: ${parsed.reason}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Sibling directory path is BLOCKED (validates the '/' boundary check AND prefix-overlap)
|
||||
describe('sibling path is blocked', () => {
|
||||
test('path that shares prefix with worktree root but is a sibling exits 2', () => {
|
||||
// This test exercises BOTH the prefix-overlap boundary check AND the different-git-root block:
|
||||
// worktree = <base>/wt
|
||||
// sibling = <base>/wt-sibling ← shares "wt" prefix with the worktree root
|
||||
// target = <base>/wt-sibling/file.ts
|
||||
//
|
||||
// A naive startsWith(wtRoot) check would wrongly classify "<base>/wt-sibling/..." as inside
|
||||
// the worktree (it doesn't include the '/' boundary). The hook resolves the sibling's git
|
||||
// toplevel (a different repo) so the different-git-root block fires regardless.
|
||||
// (#1342: paths outside all git repos now fail open; only different-git-root blocks.)
|
||||
const base = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-sib-base-')));
|
||||
const wtDir = path.join(base, 'wt');
|
||||
const siblingRepoDir = path.join(base, 'wt-sibling');
|
||||
// We need a genuine linked worktree at <base>/wt and a separate git repo at <base>/wt-sibling.
|
||||
// Create a fresh main repo to host this worktree (the fixture worktree is already allocated).
|
||||
const sibMainRepo = realp(makeMainRepo());
|
||||
try {
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
// Create linked worktree at <base>/wt (using sibMainRepo as its host).
|
||||
git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]);
|
||||
// Create a separate git repo at <base>/wt-sibling (shares "wt" prefix).
|
||||
fs.mkdirSync(siblingRepoDir, { recursive: true });
|
||||
git(siblingRepoDir, ['init', '-q']);
|
||||
git(siblingRepoDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(siblingRepoDir, ['config', 'user.name', 'Test User']);
|
||||
git(siblingRepoDir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(siblingRepoDir, 'README.md'), '# sibling\n');
|
||||
git(siblingRepoDir, ['add', 'README.md']);
|
||||
git(siblingRepoDir, ['commit', '-q', '-m', 'chore: sibling init']);
|
||||
|
||||
// Confirm prefix-overlap: siblingRepoDir starts with wtDir (without trailing sep).
|
||||
assert.ok(
|
||||
siblingRepoDir.startsWith(wtDir),
|
||||
`Sibling "${siblingRepoDir}" must share a string prefix with worktree "${wtDir}" for this test to be meaningful`
|
||||
);
|
||||
// Confirm they are genuinely distinct (different toplevel).
|
||||
assert.notStrictEqual(
|
||||
realp(siblingRepoDir), realp(wtDir),
|
||||
'sibling and worktree must be different directories'
|
||||
);
|
||||
|
||||
const siblingPath = path.join(realp(siblingRepoDir), 'file.ts');
|
||||
const payload = {
|
||||
cwd: realp(wtDir),
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: siblingPath },
|
||||
};
|
||||
const result = runHook(realp(wtDir), payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Path inside a prefix-sibling git repo "${siblingPath}" must be blocked (exit 2), got ${result.status}. ` +
|
||||
`This validates both the prefix-overlap boundary and the different-git-root block. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
} finally {
|
||||
try { git(sibMainRepo, ['worktree', 'remove', '--force', wtDir]); } catch { /* ignore */ }
|
||||
cleanup(sibMainRepo);
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 7. Adversarial: subdirectory cwd still guards correctly (Codex finding #2)
|
||||
describe('subdirectory cwd', () => {
|
||||
test('hook fires when cwd is a subdirectory of the worktree, not just its root', () => {
|
||||
// The orchestrator may set cwd to a subdirectory. The hook must still
|
||||
// detect the worktree context via git rev-parse --git-dir and block.
|
||||
const subDir = path.join(worktreeDir, 'src');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const payload = {
|
||||
cwd: subDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(subDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Hook must block even when cwd is a subdirectory of the worktree. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('path inside worktree passes even when cwd is a subdirectory', () => {
|
||||
const subDir = path.join(worktreeDir, 'src');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const payload = {
|
||||
cwd: subDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(subDir, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Absolute path inside worktree should pass regardless of cwd. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 8. Adversarial: `..` traversal is normalised before the containment check (Codex finding #1)
|
||||
describe('dot-dot traversal is blocked', () => {
|
||||
test('path with .. that escapes the worktree is blocked', () => {
|
||||
// Construct the traversal target inside a SEPARATE git repo that is
|
||||
// guaranteed to be outside the worktree on every platform (no symlink
|
||||
// ambiguity). The hook finds the external dir's git toplevel (a different
|
||||
// repo → different-git-root block).
|
||||
// (#1342: paths outside all git repos now fail open; only different-git-root blocks,
|
||||
// so externalDir must be inside a real different git repo to exercise the block.)
|
||||
const externalDir = realp(makeMainRepo());
|
||||
try {
|
||||
// Sanity: the external directory must not be inside the worktree.
|
||||
assert.ok(
|
||||
!externalDir.startsWith(worktreeDir + path.sep) && externalDir !== worktreeDir,
|
||||
`externalDir "${externalDir}" must be outside worktreeDir "${worktreeDir}"`
|
||||
);
|
||||
|
||||
// Build a traversal path that uses ../ segments to climb out of the
|
||||
// worktree and into externalDir. path.resolve() will normalise it to
|
||||
// externalDir/file.ts, which is outside the worktree by construction.
|
||||
// We compute the number of segments needed to reach the filesystem root
|
||||
// from worktreeDir so the traversal always lands at the right level
|
||||
// regardless of how deep the worktree path is.
|
||||
// Build a file_path containing literal `..` segments that climb out of the
|
||||
// worktree into externalDir. path.relative() yields a ..-laden relative path
|
||||
// between two same-drive absolute paths (both live under os.tmpdir()); we
|
||||
// re-anchor it at worktreeDir via STRING CONCAT (NOT path.join, which would
|
||||
// normalise the `..` away) so the hook's path.resolve() must collapse it.
|
||||
// Windows-safe: avoids the drive-letter doubling that
|
||||
// path.join(worktreeDir, '..', absolutePath) produces on win32 (#1342).
|
||||
const externalTarget = path.join(externalDir, 'file.ts');
|
||||
const traversalPath = worktreeDir + path.sep + path.relative(worktreeDir, externalTarget);
|
||||
|
||||
// Confirm the resolved path is truly outside the worktree (test integrity guard).
|
||||
const resolved = path.resolve(traversalPath);
|
||||
assert.ok(
|
||||
!resolved.startsWith(worktreeDir + path.sep) && resolved !== worktreeDir,
|
||||
`Traversal resolved to "${resolved}" which is still inside worktreeDir "${worktreeDir}". ` +
|
||||
`This means the test itself is broken, not a production bug.`
|
||||
);
|
||||
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: traversalPath },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Traversal path "${traversalPath}" resolves to "${resolved}" which is outside the worktree. ` +
|
||||
`Must be blocked (exit 2). Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block',
|
||||
`Expected decision:"block", got: ${JSON.stringify(parsed)}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(externalDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 9. MultiEdit is also guarded (Codex finding #5)
|
||||
describe('MultiEdit tool is guarded', () => {
|
||||
test('MultiEdit with outside absolute path is blocked', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`MultiEdit targeting outside path must be blocked. Got ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('MultiEdit with inside absolute path passes', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`MultiEdit inside worktree should pass. Got ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1342 — GSD-activity gate + fail-open for no-repo targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
|
||||
// Fixtures: one non-agent linked worktree (plain user branch) + one agent worktree
|
||||
let mainRepo1342;
|
||||
let nonAgentWorktree; // on branch 'feature-x' — non-GSD
|
||||
let agentWorktree; // on branch 'worktree-agent-foo' — GSD-managed
|
||||
|
||||
before(() => {
|
||||
mainRepo1342 = realp(makeMainRepo());
|
||||
nonAgentWorktree = realp(makeWorktree(mainRepo1342, 'feature-x'));
|
||||
agentWorktree = realp(makeWorktree(mainRepo1342, 'worktree-agent-foo'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { git(mainRepo1342, ['worktree', 'remove', '--force', nonAgentWorktree]); } catch { /* ignore */ }
|
||||
try { git(mainRepo1342, ['worktree', 'remove', '--force', agentWorktree]); } catch { /* ignore */ }
|
||||
cleanup(mainRepo1342);
|
||||
cleanup(nonAgentWorktree);
|
||||
cleanup(agentWorktree);
|
||||
});
|
||||
|
||||
// Test 1 — reporter repro: non-agent worktree writing outside all git repos → exit 0
|
||||
test('(1) non-agent linked worktree: Write to a path outside all git repos exits 0 (no block)', () => {
|
||||
// Simulates Claude Code plan-mode writing ~/.claude/plans/<slug>.md from a
|
||||
// manually-created linked worktree that is NOT on a worktree-agent-* branch.
|
||||
const plansDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-plans-'));
|
||||
try {
|
||||
const targetPath = path.join(plansDir, 'my-plan.md');
|
||||
const payload = {
|
||||
cwd: nonAgentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: targetPath },
|
||||
};
|
||||
const result = runHook(nonAgentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Non-agent linked worktree writing outside git repos must exit 0 (reporter repro). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
} finally {
|
||||
cleanup(plansDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2 — non-agent linked worktree: Edit targeting MAIN repo root → exit 0 (gate no-op)
|
||||
test('(2) non-agent linked worktree: Edit targeting main repo root exits 0 (gate no-op, not #260 block)', () => {
|
||||
const payload = {
|
||||
cwd: nonAgentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(nonAgentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Non-agent linked worktree must exit 0 (GSD-activity gate fires before #260 check). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
});
|
||||
|
||||
// Test 3 — GSD-managed worktree (worktree-agent-foo): Edit targeting main repo root → exit 2 (block)
|
||||
test('(3) GSD-managed worktree: Edit targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`GSD-managed worktree targeting main repo root must be blocked (exit 2). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
});
|
||||
|
||||
// Test 4 — GSD-managed worktree: absolute target INSIDE the active worktree → exit 0
|
||||
test('(4) GSD-managed worktree: absolute target inside the active worktree exits 0', () => {
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(agentWorktree, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`GSD-managed worktree targeting its own subtree must pass. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
});
|
||||
|
||||
// Test 5 — GSD-managed worktree: target OUTSIDE all git repos (tmpdir) → exit 0 (fail open)
|
||||
test('(5) GSD-managed worktree: target outside all git repos exits 0 (fail open, not #260 vector)', () => {
|
||||
// Create a temp dir that is NOT a git repository (no .git).
|
||||
// This is the ~/.claude/plans/ scenario — a path that has a real ancestor
|
||||
// directory but is outside every git repo.
|
||||
// IMPORTANT: this dir must NOT be inside any .git directory — it must be a plain tempdir
|
||||
// so the fail-open path (truly outside all repos) is exercised, not the .git-internals block.
|
||||
const externalDir = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-ext-')));
|
||||
try {
|
||||
const targetPath = path.join(externalDir, 'notes.md');
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: targetPath },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`GSD-managed worktree writing to a path outside all git repos must fail open (exit 0). ` +
|
||||
`Only the different-git-root vector (#260) blocks; no-repo targets are not that vector. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
} finally {
|
||||
cleanup(externalDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6 — GSD-managed worktree: Write to .git/config of the MAIN repo → exit 2 (block)
|
||||
test('(6) blocks absolute writes into the main repo .git internals from a GSD worktree (#1342)', () => {
|
||||
// A target like /main-repo/.git/config or /main-repo/.git/hooks/pre-commit causes
|
||||
// `git rev-parse --show-toplevel` to FAIL (a .git dir is not a work tree), so the
|
||||
// "file not in any git repo" branch fires. Previously that branch failed open — but
|
||||
// writing into repository internals via an absolute path is still a #260-class escape
|
||||
// (and dangerous, e.g. injecting a git hook). The fix checks --is-inside-git-dir and
|
||||
// blocks when true.
|
||||
const gitConfigPath = path.join(mainRepo1342, '.git', 'config');
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: gitConfigPath },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`GSD-managed worktree targeting .git/config of another repo must be blocked (exit 2). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
assert.ok(
|
||||
parsed.reason && parsed.reason.includes('.git'),
|
||||
`Block reason should mention .git internals. Got: ${parsed.reason}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static analysis: install.js guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install.js guard for gsd-worktree-path-guard.js', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
});
|
||||
|
||||
test('install.js has hasWorktreePathGuardHook variable', () => {
|
||||
assert.ok(
|
||||
src.includes('hasWorktreePathGuardHook'),
|
||||
'hasWorktreePathGuardHook variable not found in install.js'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js checks fs.existsSync before registering gsd-worktree-path-guard.js', () => {
|
||||
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
|
||||
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
|
||||
|
||||
const blockStart = anchorIdx;
|
||||
const blockEnd = Math.min(src.length, anchorIdx + 1200);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
'install.js must call fs.existsSync on the target path before registering ' +
|
||||
'gsd-worktree-path-guard.js in settings.json. Without this guard, the hook ' +
|
||||
'is registered even when the .js file was never copied (root cause of #1754).'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js emits a skip warning when gsd-worktree-path-guard.js is missing', () => {
|
||||
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
|
||||
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
|
||||
|
||||
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
|
||||
|
||||
assert.ok(
|
||||
block.includes('Skipped') && block.includes('gsd-worktree-path-guard'),
|
||||
'install.js must emit a skip warning mentioning gsd-worktree-path-guard when the file is not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2601
|
||||
*
|
||||
* `config-set-model-profile inherit` (and `config-set model_profile inherit`)
|
||||
* was rejected by the validator even though the runtime accepts 'inherit' as a
|
||||
* valid model_profile value meaning "inherit from parent configuration".
|
||||
*
|
||||
* Root cause: VALID_PROFILES in model-profiles.cjs is derived from
|
||||
* Object.keys(MODEL_PROFILES['gsd-planner']), which does not include 'inherit'.
|
||||
* cmdConfigSetModelProfile() rejects any value not in VALID_PROFILES.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('bug #2601: config-set-model-profile accepts inherit', () => {
|
||||
test('config-set-model-profile inherit succeeds', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const result = runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
||||
assert.ok(result.success, `should accept inherit: ${result.error}`);
|
||||
});
|
||||
|
||||
test('config-set model_profile inherit succeeds', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const result = runGsdTools(['config-set', 'model_profile', 'inherit'], tmpDir);
|
||||
assert.ok(result.success, `config-set model_profile inherit should succeed: ${result.error}`);
|
||||
});
|
||||
|
||||
test('config-set-model-profile inherit writes inherit to config', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
||||
const getResult = runGsdTools(['config-get', 'model_profile'], tmpDir);
|
||||
assert.ok(getResult.success, `config-get should succeed: ${getResult.error}`);
|
||||
assert.strictEqual(JSON.parse(getResult.output), 'inherit');
|
||||
});
|
||||
|
||||
test('config-set-model-profile still rejects truly invalid profiles', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const result = runGsdTools(['config-set-model-profile', 'not-a-real-profile'], tmpDir);
|
||||
assert.ok(!result.success, 'should reject invalid profiles');
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-workflow-guard.js');
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
function makeRepo(branch) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug-261-'));
|
||||
git(dir, ['init', '-q']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'Test User']);
|
||||
git(dir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
||||
git(dir, ['add', 'README.md']);
|
||||
git(dir, ['commit', '-q', '-m', 'chore: init']);
|
||||
git(dir, ['checkout', '-q', '-b', branch]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function setWorkflowGuard(dir, enabled) {
|
||||
const planningDir = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ hooks: { workflow_guard: enabled } }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
function runHookInput(cwd, input) {
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify({ cwd, ...input }),
|
||||
});
|
||||
}
|
||||
|
||||
function runBashHook(cwd, command) {
|
||||
return runHookInput(cwd, {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #261: workflow guard blocks forced git add on worktree-agent branches', () => {
|
||||
test('blocks git add -f on worktree-agent branch when workflow guard is enabled', () => {
|
||||
const dir = makeRepo('worktree-agent-a1');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md');
|
||||
assert.strictEqual(result.status, 2);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.strictEqual(envelope.decision, 'block');
|
||||
assert.strictEqual(envelope.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocks git add --force with git global options on worktree-agent branch', () => {
|
||||
const dir = makeRepo('worktree-agent-b2');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, `git -C "${dir}" add --force .planning/SUMMARY.md`);
|
||||
assert.strictEqual(result.status, 2);
|
||||
assert.strictEqual(JSON.parse(result.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows ordinary git add on worktree-agent branch', () => {
|
||||
const dir = makeRepo('worktree-agent-c3');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows pathspecs named like force flags after git add -- terminator', () => {
|
||||
const dir = makeRepo('worktree-agent-d4');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -- -f');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f outside worktree-agent branches', () => {
|
||||
const dir = makeRepo('feature-docs');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f on worktree-agent branch when workflow guard is disabled', () => {
|
||||
const dir = makeRepo('worktree-agent-e5');
|
||||
try {
|
||||
setWorkflowGuard(dir, false);
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f on worktree-agent branch when no GSD config exists', () => {
|
||||
const dir = makeRepo('worktree-agent-f6');
|
||||
try {
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies the advisory path to MultiEdit when workflow guard is enabled', () => {
|
||||
const dir = makeRepo('feature-multiedit');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runHookInput(dir, {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
file_path: path.join(dir, 'src.js'),
|
||||
edits: [],
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.match(
|
||||
envelope.hookSpecificOutput.additionalContext,
|
||||
/WORKFLOW ADVISORY/
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* GSD Tools Tests — Bug #2630
|
||||
*
|
||||
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
|
||||
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
|
||||
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
|
||||
* workflow rewrote the body but left the frontmatter pointing at the previous
|
||||
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
|
||||
* reported the stale milestone.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const STALE_STATE = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: Foundation
|
||||
status: completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 5 (Foundation) — COMPLETED
|
||||
Plan: 3 of 3
|
||||
Status: v1.0 milestone complete
|
||||
Last activity: 2026-04-20 -- v1.0 shipped
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
- [Phase 1]: Use Node 20
|
||||
`;
|
||||
|
||||
describe('state milestone-switch (#2630)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
STALE_STATE,
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
'{}',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.equal(result.success, true, result.error || result.output);
|
||||
|
||||
const after = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Frontmatter reflects the NEW milestone — the core of bug #2630.
|
||||
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
|
||||
assert.match(
|
||||
after,
|
||||
/^milestone_name:\s*Notifications\s*$/m,
|
||||
'frontmatter milestone_name not switched',
|
||||
);
|
||||
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
|
||||
// Progress counters reset to zero.
|
||||
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
|
||||
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
|
||||
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
|
||||
|
||||
// Body Current Position reset to the new-milestone template.
|
||||
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
|
||||
assert.match(
|
||||
after,
|
||||
/Phase:\s*Not started \(defining requirements\)/,
|
||||
'body Phase not reset',
|
||||
);
|
||||
|
||||
// Accumulated Context is preserved.
|
||||
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
|
||||
});
|
||||
|
||||
test('rejects missing --milestone', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--name', 'Something'],
|
||||
tmpDir,
|
||||
);
|
||||
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
|
||||
const combined = (result.output || '') + (result.error || '');
|
||||
assert.match(combined, /milestone required/i);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2638.
|
||||
*
|
||||
* loadConfig previously migrated/synced sub_repos to the TOP-LEVEL
|
||||
* `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes
|
||||
* `planning.sub_repos` (per #2561 — canonical location). That asymmetry
|
||||
* made loadConfig write a key it then warns is unknown on the next read.
|
||||
*
|
||||
* Fix: writers target `parsed.planning.sub_repos` and strip any stale
|
||||
* top-level copy during the same migration pass.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
|
||||
function makeSubRepo(parent, name) {
|
||||
const dir = path.join(parent, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
function readConfig(tmpDir) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
|
||||
);
|
||||
}
|
||||
|
||||
function writeConfig(tmpDir, obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
describe('bug #2638 — sub_repos canonical location', () => {
|
||||
let tmpDir;
|
||||
let originalCwd;
|
||||
let stderrCapture;
|
||||
let origStderrWrite;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
originalCwd = process.cwd();
|
||||
stderrCapture = '';
|
||||
origStderrWrite = process.stderr.write;
|
||||
process.stderr.write = (chunk) => { stderrCapture += chunk; return true; };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = origStderrWrite;
|
||||
process.chdir(originalCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
makeSubRepo(tmpDir, 'frontend');
|
||||
writeConfig(tmpDir, {
|
||||
planning: { sub_repos: ['backend', 'frontend'] },
|
||||
});
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`should not warn for planning.sub_repos, got: ${stderrCapture}`
|
||||
);
|
||||
assert.ok(
|
||||
!stderrCapture.includes('sub_repos'),
|
||||
`should not mention sub_repos at all, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
makeSubRepo(tmpDir, 'frontend');
|
||||
writeConfig(tmpDir, { multiRepo: true });
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.deepStrictEqual(
|
||||
after.planning?.sub_repos,
|
||||
['backend', 'frontend'],
|
||||
'migration should write to planning.sub_repos'
|
||||
);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'migration must not leave a top-level sub_repos key'
|
||||
);
|
||||
assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed');
|
||||
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`post-migration read should not warn, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('filesystem sync writes detected list to planning.sub_repos only', () => {
|
||||
makeSubRepo(tmpDir, 'api');
|
||||
makeSubRepo(tmpDir, 'web');
|
||||
writeConfig(tmpDir, { planning: { sub_repos: ['api'] } });
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'sync must not create a top-level sub_repos key'
|
||||
);
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`sync should not produce unknown-key warning, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('stale top-level sub_repos is stripped on load', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
writeConfig(tmpDir, {
|
||||
sub_repos: ['backend'],
|
||||
planning: { sub_repos: ['backend'] },
|
||||
});
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'stale top-level sub_repos should be removed to self-heal legacy installs'
|
||||
);
|
||||
assert.deepStrictEqual(after.planning?.sub_repos, ['backend']);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2643 / #2808: skill frontmatter name parity.
|
||||
*
|
||||
* Original (#2643): workflows emitted Skill(skill="gsd:<cmd>") and the
|
||||
* installer registered colon form in SKILL.md name: to match.
|
||||
*
|
||||
* Updated (#2808): workflows now use Skill(skill="gsd-<cmd>") (hyphen),
|
||||
* and the installer emits name: gsd-<cmd> (hyphen). Claude Code autocomplete
|
||||
* now shows the canonical hyphen form instead of the deprecated colon form.
|
||||
* The directory name (gsd-<cmd>) is unchanged.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
skillFrontmatterName,
|
||||
} = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
function collectFiles(dir, results) {
|
||||
if (!results) results = [];
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) collectFiles(full, results);
|
||||
else if (e.name.endsWith('.md')) results.push(full);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every `Skill(skill="<name>")` invocation as a structured record.
|
||||
*
|
||||
* Per project test rigor (`feedback_no_source_grep_tests.md`), this parses
|
||||
* each call as a unit instead of leaning on a single regex over raw bytes.
|
||||
* The flow is:
|
||||
*
|
||||
* 1. Strip HTML comments so commented-out examples don't count as drift.
|
||||
* 2. Walk the content for `Skill(` openers; for each, find the matching
|
||||
* `)` closer (Skill bodies are simple kwarg lists, no nesting).
|
||||
* 3. Parse the call body for the `skill = "..."` keyword argument.
|
||||
* Permissive whitespace around the keyword and `=`, permissive
|
||||
* single/double quoting (with optional `\` escapes from string-
|
||||
* embedded examples), permissive name body — so malformed drift like
|
||||
* `Skill(skill="gsd:extract_learnings")` is surfaced rather than
|
||||
* silently skipped by an over-strict character class.
|
||||
*
|
||||
* Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:)
|
||||
* happens at the call site so the extractor stays neutral.
|
||||
*/
|
||||
function extractSkillCalls(content) {
|
||||
// regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization)
|
||||
let stripped = '';
|
||||
{
|
||||
let rest = content;
|
||||
let idx;
|
||||
while ((idx = rest.indexOf('<!--')) !== -1) {
|
||||
stripped += rest.slice(0, idx);
|
||||
const end = rest.indexOf('-->', idx + 4);
|
||||
if (end === -1) { rest = ''; break; }
|
||||
rest = rest.slice(end + 3);
|
||||
}
|
||||
stripped += rest;
|
||||
}
|
||||
const calls = [];
|
||||
// Body class excludes backslash so the extractor doesn't include an
|
||||
// escape character that precedes the closing quote in embedded examples
|
||||
// (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string
|
||||
// context). A trailing `\` is permitted on the closing-quote side via the
|
||||
// optional `\\?` so both `\"` and `"` close the value cleanly.
|
||||
const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i;
|
||||
let i = 0;
|
||||
while (i < stripped.length) {
|
||||
const open = stripped.indexOf('Skill(', i);
|
||||
if (open === -1) break;
|
||||
const close = stripped.indexOf(')', open);
|
||||
if (close === -1) break;
|
||||
const body = stripped.slice(open + 'Skill('.length, close);
|
||||
const match = body.match(argRe);
|
||||
if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) });
|
||||
i = close + 1;
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function extractSkillNamesHyphen(content) {
|
||||
return new Set(
|
||||
extractSkillCalls(content)
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n.startsWith('gsd-')),
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkillNamesColon(content) {
|
||||
return new Set(
|
||||
extractSkillCalls(content)
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n.startsWith('gsd:')),
|
||||
);
|
||||
}
|
||||
|
||||
describe('skill frontmatter name parity (#2643 / #2808)', () => {
|
||||
test('skillFrontmatterName helper emits hyphen form (#2808)', () => {
|
||||
assert.strictEqual(typeof skillFrontmatterName, 'function');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next');
|
||||
});
|
||||
|
||||
test('convertClaudeCommandToClaudeSkill emits name: gsd-<cmd> (hyphen)', () => {
|
||||
const input = '---\nname: old\ndescription: test\n---\n\nBody.';
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase');
|
||||
// Parse the frontmatter block structurally: extract the name: field value.
|
||||
const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---');
|
||||
const frontmatterLines = frontmatterMatch[1].split(/\r?\n/);
|
||||
const nameEntry = frontmatterLines.find((l) => l.startsWith('name:'));
|
||||
assert.ok(nameEntry, 'frontmatter must contain a name: field');
|
||||
const nameValue = nameEntry.replace(/^name:\s*/, '').trim();
|
||||
assert.strictEqual(
|
||||
nameValue,
|
||||
'gsd-execute-phase',
|
||||
`frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'`
|
||||
);
|
||||
});
|
||||
|
||||
test('no workflow uses deprecated Skill(skill="gsd:<cmd>") colon form', () => {
|
||||
const workflowFiles = collectFiles(WORKFLOWS_DIR);
|
||||
const colonRefs = [];
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
for (const n of extractSkillNamesColon(src)) {
|
||||
colonRefs.push(path.basename(f) + ': ' + n);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
colonRefs,
|
||||
[],
|
||||
'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
test('every workflow Skill(skill="gsd-<cmd>") resolves to an emitted skill name', () => {
|
||||
const workflowFiles = collectFiles(WORKFLOWS_DIR);
|
||||
const referenced = new Set();
|
||||
const templatedSkipped = [];
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
for (const n of extractSkillNamesHyphen(src)) {
|
||||
// Skip template expressions (e.g. `gsd-${ref.skill}`): these are
|
||||
// capability-dispatched — the skill stem is resolved at runtime from
|
||||
// the `loop render-hooks` registry output (ADR-857 phase 6), so there
|
||||
// is no single literal skill file to validate against here.
|
||||
// The capability registry's own validateStep gate (gen-capability-registry.cjs)
|
||||
// is responsible for ensuring each `steps[].ref.skill` corresponds to a
|
||||
// real skill declared in the capability's `skills` array.
|
||||
if (n.includes('${')) {
|
||||
templatedSkipped.push(path.basename(f) + ': ' + n);
|
||||
} else {
|
||||
referenced.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
referenced.size > 0,
|
||||
`expected at least one literal Skill(skill="gsd-<cmd>") reference in workflows under ${WORKFLOWS_DIR}`
|
||||
);
|
||||
|
||||
const emitted = new Set();
|
||||
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const cmd of cmdFiles) {
|
||||
const base = cmd.replace(/\.md$/, '');
|
||||
const skillDirName = 'gsd-' + base;
|
||||
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
|
||||
const out = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
const m = out.match(/^---\r?\nname:\s*(.+)$/m);
|
||||
if (m) emitted.add(m[1].trim());
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
for (const r of referenced) if (!emitted.has(r)) missing.push(r);
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
'workflow refs not emitted as skill names: ' + missing.join(', '),
|
||||
);
|
||||
// Informational: report how many templated dispatches were intentionally skipped.
|
||||
// (Templated names are validated by the capability registry, not statically here.)
|
||||
if (templatedSkipped.length > 0) {
|
||||
// Not a failure — just a note for test output transparency.
|
||||
// Use a diagnostic comment: node:test does not have a skip-within-test API.
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2659.
|
||||
*
|
||||
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
||||
* `output(...)` on both the --json and text branches. `output` is never in
|
||||
* local scope — the entire core module is imported as `const core`, so every
|
||||
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
||||
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
||||
*
|
||||
* This test runs both invocations against a minimal temp project and asserts
|
||||
* they exit successfully with non-empty stdout. It fails with the
|
||||
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-bug-2659-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('audit-open (text output) succeeds and produces stdout', () => {
|
||||
const result = runGsdTools('audit-open', tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open must write a non-empty report to stdout'
|
||||
);
|
||||
});
|
||||
|
||||
test('audit-open --json succeeds and produces stdout', () => {
|
||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open --json must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open --json must write output to stdout'
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(
|
||||
() => { parsed = JSON.parse(result.output); },
|
||||
'audit-open --json must emit valid JSON'
|
||||
);
|
||||
assert.ok(
|
||||
parsed !== null && typeof parsed === 'object',
|
||||
'audit-open --json must emit a JSON object or array'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Bug #2660: `gsd-tools milestone complete <version>` writes MILESTONES.md
|
||||
* bullets that read "- One-liner:" (the literal label) instead of the prose
|
||||
* after the label.
|
||||
*
|
||||
* Root cause: extractOneLinerFromBody() matches the first **...** span. In
|
||||
* `**One-liner:** prose`, the first span contains only `One-liner:` so the
|
||||
* function returns the label instead of the prose after it.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
const { extractOneLinerFromBody } = require(
|
||||
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core-utils.cjs')
|
||||
);
|
||||
|
||||
describe('bug #2660: extractOneLinerFromBody', () => {
|
||||
test('a) body-style **One-liner:** label returns prose after the label', () => {
|
||||
const content =
|
||||
'# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.');
|
||||
});
|
||||
|
||||
test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => {
|
||||
const content =
|
||||
'---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), null);
|
||||
});
|
||||
|
||||
test('c) no one-liner at all returns null', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), null);
|
||||
});
|
||||
|
||||
test('d) bold spans inside the prose are preserved', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'This is **important** stuff.'
|
||||
);
|
||||
});
|
||||
|
||||
test('e) empty prose after label returns null (no bogus bullet)', () => {
|
||||
const empty =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n';
|
||||
const whitespace =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(empty), null);
|
||||
assert.strictEqual(extractOneLinerFromBody(whitespace), null);
|
||||
});
|
||||
|
||||
test('f) legacy bare **prose** format still works (no label, no colon)', () => {
|
||||
// Preserve pre-existing behavior: SUMMARY files historically used
|
||||
// `**bold prose**` with no label. See tests/commands.test.cjs:366 and
|
||||
// tests/milestone.test.cjs:451 — both assert this form.
|
||||
const content =
|
||||
'---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'JWT auth with refresh rotation using jose library'
|
||||
);
|
||||
});
|
||||
|
||||
test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.');
|
||||
});
|
||||
|
||||
test('h) CRLF line endings (Windows) are handled', () => {
|
||||
const content =
|
||||
'---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'Windows-authored prose.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2661:
|
||||
* `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false
|
||||
* left ROADMAP plan checkboxes unchecked until a manual
|
||||
* `roadmap update-plan-progress` was run.
|
||||
*
|
||||
* Root cause (workflow-level): execute-plan.md `update_roadmap` step was
|
||||
* gated on a worktree-detection branch that incorrectly conflated
|
||||
* "parallel mode" with "worktree mode". When `parallelization: true,
|
||||
* use_worktrees: false` was configured, the step was still gated by the
|
||||
* worktree-only check (which is true: the executing tree IS the main repo,
|
||||
* not a worktree, so the gate happened to fire correctly there) — the
|
||||
* actual reproducer was a different code path. The original PR #2682 fix
|
||||
* made the sync unconditional, which violated the single-writer contract
|
||||
* for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode.
|
||||
*
|
||||
* Minimal fix (this PR): restore the worktree guard and document its
|
||||
* intent explicitly. The `IS_WORKTREE != "true"` branch IS the
|
||||
* `use_worktrees: false` mode: only that mode runs the in-handler sync.
|
||||
* Worktree mode relies on the orchestrator's post-merge sync at
|
||||
* execute-phase.md §5.7 (lines 815-834) — the single writer for shared
|
||||
* tracking files.
|
||||
*
|
||||
* These tests:
|
||||
* (1) assert the workflow gates the sync call on `use_worktrees: false`
|
||||
* (i.e. the IS_WORKTREE != "true" branch is present and gates the call);
|
||||
* (2) assert the handler itself behaves correctly under the
|
||||
* use_worktrees: false reproducer (the original #2661 case);
|
||||
* (3) assert the handler is idempotent and lock-safe (lockfile is the
|
||||
* in-handler defense; the workflow gate is the cross-handler one).
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
|
||||
|
||||
function writeRoadmap(tmpDir, content) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function readRoadmap(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
}
|
||||
|
||||
function seedPhase(tmpDir, phaseNum, planIds, summaryIds) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
for (const id of planIds) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`);
|
||||
}
|
||||
for (const id of summaryIds) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const THREE_PLAN_ROADMAP = `# Roadmap
|
||||
|
||||
- [ ] Phase 1: Test phase with three parallel plans
|
||||
- [ ] 01-01-PLAN.md
|
||||
- [ ] 01-02-PLAN.md
|
||||
- [ ] 01-03-PLAN.md
|
||||
|
||||
### Phase 1: Test
|
||||
**Goal:** Parallel execution regression test
|
||||
**Plans:** 3 plans
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 1. Test | v1.0 | 0/3 | Planned | |
|
||||
`;
|
||||
|
||||
// ─── Structural: workflow gates sync on use_worktrees=false ──────────────────
|
||||
|
||||
describe('bug #2661: execute-plan.md update_roadmap gating', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const stepMatch = content.match(
|
||||
/<step name="update_roadmap">([\s\S]*?)<\/step>/
|
||||
);
|
||||
const step = stepMatch && stepMatch[1];
|
||||
|
||||
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
|
||||
assert.ok(stepMatch, 'update_roadmap step must exist');
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(
|
||||
/gsd_run query roadmap\.update-plan-progress/.test(step),
|
||||
'update_roadmap must still invoke roadmap.update-plan-progress'
|
||||
);
|
||||
});
|
||||
|
||||
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
|
||||
// The non-worktree branch must contain the sync call.
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(
|
||||
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step),
|
||||
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
|
||||
);
|
||||
});
|
||||
|
||||
test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => {
|
||||
// The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block,
|
||||
// i.e. it must NOT be unconditional and it must NOT appear on the worktree branch.
|
||||
// We verify by extracting the bash block and checking the call sits under the gate.
|
||||
const bashMatch = step.match(/```bash\s*([\s\S]*?)```/);
|
||||
assert.ok(bashMatch, 'update_roadmap must contain a bash block');
|
||||
const bash = bashMatch[1];
|
||||
|
||||
assert.ok(
|
||||
/IS_WORKTREE/.test(bash),
|
||||
'bash block must include the IS_WORKTREE worktree-detection check'
|
||||
);
|
||||
// Sync call must appear after the guard check, not before.
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
|
||||
const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/);
|
||||
assert.ok(guardIdx >= 0, 'guard must be present');
|
||||
assert.ok(callIdx > guardIdx,
|
||||
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
|
||||
});
|
||||
|
||||
test('intent doc references single-writer contract / orchestrator-owns-write', () => {
|
||||
// The prose must justify why worktree mode is excluded so future readers
|
||||
// do not regress this back to unconditional.
|
||||
assert.ok(
|
||||
/worktree|orchestrator|single-writer|#1486|#2661/i.test(step),
|
||||
'update_roadmap must document the contract that justifies the gate'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─
|
||||
|
||||
describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, `handler failed: ${result.error}`);
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked');
|
||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
||||
assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked');
|
||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3');
|
||||
});
|
||||
|
||||
test('handler is idempotent — second call produces identical content', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const first = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(first.success, first.error);
|
||||
const afterFirst = readRoadmap(tmpDir);
|
||||
|
||||
const second = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(second.success, second.error);
|
||||
const afterSecond = readRoadmap(tmpDir);
|
||||
|
||||
assert.strictEqual(afterSecond, afterFirst,
|
||||
'repeated invocation must not mutate ROADMAP.md further (idempotent)');
|
||||
});
|
||||
|
||||
test('partial completion: only plans with SUMMARY.md get [x]', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
// Only plan 01-02 has a SUMMARY.md
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']);
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, result.error);
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked');
|
||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
||||
assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked');
|
||||
assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3');
|
||||
});
|
||||
|
||||
test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => {
|
||||
// Scope: lockfile only serializes within a single working tree. Cross-worktree
|
||||
// serialization is enforced by the workflow gate (worktree mode never calls
|
||||
// this handler from execute-plan.md), not by the lockfile.
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const invocations = Array.from({ length: 3 }, () =>
|
||||
new Promise((resolve) => {
|
||||
const r = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
resolve(r);
|
||||
})
|
||||
);
|
||||
const results = await Promise.all(invocations);
|
||||
|
||||
for (const r of results) {
|
||||
assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`);
|
||||
}
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
// Structural integrity: each checkbox appears exactly once, progress row intact.
|
||||
for (const id of ['01-01', '01-02', '01-03']) {
|
||||
const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1;
|
||||
assert.strictEqual(occurrences, 1,
|
||||
`plan ${id} checkbox should appear exactly once (got ${occurrences})`);
|
||||
}
|
||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs');
|
||||
// Lockfile should have been cleaned up after the final release.
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')),
|
||||
'ROADMAP.md.lock should be released after concurrent invocations settle'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2686
|
||||
*
|
||||
* The gsd-code-fixer agent (spawned by /gsd-code-review-fix) operated directly
|
||||
* against the main working tree. When it ran concurrently with a foreground
|
||||
* session both processes raced for HEAD, the index, and on-disk files. The
|
||||
* foreground session's next commit could land on the wrong branch (whichever
|
||||
* branch the agent last checked out).
|
||||
*
|
||||
* Fix: the agent's working instructions must include `git worktree add` as the
|
||||
* FIRST git operation, run ALL subsequent git operations inside that worktree
|
||||
* path, and call `git worktree remove` for cleanup when done.
|
||||
*
|
||||
* This mirrors the pattern already used by every other per-issue GSD agent at
|
||||
* /private/tmp/sv-<n>.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The gsd-code-fixer agent's working instructions ARE the product — Claude
|
||||
// executes them literally at runtime. Testing the text content tests the
|
||||
// deployed contract: if the instruction is absent, the isolation guarantee
|
||||
// is absent.
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('bug-2686: review-fix agent worktree isolation', () => {
|
||||
let agentContent;
|
||||
|
||||
before(() => {
|
||||
const agentPath = path.join(__dirname, '..', 'agents', 'gsd-code-fixer.md');
|
||||
assert.ok(fs.existsSync(agentPath), 'agents/gsd-code-fixer.md must exist');
|
||||
agentContent = fs.readFileSync(agentPath, 'utf-8');
|
||||
});
|
||||
|
||||
test('agent instructions include git worktree add before any branch-switching checkout or commit', () => {
|
||||
const worktreePos = agentContent.indexOf('git worktree add');
|
||||
|
||||
assert.ok(
|
||||
worktreePos !== -1,
|
||||
'gsd-code-fixer.md must include a "git worktree add" instruction to isolate operations from the main working tree (#2686)'
|
||||
);
|
||||
|
||||
// `git checkout -- {file}` is a file-restore within the worktree — safe, not a branch switch.
|
||||
// The dangerous operation is `git checkout <branch>` (no leading --).
|
||||
// Find the first branch-switching checkout (pattern: "git checkout " NOT followed by "--").
|
||||
const branchCheckoutMatch = /git checkout (?!--)/.exec(agentContent);
|
||||
if (branchCheckoutMatch) {
|
||||
const branchCheckoutPos = branchCheckoutMatch.index;
|
||||
assert.ok(
|
||||
worktreePos < branchCheckoutPos,
|
||||
'git worktree add must appear before any branch-switching git checkout in the agent instructions'
|
||||
);
|
||||
}
|
||||
|
||||
// commit command must come after worktree setup — the fixer may use
|
||||
// either `git commit` directly or `gsd-sdk query commit`
|
||||
const commitMatch = /(?:git commit|gsd-sdk query commit)/.exec(agentContent);
|
||||
if (commitMatch) {
|
||||
const commitPos = commitMatch.index;
|
||||
assert.ok(
|
||||
worktreePos < commitPos,
|
||||
'git worktree add must appear before any commit command in the agent instructions'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('agent instructions include worktree cleanup after completion', () => {
|
||||
assert.ok(
|
||||
agentContent.includes('git worktree remove') || agentContent.includes('worktree remove'),
|
||||
'gsd-code-fixer.md must include worktree cleanup (git worktree remove) to avoid leaking tmp directories (#2686)'
|
||||
);
|
||||
});
|
||||
|
||||
test('agent instructions use a /tmp path for the worktree', () => {
|
||||
// Require either a literal /tmp/sv- path or a variable assignment to /tmp/sv-
|
||||
// (e.g. `wt=$(mktemp -d "/tmp/sv-..."`). Bare `$wt` or `wt=` references
|
||||
// without a /tmp/sv- assignment are not sufficient.
|
||||
const hasTmpWorktreePath =
|
||||
/\/tmp\/sv-/.test(agentContent) ||
|
||||
/\bwt\s*=\s*["']?\/tmp\/sv-/.test(agentContent);
|
||||
assert.ok(
|
||||
hasTmpWorktreePath,
|
||||
'gsd-code-fixer.md must define a worktree variable at a /tmp/sv-... path, consistent with other GSD agents (#2686)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2698: CRLF line endings break agent-block strip regexes
|
||||
*
|
||||
* The legacy `gsd-update-check` hook migration in bin/install.js uses two
|
||||
* separate .replace() calls:
|
||||
* 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/
|
||||
* 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/
|
||||
*
|
||||
* These patterns fail when config.toml has mixed line endings — e.g. the
|
||||
* "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This
|
||||
* can happen when the file is created cross-platform (Windows/Linux), when
|
||||
* editors convert only part of the file, or when a previous GSD version wrote
|
||||
* the block with different EOL than the file's dominant EOL.
|
||||
*
|
||||
* Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and
|
||||
* any mix in a single pass, making the migration robust regardless of the
|
||||
* platform the file was last written on.
|
||||
*
|
||||
* Test approach: write a `.codex/config.toml` with a stale gsd-update-check
|
||||
* block that uses mixed line endings (header in LF, body in CRLF), then run
|
||||
* install() and assert the stale block is gone.
|
||||
*
|
||||
* Note: The local Codex install writes to `.codex/` in the current directory.
|
||||
* Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Ensure hooks/dist/ is populated before install tests
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block
|
||||
// using the given line ending for the stale hooks block header, and a potentially
|
||||
// different EOL for the hooks body. This exercises the cross-platform mixed scenario.
|
||||
function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) {
|
||||
// Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL
|
||||
// for the content lines (simulates a file edited by two different platforms).
|
||||
const staleBlock = [
|
||||
'# GSD Hooks', // line that starts the stale section
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "node /old/path/gsd-update-check.js"',
|
||||
].join(bodyEol);
|
||||
|
||||
// Put the stale block in user content BEFORE the GSD marker. The GSD marker area
|
||||
// will be regenerated by mergeCodexConfig during install(); the stale block in
|
||||
// the user area is what the hooks migration must remove.
|
||||
const content = [
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol;
|
||||
|
||||
const codexDir = path.join(dir, '.codex');
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function readHooksSessionStartCommands(codexHome) {
|
||||
const hooksPath = path.join(codexHome, 'hooks.json');
|
||||
if (!fs.existsSync(hooksPath)) return [];
|
||||
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
||||
? parsed.hooks
|
||||
: parsed;
|
||||
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
||||
return sessionStart.flatMap((entry) => [
|
||||
...(typeof entry?.command === 'string' ? [entry.command] : []),
|
||||
...(Array.isArray(entry?.hooks)
|
||||
? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string')
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
'Stale gsd-update-check entry must be removed from LF config.toml (#2698)'
|
||||
);
|
||||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||||
assert.equal(
|
||||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||||
true,
|
||||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||||
);
|
||||
});
|
||||
|
||||
test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)'
|
||||
);
|
||||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||||
assert.equal(
|
||||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||||
true,
|
||||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => {
|
||||
// This is the primary failure case: header line uses LF but the body uses CRLF.
|
||||
// The old LF-only regex requires all-\n separators; the old CRLF-only regex requires
|
||||
// all-\r\n separators. Neither matches a block with mixed endings, so the stale
|
||||
// block survives reinstall with the old code (#2698).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform)
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
[
|
||||
'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).',
|
||||
'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.',
|
||||
'Fix consolidates to a single \\r?\\n-aware regex.',
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Regression tests for issue #2769
|
||||
*
|
||||
* The Requirements header in ROADMAP.md phase blocks renders identically in
|
||||
* markdown for three textually distinct forms:
|
||||
*
|
||||
* **Requirements:** colon INSIDE bold delimiters
|
||||
* **Requirements**: colon OUTSIDE bold delimiters
|
||||
* **Requirements** : space-then-colon outside bold
|
||||
*
|
||||
* Two parsers in the codebase used opposing strict regexes — one only
|
||||
* matched the outside-colon form (init.cjs / init.ts), the other only the
|
||||
* inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md
|
||||
* traceability sweep). Both must accept all three variants so phase
|
||||
* metadata propagation is robust to authoring style.
|
||||
*
|
||||
* Tests for the init query side live in `tests/init.test.cjs` (parameterized
|
||||
* over the three variants). This file exercises the inverse bug in
|
||||
* `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP
|
||||
* uses the outside-colon form, which previously was silently skipped.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
const headerVariants = [
|
||||
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' },
|
||||
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' },
|
||||
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' },
|
||||
];
|
||||
|
||||
for (const variant of headerVariants) {
|
||||
test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phasesDir, '01-1-PLAN.md'),
|
||||
['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phasesDir, '01-1-SUMMARY.md'),
|
||||
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phasesDir, '01-VERIFICATION.md'),
|
||||
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
|
||||
);
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'',
|
||||
'**Goal:** Build core',
|
||||
variant.header,
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'Plans:',
|
||||
'- [x] 01-1-PLAN.md',
|
||||
'',
|
||||
'| Phase | Plans | Status | Completed |',
|
||||
'|-------|-------|--------|-----------|',
|
||||
'| 1. Foundation | 0/1 | Pending | - |',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
|
||||
const requirements = [
|
||||
'# Requirements',
|
||||
'',
|
||||
'## Functional Requirements',
|
||||
'',
|
||||
'- [ ] **REQ-001**: Core data model',
|
||||
'',
|
||||
'## Traceability',
|
||||
'',
|
||||
'| REQ-ID | Phase | Status |',
|
||||
'|--------|-------|--------|',
|
||||
'| REQ-001 | 1 | Pending |',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements);
|
||||
|
||||
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
const updated = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.match(
|
||||
updated,
|
||||
/-\s*\[x\]\s*\*\*REQ-001\*\*/,
|
||||
`REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`,
|
||||
);
|
||||
assert.match(
|
||||
updated,
|
||||
/\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/,
|
||||
`Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression — issue #2770
|
||||
*
|
||||
* `roadmap.annotate-dependencies` crashes with
|
||||
* `TypeError: t.trim is not a function` when must_haves.truths contains a
|
||||
* non-string scalar (e.g., a YAML int like `- 3` interpreted by an upstream
|
||||
* parser as a number, or a kv-shaped item whose value is numeric).
|
||||
*
|
||||
* The original guard `if (typeof t !== 'string') continue` skipped silently —
|
||||
* which avoids the crash but **drops the constraint from cross-cutting
|
||||
* analysis**. The required behaviour is to **coerce, not skip**: a numeric
|
||||
* scalar `3` must be surfaced as the string "3", and a kv-shaped truth like
|
||||
* `{ title: "X", count: 3 }` must contribute its title to the analysis.
|
||||
*
|
||||
* The two literal cases called out in the issue title (bare-int `depends_on`
|
||||
* values) are also exercised here as regression guards on the frontmatter
|
||||
* parser to prove the dependency is preserved as a string and never dropped.
|
||||
*/
|
||||
|
||||
const { test, describe, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
|
||||
function makePlanProject(files = {}) {
|
||||
const dir = createTempProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content, 'utf-8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const ROADMAP = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Set up project',
|
||||
'**Plans:** 2 plans',
|
||||
'',
|
||||
'Plans:',
|
||||
'- [ ] 01-01-PLAN.md — Set up DB',
|
||||
'- [ ] 01-02-PLAN.md — Build API',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// PLAN where must_haves.truths includes a bare numeric scalar AND a kv-shaped
|
||||
// item whose value is numeric — both must be surfaced as cross-cutting
|
||||
// constraints when shared across plans, not silently dropped.
|
||||
const PLAN_NUMERIC_TRUTH = (wave, sharedTitle) => [
|
||||
'---',
|
||||
'phase: "1"',
|
||||
`plan: "01-0${wave}"`,
|
||||
'type: standard',
|
||||
`wave: ${wave}`,
|
||||
'depends_on: []',
|
||||
'files_modified: []',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' truths:',
|
||||
` - title: ${sharedTitle}`,
|
||||
' count: 3',
|
||||
' - 42',
|
||||
' artifacts: []',
|
||||
' key_links: []',
|
||||
'---',
|
||||
'',
|
||||
`<objective>Plan ${wave}</objective>`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('bug #2770 — non-string truths must be coerced, not dropped', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('numeric scalar truth shared across 2+ plans is surfaced as cross-cutting constraint', () => {
|
||||
// Both plans share the numeric truth `42`. Pre-fix: silently dropped by
|
||||
// `typeof t !== 'string' continue`, so cross_cutting_constraints == 0.
|
||||
// Post-fix: coerced to "42" and surfaced as a constraint.
|
||||
const PLAN_BARE_INT_TRUTH = (wave) => [
|
||||
'---',
|
||||
'phase: "1"',
|
||||
`plan: "01-0${wave}"`,
|
||||
'type: standard',
|
||||
`wave: ${wave}`,
|
||||
'depends_on: []',
|
||||
'files_modified: []',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' truths:',
|
||||
' - 42',
|
||||
' artifacts: []',
|
||||
' key_links: []',
|
||||
'---',
|
||||
'',
|
||||
`<objective>Plan ${wave}</objective>`,
|
||||
'',
|
||||
].join('\n');
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': ROADMAP,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_BARE_INT_TRUTH(1),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_BARE_INT_TRUTH(2),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
out.cross_cutting_constraints,
|
||||
1,
|
||||
'numeric truth shared across plans must be surfaced (coerced), not dropped'
|
||||
);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('Cross-cutting constraints:'),
|
||||
'cross-cutting subsection present');
|
||||
assert.ok(/-\s*42\b/.test(roadmap),
|
||||
'numeric truth "42" surfaced as a string in the roadmap');
|
||||
});
|
||||
|
||||
test('kv-shaped truth with numeric value uses title and contributes to cross-cutting analysis', () => {
|
||||
// Both plans share `{ title: 'shared-rule', count: 3 }`. Pre-fix:
|
||||
// typeof === 'object' so silently skipped → constraint dropped.
|
||||
// Post-fix: title extracted, surfaced in cross-cutting subsection.
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': ROADMAP,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_NUMERIC_TRUTH(1, 'shared-rule'),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_NUMERIC_TRUTH(2, 'shared-rule'),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
// Both plans share two truths: the kv-shaped { title: 'shared-rule', ... }
|
||||
// and the bare numeric 42. Pre-fix neither would survive the typeof guard;
|
||||
// post-fix both are coerced and surfaced.
|
||||
assert.strictEqual(
|
||||
out.cross_cutting_constraints,
|
||||
2,
|
||||
'kv-shaped truth and numeric truth both surface, not dropped'
|
||||
);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('shared-rule'),
|
||||
'title from kv-shaped truth surfaced in cross-cutting list');
|
||||
assert.ok(/-\s*42\b/.test(roadmap),
|
||||
'numeric truth surfaced as a string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2770 — bare-int depends_on values parse as preserved strings', () => {
|
||||
test('scalar bare-int depends_on parses as string "3" (not dropped, not numeric)', () => {
|
||||
// Per issue title: a YAML scalar `depends_on: 3` must be preserved as the
|
||||
// string "3". The frontmatter parser already returns strings here; this
|
||||
// test pins the behaviour so a future "convert YAML scalars to numbers"
|
||||
// optimization cannot silently regress dependency tracking.
|
||||
const fm = extractFrontmatter([
|
||||
'---',
|
||||
'phase: "1"',
|
||||
'plan: "01"',
|
||||
'depends_on: 3',
|
||||
'---',
|
||||
'body',
|
||||
'',
|
||||
].join('\n'));
|
||||
assert.strictEqual(typeof fm.depends_on, 'string',
|
||||
'scalar depends_on must remain a string after parse');
|
||||
assert.strictEqual(fm.depends_on, '3',
|
||||
'bare int 3 must be preserved as the string "3"');
|
||||
});
|
||||
|
||||
test('inline-array bare-int depends_on parses to ["3","4"] (preserved as strings)', () => {
|
||||
const fm = extractFrontmatter([
|
||||
'---',
|
||||
'phase: "1"',
|
||||
'plan: "01"',
|
||||
'depends_on: [3, 4]',
|
||||
'---',
|
||||
'body',
|
||||
'',
|
||||
].join('\n'));
|
||||
assert.ok(Array.isArray(fm.depends_on),
|
||||
'inline array depends_on must be an array');
|
||||
assert.deepStrictEqual(fm.depends_on, ['3', '4'],
|
||||
'bare ints in inline array must be preserved as strings — never dropped');
|
||||
// Critical: assert *length* matches input. A naive `if (typeof !== string) continue`
|
||||
// would silently drop entries; we must coerce, not skip.
|
||||
assert.strictEqual(fm.depends_on.length, 2,
|
||||
'no dependency may be silently dropped during coercion');
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest
|
||||
*
|
||||
* USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user.
|
||||
* preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest()
|
||||
* also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was
|
||||
* on disk at install time. On the next install, saveLocalPatches() compares the on-disk
|
||||
* (refreshed) hash to the manifest hash, finds them different, and emits the spurious
|
||||
* "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning.
|
||||
*
|
||||
* Invariant: a file is either distribution (manifest-tracked, diff'd against manifest)
|
||||
* or user artifact (preserved across installs, never diff'd). It cannot be both. The
|
||||
* shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both
|
||||
* preserveUserArtifacts callers and writeManifest.
|
||||
*
|
||||
* Closes: #2771
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
});
|
||||
|
||||
function runInstaller(configDir) {
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'],
|
||||
{ encoding: 'utf-8', stdio: 'pipe', env }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ────────────────────
|
||||
|
||||
describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate /gsd-profile-user creating USER-PROFILE.md
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n');
|
||||
|
||||
// Re-install: writeManifest runs again with USER-PROFILE.md present on disk
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(fs.existsSync(manifestPath), 'manifest must be written');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'),
|
||||
'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ────────────
|
||||
|
||||
describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
const content = '# Profile\n\nUser content from /gsd-profile-user.\n';
|
||||
fs.writeFileSync(profilePath, content);
|
||||
|
||||
runInstaller(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall');
|
||||
assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ──────
|
||||
|
||||
describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => {
|
||||
// Initial install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// /gsd-profile-user creates USER-PROFILE.md (v1)
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile v1\n');
|
||||
|
||||
// Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1)
|
||||
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
|
||||
|
||||
// Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest
|
||||
// hash → patch detected. Under fix, file is not in manifest → no patch.
|
||||
const output = runInstaller(tmpDir);
|
||||
|
||||
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
|
||||
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
assert.ok(
|
||||
!fs.existsSync(patchFile),
|
||||
'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file'
|
||||
);
|
||||
|
||||
const offendingLine = output
|
||||
.split('\n')
|
||||
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
|
||||
assert.strictEqual(
|
||||
offendingLine,
|
||||
undefined,
|
||||
'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ─────────
|
||||
|
||||
describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => {
|
||||
// Initial install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile v1\n');
|
||||
|
||||
// Reinstall to populate manifest under the (now-fixed) writer
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Inject a stale manifest entry simulating a pre-#2771 install: a hash for
|
||||
// USER-PROFILE.md that does NOT match current content.
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
manifest.files = manifest.files || {};
|
||||
manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
// /gsd-profile-user --refresh rewrites USER-PROFILE.md
|
||||
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
|
||||
|
||||
// Reinstall — saveLocalPatches must strip the legacy entry before scanning
|
||||
const output = runInstaller(tmpDir);
|
||||
|
||||
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
|
||||
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
assert.ok(
|
||||
!fs.existsSync(patchFile),
|
||||
'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch'
|
||||
);
|
||||
|
||||
const offendingLine = output
|
||||
.split('\n')
|
||||
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
|
||||
assert.strictEqual(
|
||||
offendingLine,
|
||||
undefined,
|
||||
'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: shared constant exists and is used by both call sites ────────────
|
||||
|
||||
describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => {
|
||||
test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set,
|
||||
'install.js must export USER_OWNED_ARTIFACTS as a single source of truth'
|
||||
);
|
||||
const list = Array.isArray(mod.USER_OWNED_ARTIFACTS)
|
||||
? mod.USER_OWNED_ARTIFACTS
|
||||
: Array.from(mod.USER_OWNED_ARTIFACTS);
|
||||
assert.ok(
|
||||
list.includes('USER-PROFILE.md'),
|
||||
'USER_OWNED_ARTIFACTS must include USER-PROFILE.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest path safety', () => {
|
||||
let tmpDir;
|
||||
let outside;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-manifest-path-safety-');
|
||||
outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup(outside);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('saveLocalPatches ignores manifest entries that escape the install root', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outside, 'outside user data\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: 'legacy',
|
||||
timestamp: '2026-05-11T00:00:00.000Z',
|
||||
files: {
|
||||
'../outside-managed-file.txt': 'deadbeef',
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const modified = mod.saveLocalPatches(tmpDir);
|
||||
|
||||
assert.deepEqual(modified, []);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n');
|
||||
assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false);
|
||||
});
|
||||
|
||||
test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
const hookPath = path.join(tmpDir, 'hooks', 'managed.js');
|
||||
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
||||
fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: 'legacy',
|
||||
timestamp: '2026-05-11T00:00:00.000Z',
|
||||
files: {
|
||||
'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'),
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const modified = mod.saveLocalPatches(tmpDir);
|
||||
|
||||
assert.deepEqual(modified, []);
|
||||
assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false);
|
||||
});
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2772: worktree isolation is unconditionally disabled
|
||||
* when `.gitmodules` exists in the repo, even when the plan does not touch
|
||||
* any submodule path.
|
||||
*
|
||||
* Behavioral test: the bash decision pipeline from
|
||||
* gsd-core/workflows/execute-phase.md is extracted verbatim into an
|
||||
* executable snippet here, then run via execFileSync('bash', ...) against
|
||||
* real fixture projects built with `createTempGitProject()`. We assert
|
||||
* the resulting USE_WORKTREES_FOR_PLAN value (printed on the final line
|
||||
* of stdout) and the presence/absence of the [worktree] log line for each
|
||||
* scenario.
|
||||
*
|
||||
* If execute-phase.md's bash gate is ever rewritten so the extracted
|
||||
* snippet stops matching real behavior, this test must be updated to
|
||||
* track the new pipeline — never replaced with a source grep.
|
||||
*
|
||||
* In addition to the per-plan gate behavior, this file also asserts:
|
||||
* - The workflow markdown actually wires USE_WORKTREES_FOR_PLAN into
|
||||
* each of the four dispatch sites (worktree-mode gate, sequential-mode
|
||||
* gate, "worktrees disabled" prose, post-wave cleanup gate). Without
|
||||
* this, the per-plan computation would be dead code (the original
|
||||
* #2772 fix shipped in this state — CodeRabbit caught it).
|
||||
* - The quick.md executor prompt injects SUBMODULE_PATHS and a fail-loud
|
||||
* pre-commit guard, and the guard actually aborts when staged paths
|
||||
* fall inside a submodule.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Bash snippet extracted from execute-phase.md (the SUBMODULE_PATHS parse +
|
||||
// per-plan intersection logic with normalization + bidirectional matching).
|
||||
// Inputs come from env vars: PLAN_FILES (whitespace-separated) and plan_id.
|
||||
// Output: log lines on stdout, then a final line
|
||||
// `USE_WORKTREES_FOR_PLAN=<true|false>` for the test to parse.
|
||||
const GATE_SNIPPET = [
|
||||
'set -e',
|
||||
'USE_WORKTREES="${USE_WORKTREES:-true}"',
|
||||
'if [ -f .gitmodules ]; then',
|
||||
" SUBMODULE_PATHS=$(git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' 2>/dev/null | awk '{print $2}')",
|
||||
'else',
|
||||
' SUBMODULE_PATHS=""',
|
||||
'fi',
|
||||
'USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"',
|
||||
'if [ -n "$SUBMODULE_PATHS" ] && [ "$USE_WORKTREES_FOR_PLAN" != "false" ]; then',
|
||||
' if [ -z "$PLAN_FILES" ]; then',
|
||||
' echo "[worktree] Plan ${plan_id}: files_modified missing/unparseable — disabling worktree isolation as a safety fallback (submodule project)"',
|
||||
' USE_WORKTREES_FOR_PLAN=false',
|
||||
' else',
|
||||
' INTERSECT=""',
|
||||
' set -f',
|
||||
' for sm_raw in $SUBMODULE_PATHS; do',
|
||||
' sm="${sm_raw#./}"',
|
||||
' sm="${sm%/}"',
|
||||
' [ -z "$sm" ] && continue',
|
||||
' for pf_raw in $PLAN_FILES; do',
|
||||
' pf="${pf_raw#./}"',
|
||||
' pf="${pf%/}"',
|
||||
' [ -z "$pf" ] && continue',
|
||||
' matched=0',
|
||||
' case "$pf" in',
|
||||
' "$sm"|"$sm"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$sm" in',
|
||||
' "$pf"|"$pf"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$pf" in',
|
||||
" *'*'*|*'?'*|*'['*)",
|
||||
' prefix="${pf%%[*?[]*}"',
|
||||
' prefix="${prefix%/}"',
|
||||
' if [ -n "$prefix" ]; then',
|
||||
' case "$sm" in',
|
||||
' "$prefix"|"$prefix"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$prefix" in',
|
||||
' "$sm"|"$sm"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' fi',
|
||||
' ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' if [ "$matched" -eq 1 ]; then',
|
||||
' INTERSECT="$INTERSECT $pf_raw"',
|
||||
' fi',
|
||||
' done',
|
||||
' done',
|
||||
' set +f',
|
||||
' if [ -n "$INTERSECT" ]; then',
|
||||
' echo "[worktree] Plan ${plan_id}: planned paths intersect submodule paths (${INTERSECT# }) — disabling worktree isolation for this plan"',
|
||||
' USE_WORKTREES_FOR_PLAN=false',
|
||||
' fi',
|
||||
' fi',
|
||||
'fi',
|
||||
'echo "USE_WORKTREES_FOR_PLAN=$USE_WORKTREES_FOR_PLAN"',
|
||||
].join('\n');
|
||||
|
||||
function runGate(cwd, env) {
|
||||
const out = execFileSync('bash', ['-c', GATE_SNIPPET], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
const lines = out.trim().split('\n');
|
||||
const last = lines[lines.length - 1];
|
||||
const m = last.match(/^USE_WORKTREES_FOR_PLAN=(true|false)$/);
|
||||
assert.ok(
|
||||
m,
|
||||
`expected final line to be USE_WORKTREES_FOR_PLAN=<bool>, got: ${last}\nfull stdout:\n${out}`
|
||||
);
|
||||
return { decision: m[1], stdout: out, logLines: lines.slice(0, -1) };
|
||||
}
|
||||
|
||||
function writeGitmodulesWithSubmodule(repo, submodulePath) {
|
||||
const content = [
|
||||
`[submodule "${submodulePath}"]`,
|
||||
`\tpath = ${submodulePath}`,
|
||||
`\turl = https://example.invalid/${submodulePath}.git`,
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), content);
|
||||
}
|
||||
|
||||
describe('Submodule worktree-isolation gate intersects planned paths (#2772)', () => {
|
||||
let repo;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createTempGitProject('gsd-test-2772-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(repo);
|
||||
});
|
||||
|
||||
test('plan touching only src/ in a submodule project keeps worktree isolation ENABLED', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'src/index.ts src/lib/util.ts',
|
||||
plan_id: 'plan-001',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
test('plan touching vendor/foo/bar.ts in a submodule project DISABLES worktree isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'src/index.ts vendor/foo/bar.ts',
|
||||
plan_id: 'plan-002',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-002: planned paths intersect submodule paths/);
|
||||
assert.match(stdout, /vendor\/foo\/bar\.ts/);
|
||||
});
|
||||
|
||||
test('plan whose path equals the submodule root (vendor/foo) DISABLES worktree isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo',
|
||||
plan_id: 'plan-003',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-003: planned paths intersect submodule paths/);
|
||||
});
|
||||
|
||||
test('missing files_modified in a submodule project falls back to DISABLE with a logged reason', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: '',
|
||||
plan_id: 'plan-004',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-004: files_modified missing\/unparseable/);
|
||||
assert.match(stdout, /safety fallback/);
|
||||
});
|
||||
|
||||
test('repo with no .gitmodules at all keeps worktree isolation ENABLED regardless of plan paths', () => {
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/bar.ts src/index.ts',
|
||||
plan_id: 'plan-005',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
test('multiple submodules, plan touches only one of them — DISABLE with that path in the log', () => {
|
||||
const gitmodules = [
|
||||
'[submodule "vendor/foo"]',
|
||||
'\tpath = vendor/foo',
|
||||
'\turl = https://example.invalid/foo.git',
|
||||
'[submodule "third_party/bar"]',
|
||||
'\tpath = third_party/bar',
|
||||
'\turl = https://example.invalid/bar.git',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'src/a.ts third_party/bar/b.ts',
|
||||
plan_id: 'plan-006',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /third_party\/bar\/b\.ts/);
|
||||
});
|
||||
|
||||
test('planned path that merely shares a prefix with a submodule (vendor/foobar) does NOT count as intersection', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foobar/x.ts',
|
||||
plan_id: 'plan-007',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
// ---- Path-normalization & glob coverage (CodeRabbit MAJOR finding) ----
|
||||
|
||||
test('planned path with leading "./" normalizes and DISABLES isolation when inside a submodule', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: './vendor/foo/bar.c',
|
||||
plan_id: 'plan-norm-1',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false', './vendor/foo/bar.c must normalize and intersect vendor/foo');
|
||||
assert.match(stdout, /vendor\/foo\/bar\.c/);
|
||||
});
|
||||
|
||||
test('planned path with trailing slash equal to submodule DISABLES isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/',
|
||||
plan_id: 'plan-norm-2',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false', 'trailing slash must not defeat the submodule-root match');
|
||||
});
|
||||
|
||||
test('globby planned path "vendor/**/*.c" DISABLES isolation when submodule sits inside vendor/', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/**/*.c',
|
||||
plan_id: 'plan-norm-3',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'glob whose literal prefix "vendor" contains submodule vendor/foo must intersect'
|
||||
);
|
||||
assert.match(stdout, /vendor\/\*\*\/\*\.c/);
|
||||
});
|
||||
|
||||
test('plan declares a parent directory of the submodule (e.g. "vendor") — DISABLES isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor',
|
||||
plan_id: 'plan-norm-4',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'planned path that contains the submodule must intersect (bidirectional matching)'
|
||||
);
|
||||
});
|
||||
|
||||
test('submodule path declared with leading "./" in .gitmodules still matches a plain planned path', () => {
|
||||
const gitmodules = [
|
||||
'[submodule "vendor/foo"]',
|
||||
'\tpath = ./vendor/foo',
|
||||
'\turl = https://example.invalid/foo.git',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/bar.ts',
|
||||
plan_id: 'plan-norm-5',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'submodule "./vendor/foo" must normalize and match plain planned path vendor/foo/bar.ts'
|
||||
);
|
||||
});
|
||||
|
||||
test('globby planned path that does NOT overlap the submodule keeps isolation ENABLED', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'src/**/*.ts',
|
||||
plan_id: 'plan-norm-6',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Workflow-markdown wiring assertions (CodeRabbit CRITICAL finding) ----
|
||||
//
|
||||
// The original PR computed USE_WORKTREES_FOR_PLAN but never read it at the
|
||||
// dispatch sites — the dispatch still branched on the project-level
|
||||
// USE_WORKTREES, so the per-plan decision was dead code. Assert the markdown
|
||||
// actually wires the variable into the four dispatch sites.
|
||||
|
||||
describe('execute-phase.md dispatch wires USE_WORKTREES_FOR_PLAN (#2772)', () => {
|
||||
const workflowPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
const gatePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase',
|
||||
'steps',
|
||||
'per-plan-worktree-gate.md'
|
||||
);
|
||||
|
||||
test('workflow file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(workflowPath), `expected ${workflowPath} to exist`);
|
||||
});
|
||||
|
||||
test('per-plan worktree gate steps file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(gatePath), `expected ${gatePath} to exist`);
|
||||
});
|
||||
|
||||
test('Worktree-mode dispatch gate reads USE_WORKTREES_FOR_PLAN, not USE_WORKTREES', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/\*\*Worktree mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
|
||||
'Worktree-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('Sequential-mode dispatch gate reads USE_WORKTREES_FOR_PLAN', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/\*\*Sequential mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
|
||||
'Sequential-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('"Worktrees disabled" sequential rule is documented per-plan, not project-level', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/worktrees are disabled for a plan/i,
|
||||
'sequential-execution rule must be expressed per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md hooks the per-plan gate steps file at sub-step 2.5', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(md, /Per-plan worktree decision/, 'sub-step header must exist in execute_waves');
|
||||
assert.match(
|
||||
md,
|
||||
/execute-phase\/steps\/per-plan-worktree-gate\.md/,
|
||||
'execute-phase.md must reference the extracted gate file'
|
||||
);
|
||||
});
|
||||
|
||||
test('per-plan gate file documents PLAN_FILES extraction from plan_json', () => {
|
||||
const md = fs.readFileSync(gatePath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/jq -r '\.files_modified \/\/ \[\] \| join\(" "\)' <<<"\$plan_json"/,
|
||||
'PLAN_FILES extraction from plan_json must be documented in the gate file'
|
||||
);
|
||||
});
|
||||
|
||||
test('per-plan gate file uses bidirectional case + glob-prefix handling + set -f discipline', () => {
|
||||
const md = fs.readFileSync(gatePath, 'utf-8');
|
||||
assert.match(md, /set -f/, 'matcher must disable globbing while iterating');
|
||||
assert.match(md, /set \+f/, 'matcher must re-enable globbing after iteration');
|
||||
const pfFirst = md.match(/case "\$pf" in\s+"\$sm"\|"\$sm"\/\*\)/);
|
||||
const smFirst = md.match(/case "\$sm" in\s+"\$pf"\|"\$pf"\/\*\)/);
|
||||
assert.ok(pfFirst, 'matcher must check pf inside sm');
|
||||
assert.ok(smFirst, 'matcher must check sm inside pf (bidirectional)');
|
||||
assert.match(md, /sm="\$\{sm_raw#\.\/\}"/, 'submodule path must strip leading ./');
|
||||
assert.match(md, /pf="\$\{pf_raw#\.\/\}"/, 'planned path must strip leading ./');
|
||||
assert.match(md, /sm="\$\{sm%\/\}"/, 'submodule path must strip trailing /');
|
||||
assert.match(md, /pf="\$\{pf%\/\}"/, 'planned path must strip trailing /');
|
||||
});
|
||||
|
||||
test('Post-wave worktree-cleanup gate is per-plan, not blanket project-level', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/WAVE_WORKTREE_PLANS/,
|
||||
'post-wave cleanup must track which plans actually used worktrees'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- quick.md SUBMODULE_PATHS executor guard (CodeRabbit CRITICAL #3) ----
|
||||
//
|
||||
// Quick mode does NOT have a pre-declared files_modified list. The fail-loud
|
||||
// guard must (a) be present in the markdown of the executor prompt, and
|
||||
// (b) actually abort when run against a fixture that stages a submodule path.
|
||||
|
||||
describe('quick.md executor pre-commit submodule guard (#2772)', () => {
|
||||
const quickPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
test('quick.md executor prompt injects SUBMODULE_PATHS', () => {
|
||||
const md = fs.readFileSync(quickPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/SUBMODULE_PATHS for this project: \$\{SUBMODULE_PATHS\}/,
|
||||
'executor prompt must inline SUBMODULE_PATHS so the agent can run the guard'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md executor prompt contains a fail-loud pre-commit guard with ABORT message', () => {
|
||||
const md = fs.readFileSync(quickPath, 'utf-8');
|
||||
assert.match(md, /<submodule_commit_guard>/, 'guard block must exist');
|
||||
assert.match(
|
||||
md,
|
||||
/git diff --cached --name-only/,
|
||||
'guard must inspect staged paths before commit'
|
||||
);
|
||||
assert.match(
|
||||
md,
|
||||
/ABORT: staged path/,
|
||||
'guard must surface a fail-loud ABORT message on intersection'
|
||||
);
|
||||
assert.match(
|
||||
md,
|
||||
/workflow\.use_worktrees=false/,
|
||||
'guard must tell the user how to recover (re-run without worktrees)'
|
||||
);
|
||||
});
|
||||
|
||||
// Behavioral: extract the guard logic and run it against a fixture repo.
|
||||
// We simulate the executor's commit-time guard and assert it aborts when a
|
||||
// staged path falls inside a SUBMODULE_PATHS entry, and passes otherwise.
|
||||
const QUICK_GUARD_SNIPPET = [
|
||||
'set +e',
|
||||
'STAGED=$(git diff --cached --name-only)',
|
||||
'if [ -n "$SUBMODULE_PATHS" ]; then',
|
||||
' for sm_raw in $SUBMODULE_PATHS; do',
|
||||
' sm="${sm_raw#./}"',
|
||||
' sm="${sm%/}"',
|
||||
' [ -z "$sm" ] && continue',
|
||||
' for f_raw in $STAGED; do',
|
||||
' f="${f_raw#./}"',
|
||||
' f="${f%/}"',
|
||||
' case "$f" in',
|
||||
' "$sm"|"$sm"/*)',
|
||||
' echo "ABORT: staged path $f_raw falls inside submodule $sm — re-run with workflow.use_worktrees=false" >&2',
|
||||
' exit 1 ;;',
|
||||
' esac',
|
||||
' done',
|
||||
' done',
|
||||
'fi',
|
||||
'echo "OK"',
|
||||
].join('\n');
|
||||
|
||||
test('guard ABORTs when a staged path falls inside a submodule', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-abort-');
|
||||
try {
|
||||
// Create a file inside the submodule path and stage it.
|
||||
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
|
||||
|
||||
let err;
|
||||
try {
|
||||
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'guard must exit non-zero when staged path is inside submodule');
|
||||
assert.equal(err.status, 1, 'guard must exit with status 1');
|
||||
const stderr = err.stderr ? err.stderr.toString() : '';
|
||||
assert.match(stderr, /ABORT: staged path vendor\/foo\/bar\.ts/);
|
||||
assert.match(stderr, /vendor\/foo/);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
|
||||
test('guard passes when no staged path falls inside a submodule', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-pass-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'src', 'index.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'src/index.ts'], { cwd: repo });
|
||||
|
||||
const out = execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
|
||||
});
|
||||
assert.match(out, /OK/);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
|
||||
test('guard normalizes leading "./" on staged paths and still ABORTs', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-norm-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
|
||||
|
||||
let err;
|
||||
try {
|
||||
// Submodule path declared with ./ prefix — must still match.
|
||||
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: './vendor/foo' },
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'guard must abort even when SUBMODULE_PATHS uses ./ prefix');
|
||||
assert.equal(err.status, 1);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// Reads hook .js or bin/install.js source to assert structural invariants
|
||||
// (search array order, function wiring, path constants) that cannot be
|
||||
// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for bug #2784
|
||||
*
|
||||
* /gsd-update cache-clear step only cleared per-runtime cache paths
|
||||
* (e.g. ~/.claude/cache/gsd-update-check.json) but the SessionStart hook
|
||||
* (hooks/gsd-check-update.js) writes to the shared tool-agnostic path
|
||||
* ~/.cache/gsd/gsd-update-check.json. After a successful update, the statusline
|
||||
* kept showing the stale "⬆ /gsd-update" indicator because the actual cache
|
||||
* file was never deleted.
|
||||
*
|
||||
* Fix: add `rm -f "$HOME/.cache/gsd/gsd-update-check.json"` to the
|
||||
* run_update step's cache-clear block in gsd-core/workflows/update.md.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const UPDATE_WORKFLOW = path.join(
|
||||
REPO_ROOT,
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'update.md'
|
||||
);
|
||||
const CHECK_UPDATE_HOOK = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js');
|
||||
|
||||
describe('bug-2784: update.md cache-clear covers shared cache path', () => {
|
||||
test('gsd-check-update.js hook constructs cache dir from .cache and gsd path segments', () => {
|
||||
const hookContent = fs.readFileSync(CHECK_UPDATE_HOOK, 'utf-8');
|
||||
// Parse the path.join() call structurally rather than text-grepping.
|
||||
const m = hookContent.match(/const cacheDir\s*=\s*path\.join\(([^)]+)\)/);
|
||||
assert.ok(
|
||||
m !== null,
|
||||
'hook must assign cacheDir via path.join() with explicit path segments'
|
||||
);
|
||||
const segments = m[1].split(',').map((a) => a.trim().replace(/^['"]|['"]$/g, ''));
|
||||
assert.ok(
|
||||
segments.includes('.cache'),
|
||||
`hook cacheDir path.join() must include '.cache' segment; got: ${JSON.stringify(segments)}`
|
||||
);
|
||||
assert.ok(
|
||||
segments.includes('gsd'),
|
||||
`hook cacheDir path.join() must include 'gsd' segment; got: ${JSON.stringify(segments)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md run_update bash commands include rm for shared gsd cache file', () => {
|
||||
const workflowContent = fs.readFileSync(UPDATE_WORKFLOW, 'utf-8');
|
||||
// Parse the step block structurally, then extract only bash fenced code lines.
|
||||
const stepMatch = workflowContent.match(/<step name="run_update">[\s\S]*?<\/step>/);
|
||||
assert.ok(stepMatch, 'update.md must have a <step name="run_update"> block');
|
||||
const stepContent = stepMatch[0];
|
||||
|
||||
const bashLines = [];
|
||||
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g;
|
||||
let m;
|
||||
while ((m = fenceRe.exec(stepContent)) !== null) {
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) bashLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const sharedCacheClearCmds = bashLines.filter(
|
||||
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json')
|
||||
);
|
||||
assert.ok(
|
||||
sharedCacheClearCmds.length > 0,
|
||||
[
|
||||
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).',
|
||||
`Bash lines found: ${JSON.stringify(bashLines)}`,
|
||||
].join('\n')
|
||||
);
|
||||
const hasHomeExpansion = sharedCacheClearCmds.some(
|
||||
(line) => line.includes('$HOME') || line.includes('~/')
|
||||
);
|
||||
assert.ok(
|
||||
hasHomeExpansion,
|
||||
`shared cache rm command must use $HOME or ~/ expansion; found: ${JSON.stringify(sharedCacheClearCmds)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js]
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
|
||||
describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => {
|
||||
test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => {
|
||||
assert.ok(
|
||||
/Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) &&
|
||||
/Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src),
|
||||
'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent',
|
||||
);
|
||||
});
|
||||
|
||||
test('adapter includes deferred tool_search discovery guidance before inline fallback', () => {
|
||||
assert.ok(
|
||||
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'),
|
||||
'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2794
|
||||
*
|
||||
* OpenCode generated agents ignored `model_profile_overrides.opencode.*`.
|
||||
* The agent install path called `readGsdEffectiveModelOverrides` (explicit
|
||||
* per-agent overrides) but never called `readGsdRuntimeProfileResolver`
|
||||
* (tier-based profile overrides). When a user configured:
|
||||
*
|
||||
* { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } }
|
||||
*
|
||||
* generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter.
|
||||
*
|
||||
* The fix adds a tier-resolver fallback in the OpenCode agent conversion block:
|
||||
* explicit `model_overrides[agent]` > `model_profile_overrides.opencode.<tier>` > omit.
|
||||
*
|
||||
* This test exercises:
|
||||
* 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides.
|
||||
* 2. The agent install code path embeds the resolved model into OpenCode frontmatter.
|
||||
* 3. Explicit `model_overrides` still wins over tier-based resolution.
|
||||
* 4. Missing overrides produce no `model:` field (no regression on omit behavior).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
readGsdRuntimeProfileResolver,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`);
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
|
||||
describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('resolves opencode sonnet tier to user-supplied model ID', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'anthropic/claude-sonnet-4-7',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.ok(resolver !== null, 'expected a resolver for opencode runtime');
|
||||
|
||||
// gsd-roadmapper balanced tier = sonnet — should resolve to override
|
||||
const entry = resolver.resolve('gsd-roadmapper');
|
||||
assert.ok(entry !== null, 'expected entry for gsd-roadmapper');
|
||||
assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied');
|
||||
});
|
||||
|
||||
test('returns null resolver when runtime is not set', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: { opencode: { sonnet: 'x' } },
|
||||
});
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.strictEqual(resolver, null, 'no resolver without runtime field');
|
||||
});
|
||||
|
||||
test('resolver returns null for agent not in MODEL_PROFILES', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: { opencode: { sonnet: 'x' } },
|
||||
});
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.ok(resolver !== null);
|
||||
const entry = resolver.resolve('gsd-nonexistent-agent');
|
||||
assert.strictEqual(entry, null, 'unknown agent name yields null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
let origCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
origCwd = process.cwd();
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
process.chdir(projectDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
process.chdir(origCwd);
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'anthropic/claude-sonnet-4-7',
|
||||
opus: 'anthropic/claude-opus-4-7',
|
||||
haiku: 'anthropic/claude-haiku-4-5',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const agentsDir = path.join(projectDir, '.opencode', 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents directory should be created');
|
||||
|
||||
// gsd-roadmapper is balanced -> sonnet tier
|
||||
const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md');
|
||||
assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist');
|
||||
const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
assert.match(
|
||||
roadmapperContent,
|
||||
/^model: anthropic\/claude-sonnet-4-7$/m,
|
||||
'gsd-roadmapper should have sonnet model from model_profile_overrides'
|
||||
);
|
||||
|
||||
// gsd-planner is balanced -> opus tier
|
||||
const plannerPath = path.join(agentsDir, 'gsd-planner.md');
|
||||
assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist');
|
||||
const plannerContent = fs.readFileSync(plannerPath, 'utf-8');
|
||||
assert.match(
|
||||
plannerContent,
|
||||
/^model: anthropic\/claude-opus-4-7$/m,
|
||||
'gsd-planner should have opus model from model_profile_overrides'
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_overrides: {
|
||||
'gsd-roadmapper': 'explicit-winner-model',
|
||||
},
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'tier-model-that-should-lose',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md');
|
||||
assert.ok(fs.existsSync(roadmapperPath));
|
||||
const content = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/^model: explicit-winner-model$/m,
|
||||
'explicit model_overrides must win over model_profile_overrides tier'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/tier-model-that-should-lose/,
|
||||
'tier model must not appear when explicit override is present'
|
||||
);
|
||||
});
|
||||
|
||||
test('no model field when neither model_overrides nor model_profile_overrides is set', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md');
|
||||
if (fs.existsSync(roadmapperPath)) {
|
||||
const content = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
// When no overrides, model field should either be absent or use built-in default
|
||||
// The key invariant: no model field if there are no user-supplied overrides
|
||||
// AND no built-in opencode defaults for this tier
|
||||
// (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults)
|
||||
// So we only assert no crash and no tier-model-not-provided entries
|
||||
assert.ok(typeof content === 'string', 'agent file should be a string');
|
||||
}
|
||||
// Key: no exception thrown (test passes = no crash on missing overrides)
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2798
|
||||
*
|
||||
* `gsd-sdk query config-set context_window <n>` was rejected with
|
||||
* "Unknown config key: context_window" because context_window was missing
|
||||
* from VALID_CONFIG_KEYS in sdk/src/query/config-schema.ts.
|
||||
*
|
||||
* The fix added 'context_window' to the allowlist.
|
||||
* This test prevents future drift where the key gets accidentally removed.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js');
|
||||
|
||||
function runConfigSet(key, value, projectDir) {
|
||||
const argv = ['query', 'config-set', key, String(value), '--project-dir', projectDir];
|
||||
let stdout = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [SDK_CLI, ...argv], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GSD_SESSION_KEY: '' },
|
||||
});
|
||||
} catch (err) {
|
||||
exitCode = err.status ?? 1;
|
||||
stdout = err.stdout?.toString() ?? '';
|
||||
}
|
||||
let json = null;
|
||||
try { json = JSON.parse(stdout.trim()); } catch { /* ok */ }
|
||||
return { exitCode, json };
|
||||
}
|
||||
|
||||
describe('bug-2798: context_window is a valid config key', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-test-2798-');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ mode: 'balanced' })
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('config-set context_window succeeds (not rejected as unknown key)', (t) => {
|
||||
if (!fs.existsSync(SDK_CLI)) {
|
||||
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||||
return;
|
||||
}
|
||||
const result = runConfigSet('context_window', 1000000, tmpDir);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 (key is valid)');
|
||||
assert.ok(result.json !== null, 'should emit JSON');
|
||||
assert.strictEqual(result.json?.updated, true, 'updated should be true');
|
||||
assert.strictEqual(result.json?.key, 'context_window');
|
||||
});
|
||||
|
||||
test('context_window value is written to config.json', (t) => {
|
||||
if (!fs.existsSync(SDK_CLI)) {
|
||||
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||||
return;
|
||||
}
|
||||
runConfigSet('context_window', 500000, tmpDir);
|
||||
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
|
||||
);
|
||||
assert.strictEqual(config.context_window, 500000, 'context_window should be persisted');
|
||||
});
|
||||
|
||||
test('config-schema CJS and SDK allowlists both include context_window', (t) => {
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'))) {
|
||||
t.skip('sdk/dist/query/config-schema.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||||
return;
|
||||
}
|
||||
const cjsSchema = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'config-schema.cjs'));
|
||||
const sdkSchema = require(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'));
|
||||
|
||||
assert.ok(
|
||||
cjsSchema.VALID_CONFIG_KEYS.has('context_window'),
|
||||
'CJS VALID_CONFIG_KEYS must include context_window'
|
||||
);
|
||||
assert.ok(
|
||||
sdkSchema.VALID_CONFIG_KEYS.has('context_window'),
|
||||
'SDK VALID_CONFIG_KEYS must include context_window'
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user