Merge branch 'next' into fix/1477-surface-source-marker

This commit is contained in:
Joe
2026-06-20 14:52:39 -05:00
committed by GitHub
102 changed files with 4563 additions and 380 deletions

View File

@@ -386,7 +386,7 @@ describe('VERIFY: data-flow trace, environment audit, and behavioral spot-checks
describe('DISCUSS: discussion log generation', () => {
test('discuss-phase workflow references DISCUSSION-LOG.md generation', () => {
// After #2551 progressive-disclosure refactor, the DISCUSSION-LOG.md template
// After the discuss-phase progressive-disclosure split (#717), the DISCUSSION-LOG.md template
// body lives in workflows/discuss-phase/templates/discussion-log.md and is
// read at the git_commit step. Both files together must satisfy the
// documentation contract.
@@ -402,7 +402,7 @@ describe('DISCUSS: discussion log generation', () => {
);
assert.ok(
content.includes('Audit trail only'),
'discuss-phase (or its discussion-log template after #2551) must mark discussion log as audit-only'
'discuss-phase (or its discussion-log template after the discuss-phase/modes split) must mark discussion log as audit-only'
);
});

View File

@@ -22,8 +22,8 @@
"gsd-nyquist-auditor.md": 7255,
"gsd-pattern-mapper.md": 12487,
"gsd-phase-researcher.md": 40638,
"gsd-plan-checker.md": 42003,
"gsd-planner.md": 49216,
"gsd-plan-checker.md": 44646,
"gsd-planner.md": 49306,
"gsd-project-researcher.md": 22014,
"gsd-research-synthesizer.md": 13653,
"gsd-roadmapper.md": 21781,

View File

@@ -0,0 +1,162 @@
// 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.`,
);
});
});

View File

@@ -3,9 +3,15 @@
*
* After a fresh local install (`--claude --local`), all /gsd-* commands
* except /gsd-help return "Unknown skill: gsd-quick" because
* .claude/commands/gsd/ is not populated. Claude Code reads local project
* commands from .claude/commands/gsd/ (the commands/ format), not from
* .claude/skills/ — only the global ~/.claude/skills/ is used for skills.
* .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';
@@ -37,9 +43,9 @@ before(() => {
});
});
// ─── #1736: local install deploys commands/gsd/ ─────────────────────────────
// ─── #1736 + #1367: local install deploys commands in flat gsd-<cmd>.md layout ───
describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.md layout, #1367)', () => {
let tmpDir;
beforeEach(() => {
@@ -52,48 +58,63 @@ describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
cleanup(tmpDir);
});
test('local install creates .claude/commands/gsd/ directory', (t) => {
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', 'gsd');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
'.claude/commands/gsd/ directory must exist after local install'
'.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/gsd/', (t) => {
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', 'gsd');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
'.claude/commands/gsd/ must exist'
'.claude/commands/ must exist'
);
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
files.length > 0,
`.claude/commands/gsd/ must contain at least one .md file, found: ${JSON.stringify(files)}`
`.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}`
);
});
test('local install deploys quick.md to .claude/commands/gsd/', (t) => {
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');
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'
'.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)'
);
});
});

View File

@@ -21,14 +21,14 @@ const path = require('node:path');
const DISCUSS_PHASE = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md',
);
// After #2551 progressive-disclosure refactor, the scout_codebase phase-type
// 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 #2551 — fail loudly if either is missing
// 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}`);
@@ -42,7 +42,7 @@ describe('discuss-phase context fixes (#2549, #2550, #2552)', () => {
assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist');
assert.ok(
fs.existsSync(SCOUT_REF),
'references/scout-codebase.md must exist after #2551 extraction',
'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split',
);
src = readDiscussContext();
});

View File

@@ -138,7 +138,14 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
// ---------------------------------------------------------------------------
// E — Integration: real local claude install produces clean command bodies
// ---------------------------------------------------------------------------
describe('E — integration: staged commands/gsd/*.md files contain no colon-namespace refs', () => {
// E — integration: flat gsd-*.md layout + clean bodies (#1367 fix)
//
// Prior to #1367: commands wrote to commands/gsd/<cmd>.md (bare names in a
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
// Code registers them as /gsd-<cmd> (hyphen form, matching all framework refs).
// ---------------------------------------------------------------------------
describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => {
let tmpDir;
const cmdNames = readCmdNames();
const rosterRegex = buildRosterRegex(cmdNames);
@@ -152,36 +159,45 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
cleanup(tmpDir);
});
test('E0: staged commands/gsd/ directory exists after install', () => {
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => {
// After #1367 fix: commands land at .claude/commands/gsd-<cmd>.md (flat,
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.md subdirectory
// layout must NOT be created.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
`commands/gsd/ must be created by local claude install at ${commandsDir}`,
`commands/ must be created by local claude install at ${commandsDir}`,
);
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
flatFiles.length > 0,
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` +
`Found none — install may still be using the old commands/gsd/<cmd>.md subdirectory layout.`,
);
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
const oldSubdir = path.join(commandsDir, 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
);
});
test('E1: no staged command body contains /gsd:<known-cmd> colon refs', () => {
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
assert.ok(fs.existsSync(commandsDir), 'commands/gsd/ must exist for this check to be meaningful');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
if (rosterRegex.test(content)) {
const rel = path.relative(tmpDir, fullPath);
offenders.push(rel);
}
}
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const fullPath = path.join(commandsDir, entry.name);
const content = fs.readFileSync(fullPath, 'utf-8');
if (rosterRegex.test(content)) {
offenders.push(path.relative(tmpDir, fullPath));
}
};
walk(commandsDir);
}
assert.deepEqual(
offenders,
@@ -193,29 +209,22 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => {
// Run install a second time; if the normalizer double-applies it would
// produce garbled output like /gsd--execute-phase. Verify the directory
// still passes the same cleanliness check after a second install.
// produce garbled output like /gsd--execute-phase. Verify the commands
// still pass the same cleanliness check after a second install.
runClaudeLocalInstall(tmpDir);
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const doubleRewriteRegex = /\/gsd--[a-z]/;
const garbled = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
if (doubleRewriteRegex.test(content)) {
garbled.push(path.relative(tmpDir, fullPath));
}
}
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8');
if (doubleRewriteRegex.test(content)) {
garbled.push(entry.name);
}
};
walk(commandsDir);
}
assert.deepEqual(
garbled,

View File

@@ -324,10 +324,79 @@ describe('capability disable / enable', () => {
// ─── unknown subcommand ───────────────────────────────────────────────────────
describe('capability (unknown)', () => {
test('an unknown subcommand lists the full available set', () => {
test('an unknown subcommand lists the full available set (incl. outdated)', () => {
const r = runGsdTools(['capability', 'bogus'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, trust, disable, enable, state, set/);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, outdated, trust, disable, enable, state, set/);
});
});
// ─── outdated (#1463) ───────────────────────────────────────────────────────
describe('capability outdated', () => {
test('empty ledger → --json empty array; default table shows the empty marker', () => {
const home = tmpDir('cap-cli-home-');
const json = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
assert.equal(json.success, true, `outdated --json failed: ${json.error || json.output}`);
assert.deepEqual(parse(json.output), []);
const table = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(table.success, true, `outdated table failed: ${table.error || table.output}`);
assert.match(table.output, /no installed overlay capabilities/i);
});
test('local source whose path now declares a newer version → status outdated (records shape)', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('outcap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Bump the recorded LOCAL source to a newer version — the peek re-reads it.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
const rows = parse(r.output);
const row = rows.find((x) => x.id === 'outcap');
assert.ok(row, 'installed capability reported by outdated');
// revert-fails: with the comparison inverted this would be 'current', not 'outdated'.
assert.equal(row.status, 'outdated');
assert.equal(row.current, '1.0.0');
assert.equal(row.latest, '2.0.0');
assert.equal(row.sourceKind, 'local');
assert.equal(row.scope, 'global');
});
test('local source at the same version → status current; default emits a table with the row', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('samecap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
// Table form: header columns + the capability row with status current.
assert.match(r.output, /ID\s+Source\s+Current\s+Latest\s+Status/);
assert.match(r.output, /samecap\s+local\s+1\.0\.0\s+1\.0\.0\s+current/);
});
test('tarball source → status manual (not auto-detectable)', () => {
// Plant a project-scope ledger entry with a tarball source directly (install would need network).
const cwd = makeCwd();
const ledgerMod = require('../gsd-core/bin/lib/capability-ledger.cjs');
ledgerMod.recordInstall(cwd, {
id: 'tarcap', version: '1.0.0', source: 'https://host/path/cap-1.0.0.tgz',
integrity: '', files: ['.gsd/capabilities/tarcap'], sharedEdits: [],
});
const r = runGsdTools(['capability', 'outdated', '--scope', 'project', '--json'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
const row = parse(r.output).find((x) => x.id === 'tarcap');
assert.ok(row, 'tarball capability reported');
assert.equal(row.status, 'manual');
assert.equal(row.latest, null);
});
test('invalid --scope is rejected', () => {
const r = runGsdTools(['capability', 'outdated', '--scope', 'bogus'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Invalid --scope/i);
});
});

View File

@@ -3133,3 +3133,179 @@ test('IC-05/WIN-2: a consent-store write failure leaves the install status:insta
assert.match(buf, /could not write the consent record/i, 'the warning explains the write failure');
assert.match(buf, new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'the warning names the consent store path');
});
// ---------------------------------------------------------------------------
// #1463: outdatedCapabilities (ADR-1244 D6 "Update available?")
// ---------------------------------------------------------------------------
/** Plant a ledger entry with a given source string and installed version. */
function plantEntry(dir, id, version, source) {
ledgerMod.recordInstall(dir, {
id, version, source, integrity: '',
files: [`.gsd/capabilities/${id}`], sharedEdits: [],
});
}
/** A git ls-remote --tags line for a tag. */
function lsLine(tag) {
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
}
test('#1463 outdated: empty ledger → empty records (no throw)', () => {
const dir = runtime();
assert.deepStrictEqual(lifecycle.outdatedCapabilities({ runtimeDir: dir }), []);
});
test('#1463 outdated: git source with a newer tag → status outdated; latest reported', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.2.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
// revert-fails: an inverted comparison would report 'current' here.
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.2.0');
assert.strictEqual(rec.current, '1.0.0');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git installed == latest → current', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.2.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.2.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: git installed > latest → current (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '2.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm newer → outdated; npm peek error → unknown, other caps still reported', () => {
const dir = runtime();
plantEntry(dir, 'npmgood', '1.0.0', 'npm:@org/good@^1');
plantEntry(dir, 'npmbad', '1.0.0', 'npm:@org/bad@^1');
// revert-fails: without timeout/error→unknown handling, the failing peek crashes the whole verb and
// npmgood would never be reported.
// #1463: the good peek returns npm's REAL multi-line range output (one line per matching version); the
// highest version satisfying `^1` is 1.9.0 (a 2.x would be OUT of range and must NOT be chosen).
const fakeNpm = (args) => {
const pkg = args[args.indexOf('view') + 2]; // ['view','--',<pkg>,'version']
if (pkg === '@org/good@^1') {
const out = ["@org/good@1.4.0 '1.4.0'", "@org/good@1.9.0 '1.9.0'"].join('\n') + '\n';
return { exitCode: 0, stdout: out, stderr: '', signal: null, error: null };
}
return { exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null };
};
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.npmgood.status, 'outdated');
assert.strictEqual(byId.npmgood.latest, '1.9.0', 'highest version satisfying the recorded ^1 range');
assert.strictEqual(byId.npmbad.status, 'unknown', 'a failing peek degrades that row only');
assert.strictEqual(recs.length, 2, 'both capabilities are still reported');
});
test('#1463 outdated: tarball → manual; registry → unknown', () => {
const dir = runtime();
plantEntry(dir, 'tarcap', '1.0.0', 'https://host/path/cap-1.0.0.tgz');
plantEntry(dir, 'regcap', '1.0.0', 'my-cap@gsd-registry');
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.tarcap.status, 'manual');
assert.strictEqual(byId.regcap.status, 'unknown');
});
test('#1463 outdated: local newer/equal → outdated/current (re-read of recorded path)', () => {
const dir = runtime();
const srcNew = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localnew-'));
const srcSame = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localsame-'));
cleanups.push(srcNew, srcSame);
fs.writeFileSync(path.join(srcNew, 'capability.json'), JSON.stringify(declarativeCap('locnew', '2.0.0')));
fs.writeFileSync(path.join(srcSame, 'capability.json'), JSON.stringify(declarativeCap('locsame', '1.0.0')));
plantEntry(dir, 'locnew', '1.0.0', srcNew);
plantEntry(dir, 'locsame', '1.0.0', srcSame);
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.locnew.status, 'outdated');
assert.strictEqual(byId.locnew.latest, '2.0.0');
assert.strictEqual(byId.locsame.status, 'current');
});
test('#1463 outdated: git source pinned to a commit SHA is NEVER outdated even with a newer remote tag → status pinned', () => {
const dir = runtime();
// A `#sha:<commit>`-pinned source: `update` re-resolves to the SAME commit, so a newer tag at the
// remote is irrelevant. revert-fails: without the parsed.ref pinned check, peek returns the highest
// tag 'ok' and outdatedCapabilities compares 9.9.9 > 1.0.0 ⇒ 'outdated', failing this 'pinned' assert.
plantEntry(dir, 'pinnedsha', '1.0.0', 'https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v9.9.9')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git source pinned to an explicit tag → status pinned (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'pinnedtag', '1.0.0', 'https://github.com/org/repo.git#tag:v1.0.0');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v2.0.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
});
test('#1463 outdated: UNPINNED git source (no #ref) with a newer tag → still outdated', () => {
const dir = runtime();
plantEntry(dir, 'unpinned', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.5.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.5.0');
});
test('#1463 outdated: UNPINNED git source at latest → current', () => {
const dir = runtime();
plantEntry(dir, 'unpinnedcur', '1.5.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm RANGE source picks highest matching (real multi-line output) → outdated when installed below it', () => {
const dir = runtime();
plantEntry(dir, 'npmrange', '1.0.0', 'npm:@org/cap@^1');
// npm's REAL range output: one annotated line per matching version (not a single bare token).
// revert-fails: the old single-token parse degrades this to 'unknown', so the 'outdated' assert fails.
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.10.0', 'highest matching version (numeric, not lexical) is what update installs');
});
test('#1463 outdated: npm RANGE source installed == highest matching → current', () => {
const dir = runtime();
plantEntry(dir, 'npmrangecur', '1.10.0', 'npm:@org/cap@^1');
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm NO-version source (tracks latest) → outdated/current via single latest', () => {
const dir = runtime();
plantEntry(dir, 'npmlatest', '1.0.0', 'npm:@org/cap');
const fakeNpm = () => ({ exitCode: 0, stdout: '2.0.0\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '2.0.0');
});
test('#1463 outdated: npm EXACT-pinned source (@1.2.3) → status pinned (update will not move it)', () => {
const dir = runtime();
plantEntry(dir, 'npmpinned', '1.2.3', 'npm:@org/cap@1.2.3');
// revert-fails: without the exact-pin → 'pinned' branch, peek runs npm view and the row classifies
// by comparison; a registry that advertised 9.9.9 would render it 'outdated', failing this assert.
const fakeNpm = () => ({ exitCode: 0, stdout: '9.9.9\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'pinned');
});

View File

@@ -30,12 +30,26 @@ const {
parseSpec,
_setCapabilitySourceHttpGet,
_setHttpsGetImpl,
peekLatestVersion,
pickHighestSemverTag,
splitNpmSpec,
pickHighestNpmVersion,
MAX_RESPONSE_BYTES,
MANIFEST_MAX_BYTES,
MAX_STAGED_BUNDLE_BYTES,
MAX_STAGED_BUNDLE_ENTRIES,
} = capSource;
const { EventEmitter } = require('node:events');
const fc = require('fast-check');
/** Build a `git ls-remote --tags` style stdout line for a tag. */
function lsRemoteLine(tag) {
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
}
/** A SpawnResult-shaped success. */
function spawnOk(stdout) {
return { exitCode: 0, stdout, stderr: '', signal: null, error: null };
}
// ---------------------------------------------------------------------------
// Helpers
@@ -1410,3 +1424,405 @@ describe('#1461 finding 2 — tar header-size parse removed; NAME/TYPE guards re
assert.ok(fs.existsSync(result.stagedDir), 'staged dir exists after extraction');
});
});
// ---------------------------------------------------------------------------
// #1463: pickHighestSemverTag — pure highest-stable-semver-tag parser
// ---------------------------------------------------------------------------
describe('#1463 pickHighestSemverTag (git ls-remote --tags parser)', () => {
test('BOUNDARY: picks the NUMERIC max across v1.1.0/v1.2.0/v1.10.0 + junk (1.10.0, not 1.2.0)', () => {
// revert-fails: a lexical (string) compare would pick "1.2.0" > "1.10.0"; the numeric compare
// (compareSemverCore) must pick 1.10.0. Junk + a non-semver tag must be ignored.
const out = [
lsRemoteLine('v1.1.0'),
lsRemoteLine('v1.2.0'),
lsRemoteLine('v1.10.0'),
lsRemoteLine('not-a-version'),
lsRemoteLine('release-candidate'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '1.10.0');
});
test('ignores ^{} peeled-annotation entries (same tag, not a distinct version)', () => {
const out = [
lsRemoteLine('v2.0.0'),
lsRemoteLine('v2.0.0^{}'),
lsRemoteLine('v1.5.0'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '2.0.0');
});
test('ignores prerelease/non-triplet tags; bare (no-v) triplets accepted', () => {
const out = [
lsRemoteLine('v1.0.0-rc.1'),
lsRemoteLine('1.4.2'),
lsRemoteLine('v2'),
lsRemoteLine('v1.0'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '1.4.2');
});
test('no parseable semver tags → null', () => {
assert.strictEqual(pickHighestSemverTag('0000\trefs/heads/main\n0000\trefs/tags/latest'), null);
assert.strictEqual(pickHighestSemverTag(''), null);
});
test('PROPERTY (fc): result is the numeric max of the injected stable triplets, ignoring junk', () => {
fc.assert(
fc.property(
fc.array(fc.tuple(fc.nat(50), fc.nat(50), fc.nat(50)), { minLength: 1, maxLength: 12 }),
(triplets) => {
const tags = triplets.map(([a, b, c]) => `v${a}.${b}.${c}`);
// Interleave non-semver junk that must be ignored.
const lines = [];
for (const t of tags) {
lines.push(lsRemoteLine(t));
lines.push(lsRemoteLine('junk-' + t)); // non-triplet → ignored
lines.push(lsRemoteLine(`${t}-rc.1`)); // prerelease → ignored
}
const got = pickHighestSemverTag(lines.join('\n'));
// Expected max computed numerically (not lexically).
const expected = triplets
.slice()
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
.pop();
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
},
),
{ numRuns: 200 },
);
});
});
// ---------------------------------------------------------------------------
// #1463: peekLatestVersion — per-source latest-version peek (ADR-1244 D6)
// ---------------------------------------------------------------------------
describe('#1463 peekLatestVersion (D6 per-source matrix)', () => {
test('git: highest remote tag returned as latest (status ok)', () => {
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'must use ls-remote (metadata only — no clone)');
assert.ok(!args.includes('clone'), 'must NOT clone for a peek');
return spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.3.0'), lsRemoteLine('v1.10.0')].join('\n'));
};
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
});
test('git: ls-remote non-zero exit → status unknown (DEGRADE, no throw)', () => {
const fakeGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'unknown');
assert.strictEqual(r.version, null);
});
test('git: ls-remote timeout (signal set) → status unknown', () => {
// revert-fails: without the timeout→unknown branch, a killed peek (signal) would not degrade.
const fakeGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'unknown');
});
test('git: bounded timeout is passed to execGit (≤30s)', () => {
let seenTimeout;
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk(lsRemoteLine('v1.0.0')); };
peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `git peek must be bounded ≤30s (got ${seenTimeout})`);
});
test('git: source pinned to a commit SHA (#sha:…) → status pinned, NEVER outdated (update stays on the ref)', () => {
// A `#sha:<commit>` pin is immutable: re-resolving the recorded source checks out the SAME commit,
// so a newer remote tag is irrelevant. revert-fails: without the parsed.ref pinned check the peek
// returns the highest tag with status 'ok', which outdatedCapabilities renders 'outdated' — this
// assert (status 'pinned') then fails.
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v9.9.9')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: source pinned to an explicit tag (#tag:…) → status pinned', () => {
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v2.0.0')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git#tag:v1.0.0', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: bare ref (#<ref>) resolving to a TAG (refs/tags/…) → status pinned (immutable tag)', () => {
// #1463 Fix 2 (R Medium): a bare `#<ref>` is ambiguous (tag OR branch). We classify it with a
// bounded `git ls-remote <url> <ref>`. When the remote resolves it under refs/tags/ it is an
// immutable tag → pinned. The ls-remote query is the SAME safe seam (argv + `--`).
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote (metadata only)');
assert.ok(args.includes('--'), 'argv must terminate options with `--`');
assert.ok(args.includes('release-1'), 'the bare ref is passed to ls-remote for classification');
// ls-remote <url> <ref> prints the matching ref line(s).
return spawnOk('1111111111111111111111111111111111111111\trefs/tags/release-1');
};
const r = peekLatestVersion('https://github.com/org/repo.git#release-1', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: bare ref (#main) resolving to a BRANCH (refs/heads/…) → NEVER pinned (mutable; no installed sha ⇒ unknown)', () => {
// #1463 Fix 2 (R Medium) — THE bug: `repo.git#main` is a MUTABLE branch (`update` re-clones and
// checks out the ref, so it can move). The OLD isGitRefPinned reported ANY non-empty parsed.ref as
// 'pinned', so this asserted 'pinned' and was WRONG. The ledger records NO installed commit sha for
// git sources (integrity is null), so a moved branch HEAD cannot be compared → DEGRADE to 'unknown'.
// revert-fails: with the old blanket isGitRefPinned this returns 'pinned' and the !== 'pinned'
// assert below fails (and the === 'unknown' assert fails too).
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote');
return spawnOk('2222222222222222222222222222222222222222\trefs/heads/main');
};
const r = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned', 'a mutable branch ref must NEVER be reported pinned');
assert.strictEqual(r.status, 'unknown', 'no installed sha recorded ⇒ cannot compare branch HEAD ⇒ unknown');
});
test('git: bare ref classification — ls-remote error/timeout → status unknown (DEGRADE, never pinned/crash)', () => {
const errGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
const r1 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: errGit } });
assert.notStrictEqual(r1.status, 'pinned');
assert.strictEqual(r1.status, 'unknown');
const killGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
const r2 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: killGit } });
assert.strictEqual(r2.status, 'unknown');
});
test('git: bare ref classification — unresolvable/empty ls-remote output → status unknown (never pinned)', () => {
const fakeGit = () => spawnOk('');
const r = peekLatestVersion('https://github.com/org/repo.git#mystery-ref', { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned');
assert.strictEqual(r.status, 'unknown');
});
test('git: bare ref classification — ls-remote returns BOTH refs/tags/<r> AND refs/heads/<r> (true ambiguity) → status unknown (NOT pinned)', () => {
// #1463 accuracy fix: when ls-remote resolves a bare ref under BOTH refs/tags/ AND refs/heads/
// the ref is genuinely ambiguous (a tag and a branch share the same name). The classifier must
// NOT prefer the tag and report 'pinned' — the mutable branch reading means the ref could move.
// The safe fallback is 'unknown'.
// revert-fails: a classifier that scans lines and picks the FIRST refs/tags/ hit (or any tag-wins
// strategy) would return 'pinned' here, making the notStrictEqual('pinned') assert below fail.
const ambiguousRef = 'release-1';
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'must use ls-remote for bare-ref classification');
assert.ok(args.includes(ambiguousRef), 'the bare ref must be passed to ls-remote');
// ls-remote output: same name exists as BOTH a tag and a branch head.
return spawnOk(
`1111111111111111111111111111111111111111\trefs/tags/${ambiguousRef}\n` +
`2222222222222222222222222222222222222222\trefs/heads/${ambiguousRef}\n`,
);
};
const r = peekLatestVersion(`https://github.com/org/repo.git#${ambiguousRef}`, { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned', 'ambiguous tag+branch ref must NEVER be reported pinned');
assert.strictEqual(r.status, 'unknown', 'ambiguous ref degrades to unknown (safe fallback)');
});
test('git: bare ref classification — bounded timeout (≤30s) passed to the ls-remote classify call', () => {
let seenTimeout;
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk('33\trefs/heads/main'); };
peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `classify peek must be bounded ≤30s (got ${seenTimeout})`);
});
test('git: UNPINNED source (no #ref, tracks default branch) → highest tag with status ok (NOT pinned)', () => {
// revert-fails-guard for over-pinning: an unpinned source must STILL peek and resolve a version.
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.4.0')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.4.0' });
});
test('npm: RANGE spec — npm view prints EVERY matching version (real multi-line output) → highest matching is chosen', () => {
// npm's REAL behaviour for `npm view <pkg>@<range> version`: when the range matches multiple
// versions it prints one annotated line PER matching version, e.g.
// @org/gsd-cap-foo@1.0.0 '1.0.0'
// @org/gsd-cap-foo@1.3.0 '1.3.0'
// @org/gsd-cap-foo@1.10.0 '1.10.0'
// (NOT a single bare token). The peek must parse ALL tokens and pick the HIGHEST numerically.
// revert-fails: the old single-token NPM_VERSION_RE.test(stdout.trim()) parse sees multi-line
// output as non-semver and DEGRADES to status 'unknown' — this assert then fails.
const multiLine = [
"@org/gsd-cap-foo@1.0.0 '1.0.0'",
"@org/gsd-cap-foo@1.3.0 '1.3.0'",
"@org/gsd-cap-foo@1.10.0 '1.10.0'",
].join('\n') + '\n';
const fakeNpm = (args, o) => {
assert.ok(args.includes('view'), 'must use npm view');
assert.ok(typeof o.timeout === 'number' && o.timeout <= 60_000, 'npm peek must be bounded ≤60s');
// Invocation shape is unchanged: ['view','--',<target>,'version'] with the range still on target.
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo@^1', 'version']);
return spawnOk(multiLine);
};
const r = peekLatestVersion('npm:@org/gsd-cap-foo@^1', { execOverrides: { npm: fakeNpm } });
// 1.10.0 must beat 1.3.0 numerically (not lexically), and it satisfies ^1.
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
});
test('npm: RANGE spec — highest MATCHING version is bounded by the range (out-of-range versions ignored)', () => {
// ^1 must NOT pick a 2.x even if npm happened to print one; the chosen version must satisfy the range.
const multiLine = [
"@org/cap@1.4.0 '1.4.0'",
"@org/cap@1.9.0 '1.9.0'",
"@org/cap@2.0.0 '2.0.0'",
].join('\n') + '\n';
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.9.0' });
});
test('npm: RANGE spec — installed < highest matching ⇒ caller sees newer; installed == highest ⇒ same', () => {
// Two ledgers: the peek itself only resolves the highest-matching version; the outdated/current
// decision lives in outdatedCapabilities. Here we lock the peek's resolution (the input to that).
const multiLine = [
"@org/cap@1.2.0 '1.2.0'",
"@org/cap@1.5.0 '1.5.0'",
].join('\n') + '\n';
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
assert.strictEqual(r.version, '1.5.0', 'highest matching is the version update would install');
});
test('npm: NO-version spec (tracks latest) — single bare latest line → status ok', () => {
const fakeNpm = (args) => {
// No version on the spec ⇒ target is just the bare name; npm view prints a single latest token.
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo', 'version']);
return spawnOk('2.4.1\n');
};
const r = peekLatestVersion('npm:@org/gsd-cap-foo', { execOverrides: { npm: fakeNpm } });
assert.deepStrictEqual(r, { status: 'ok', version: '2.4.1' });
});
test('npm: EXACT-pinned spec (@1.2.3) → status pinned (update re-resolves to the SAME version, never outdated)', () => {
// revert-fails: without the exact-pin → 'pinned' branch, the npm peek would run npm view and
// compare, so a pinned source could be reported outdated; this assert requires status 'pinned'.
let called = false;
const fakeNpm = () => { called = true; return spawnOk('9.9.9\n'); };
const r = peekLatestVersion('npm:@org/gsd-cap-foo@1.2.3', { execOverrides: { npm: fakeNpm } });
assert.strictEqual(r.status, 'pinned');
assert.strictEqual(r.version, '1.2.3', 'pinned reports the pinned exact version');
assert.strictEqual(called, false, 'an exact-pinned npm source needs no remote peek (update will not move it)');
});
test('npm: npm view error/timeout → status unknown (no crash)', () => {
const r1 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null }) } });
assert.strictEqual(r1.status, 'unknown');
const r2 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null }) } });
assert.strictEqual(r2.status, 'unknown');
});
test('npm: non-semver output → status unknown (untrusted output)', () => {
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk('not a version\n') } });
assert.strictEqual(r.status, 'unknown');
});
test('local: re-reads capability.json version (status ok)', () => {
const dir = makeLocalCap(featureCap('local-peek', { version: '3.1.0' }));
try {
const r = peekLatestVersion(dir);
assert.deepStrictEqual(r, { status: 'ok', version: '3.1.0' });
} finally {
cleanup(dir);
}
});
test('local: missing path → status unknown (no throw)', () => {
const r = peekLatestVersion('/no/such/path/that/exists');
assert.strictEqual(r.status, 'unknown');
});
test('tarball: not auto-detectable → status manual (no version)', () => {
const r = peekLatestVersion('https://host/path/cap-1.0.0.tgz');
assert.strictEqual(r.status, 'manual');
assert.strictEqual(r.version, null);
});
test('registry: unimplemented → status unsupported', () => {
const r = peekLatestVersion('my-cap@gsd-registry');
assert.strictEqual(r.status, 'unsupported');
assert.strictEqual(r.version, null);
});
});
// ---------------------------------------------------------------------------
// #1463: pure npm-spec / npm-view parsers (splitNpmSpec, pickHighestNpmVersion)
// ---------------------------------------------------------------------------
describe('#1463 splitNpmSpec (name vs version-selector)', () => {
test('scoped package with exact version → split at the LAST @ (not the scope @)', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg@1.2.3'), { name: '@org/pkg', selector: '1.2.3' });
});
test('scoped package with range → selector is the range', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg@^1'), { name: '@org/pkg', selector: '^1' });
});
test('scoped package, no version → empty selector (tracks latest)', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg'), { name: '@org/pkg', selector: '' });
});
test('unscoped package with version → split at the single @', () => {
assert.deepStrictEqual(splitNpmSpec('pkg@2.0.0'), { name: 'pkg', selector: '2.0.0' });
});
test('unscoped package, no version → empty selector', () => {
assert.deepStrictEqual(splitNpmSpec('pkg'), { name: 'pkg', selector: '' });
});
});
describe('#1463 pickHighestNpmVersion (robust multi-line range parse)', () => {
test('multi-line annotated range output → highest matching (numeric, not lexical)', () => {
const out = ["@org/pkg@1.2.0 '1.2.0'", "@org/pkg@1.10.0 '1.10.0'", "@org/pkg@1.3.0 '1.3.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.10.0');
});
test('range bound is honored — out-of-range versions ignored', () => {
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.0.0 '2.0.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.9.0');
});
test('empty selector = no constraint → overall max', () => {
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.4.0 '2.4.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, ''), '2.4.0');
});
test('single bare token (latest dist-tag) parses', () => {
assert.strictEqual(pickHighestNpmVersion('2.4.1\n', ''), '2.4.1');
});
test('garbage / no version tokens → null (DEGRADE)', () => {
assert.strictEqual(pickHighestNpmVersion('not a version\n', ''), null);
assert.strictEqual(pickHighestNpmVersion('', '^1'), null);
});
test('no token satisfies the range → null', () => {
const out = ["@org/pkg@2.0.0 '2.0.0'", "@org/pkg@3.0.0 '3.0.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), null);
});
test('package NAME contains a version-like substring → resolves the RESOLVED version, not the name token', () => {
// #1463 Fix 1 (R Medium): npm view (range) prints `<name>@<version> '<version>'`. When the package
// NAME itself contains an `x.y.z`-shaped substring (`@scope/cap-1.2.3`), the version must come from
// its CANONICAL position (the quoted token / the token after the LAST `@`), NOT any token on the line.
// revert-fails: the old any-token regex matches `1.2.3` from the NAME first and returns it (the
// highest token that satisfies ^1 is `1.5.0`, but `1.2.3` < `1.5.0`, so a name-poisoned parse could
// also wrongly surface `1.2.3` as a candidate). With both lines present the CORRECT answer is 1.5.0.
const out = [
"@scope/cap-1.2.3@1.0.0 '1.0.0'",
"@scope/cap-1.2.3@1.5.0 '1.5.0'",
].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.5.0');
});
test('single name-poisoned line → resolves the resolved version (not the name substring)', () => {
// revert-fails: with one line `@scope/cap-1.2.3@1.0.0 '1.0.0'` and range `^1.0.0`, the any-token
// regex picks `1.2.3` (the FIRST/HIGHEST satisfying token, from the NAME); the canonical parse must
// return `1.0.0` (the resolved version). 1.2.3 !== 1.0.0 so the assert flips on revert.
const out = "@scope/cap-1.2.3@1.0.0 '1.0.0'";
assert.strictEqual(pickHighestNpmVersion(out, '^1.0.0'), '1.0.0');
});
test('property: with no range constraint, picks the numeric max of the printed versions', () => {
fc.assert(
fc.property(
fc.array(fc.tuple(fc.nat(40), fc.nat(40), fc.nat(40)), { minLength: 1, maxLength: 12 }),
(triplets) => {
const lines = triplets.map(([a, b, c]) => `@org/pkg@${a}.${b}.${c} '${a}.${b}.${c}'`);
const got = pickHighestNpmVersion(lines.join('\n'), '');
const expected = triplets
.slice()
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
.pop();
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
},
),
{ numRuns: 200 },
);
});
});

View File

@@ -21,13 +21,13 @@ const path = require('path');
describe('plan-phase chain flag preservation (#1620)', () => {
const planPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
const discussPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md');
// After #2551, discuss-phase chain logic moved to modes/chain.md.
// After the discuss-phase/modes split (#717), discuss-phase chain logic moved to modes/chain.md.
const discussChainPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'chain.md');
const readDiscuss = () => {
// Fail loudly if either source is missing — silent filtering would let a
// regression that deletes modes/chain.md pass this whole suite.
assert.ok(fs.existsSync(discussPath), `discuss-phase.md missing: ${discussPath}`);
assert.ok(fs.existsSync(discussChainPath), `discuss-phase/modes/chain.md missing after #2551 split: ${discussChainPath}`);
assert.ok(fs.existsSync(discussChainPath), `discuss-phase/modes/chain.md missing after discuss-phase/modes split: ${discussChainPath}`);
return [discussPath, discussChainPath].map(p => fs.readFileSync(p, 'utf8')).join('\n');
};
@@ -61,7 +61,7 @@ describe('plan-phase chain flag preservation (#1620)', () => {
);
assert.ok(
discussContent.includes(guardPattern),
'discuss-phase (or discuss-phase/modes/chain.md after #2551 split) should use the dual-flag guard pattern'
'discuss-phase (or discuss-phase/modes/chain.md after the discuss-phase/modes split) should use the dual-flag guard pattern'
);
});

View File

@@ -19,7 +19,7 @@ const path = require('path');
describe('discuss-phase incremental checkpoint saves (#1485)', () => {
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md');
// After #2551 progressive-disclosure refactor, checkpoint logic lives in the
// After the discuss-phase progressive-disclosure split (#717), checkpoint logic lives in the
// default mode file and the JSON schema lives in the templates directory.
const defaultModePath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'default.md');
const checkpointTplPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'templates', 'checkpoint.json');

View File

@@ -42,7 +42,7 @@ describe('discuss-phase power user mode (#1513)', () => {
describe('main workflow file (discuss-phase.md)', () => {
test('has power_user_mode section or references discuss-phase-power.md', () => {
// After #2551, the power dispatch lives in discuss-phase/modes/power.md and
// After the discuss-phase/modes split (#717), the power dispatch lives in discuss-phase/modes/power.md and
// the parent references it via the dispatch table.
const parentContent = fs.readFileSync(workflowPath, 'utf8');
const powerModePath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'power.md');
@@ -52,7 +52,7 @@ describe('discuss-phase power user mode (#1513)', () => {
const hasReference = content.includes('discuss-phase-power');
assert.ok(
hasPowerSection || hasReference,
'discuss-phase.md (or modes/power.md after #2551) should have power_user_mode section or reference discuss-phase-power.md'
'discuss-phase.md (or modes/power.md after the discuss-phase/modes split) should have power_user_mode section or reference discuss-phase-power.md'
);
});

View File

@@ -0,0 +1,118 @@
'use strict';
/**
* Parity assertions for #1494: workflow config keys that are consumed by
* planning-pipeline code must be (a) accepted by VALID_CONFIG_KEYS and
* (b) documented in references/planning-config.md.
*
* Per DEFECT.GENERATIVE-FIX: a shared constant / key-list that spans two
* surfaces requires a parity assertion that fails when the surfaces diverge.
*/
const { describe, test, before, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs');
const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md');
describe('VALID_CONFIG_KEYS parity — #1494 orphan-undocumented keys', () => {
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH);
test('workflow.mvp_mode is in VALID_CONFIG_KEYS', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.mvp_mode'),
'workflow.mvp_mode is read by config-loader.cts and plan-phase.md but was missing from VALID_CONFIG_KEYS (#1494)'
);
});
test('workflow.code_review_command is in VALID_CONFIG_KEYS', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.code_review_command'),
'workflow.code_review_command must be in VALID_CONFIG_KEYS'
);
});
test('workflow.plan_chunked is in VALID_CONFIG_KEYS', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.plan_chunked'),
'workflow.plan_chunked must be in VALID_CONFIG_KEYS'
);
});
test('workflow.test_command is in VALID_CONFIG_KEYS', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.test_command'),
'workflow.test_command must be in VALID_CONFIG_KEYS'
);
});
test('workflow.build_command is in VALID_CONFIG_KEYS', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.build_command'),
'workflow.build_command must be in VALID_CONFIG_KEYS'
);
});
});
describe('config-set accepts workflow.mvp_mode (#1494)', () => {
let tmpDir;
afterEach(() => { if (tmpDir) cleanup(tmpDir); });
test('config-set workflow.mvp_mode true succeeds and stores the value', () => {
tmpDir = createTempProject();
const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'true'], tmpDir);
assert.ok(
result.success,
`config-set workflow.mvp_mode must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.updated, true, 'response must have updated:true');
assert.strictEqual(parsed.key, 'workflow.mvp_mode', 'response must echo the key');
});
test('config-set workflow.mvp_mode false succeeds', () => {
tmpDir = createTempProject();
const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'false'], tmpDir);
assert.ok(
result.success,
`config-set workflow.mvp_mode false must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.updated, true);
});
});
// allow-test-rule: source-text-is-the-product — planning-config.md is the deployed reference contract (#1494)
describe('planning-config.md documents #1494 keys', () => {
let content;
before(() => { content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8'); });
const KEYS = [
'workflow.mvp_mode',
'workflow.code_review_command',
'workflow.plan_chunked',
'workflow.test_command',
'workflow.build_command',
];
for (const key of KEYS) {
test(`planning-config.md documents \`${key}\``, () => {
assert.ok(
content.includes(`\`${key}\``),
`planning-config.md must document \`${key}\` (#1494)`
);
});
test(`\`${key}\` appears in the Complete Field Reference section`, () => {
const refSection = content.slice(content.indexOf('## Complete Field Reference'));
assert.ok(
refSection.includes(`\`${key}\``),
`planning-config.md Complete Field Reference must include \`${key}\` (#1494)`
);
});
}
});

View File

@@ -0,0 +1,166 @@
// allow-test-rule: source-text-is-the-product #1369
// Workflow .md files are the installed AI instructions — their text IS what the runtime
// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix.
/**
* Regression tests for bug #1369: execute-phase worktree agents fork from stale base after
* a wave merge advances orchestrator HEAD past origin/HEAD.
*
* Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap.
* execute-phase.md contains @-reference pointers; the reference files hold the content.
*/
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 WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md');
const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md');
describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
});
test('wave-guard reference file exists', () => {
assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist');
});
test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('execute-phase-wave-guard.md'),
'execute-phase.md must have an @-reference to execute-phase-wave-guard.md'
);
});
test('workflow contains step 0.5 inter-wave base re-check section', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'),
'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"'
);
});
test('step 0.5 references #1369', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability');
});
test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const forEachIdx = workflow.indexOf('**For each wave:**');
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md');
assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md');
assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave');
});
test('step 0.5 runs worktree.base-check command', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check');
});
test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected');
});
test('step 0.5 appears before step 1 (intra-wave overlap check)', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const forEachIdx = workflow.indexOf('**For each wave:**');
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx);
assert.ok(refIdx !== -1, 'wave-guard @-reference must exist');
assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist');
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
});
test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
'step 0.5 must guard on RUNTIME=claude'
);
});
test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base');
});
test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683');
});
test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('worktree.baseRef') && content.includes('head'),
'step 0.5 must mention worktree.baseRef:"head"'
);
});
});
describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
test('between-wave reference file exists', () => {
assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist');
});
test('workflow contains @-reference pointer to between-wave-reset', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('execute-phase-between-wave-reset.md'),
'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md'
);
});
test('step 7c exists with between-wave manifest reset (#1369)', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(
content.includes('7c.') && content.includes('Between-wave manifest reset'),
'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"'
);
});
test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST');
});
test('step 7c references #1369 and #3384 for traceability', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('#1369'), 'step 7c must reference #1369');
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
});
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
});
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const idx7b = ref.indexOf('7b.');
const idx7c = ref.indexOf('7c.');
const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md');
const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr);
assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file');
assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file');
assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference');
assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b');
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
});
test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
'step 7c must guard on RUNTIME=claude'
);
});
});

View File

@@ -0,0 +1,125 @@
'use strict';
/**
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
*
* Prior to this fix, `phase.list-plans` was not registered in the
* phase-command-router, so any invocation produced:
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
*
* These tests exercise the full dispatch path:
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
*/
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');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
function setupProject(phaseSlug = '01-feature') {
const tmpDir = createTempProject();
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
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 touch(dir, ...files) {
for (const f of files) {
fs.writeFileSync(path.join(dir, f), '');
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
let tmpDir;
let phaseDir;
beforeEach(() => {
const proj = setupProject('01-feature');
tmpDir = proj.tmpDir;
phaseDir = proj.phaseDir;
});
afterEach(() => {
cleanup(tmpDir);
});
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
test('command no longer returns Unknown phase subcommand error', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
// Previously this would fail with "Unknown phase subcommand"
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
`got unexpected error: ${result.error}`);
});
test('returns JSON with plan_count and plans array when plans exist', () => {
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 2, 'plan_count should be 2');
assert.equal(data.has_plans, true, 'has_plans should be true');
assert.ok(Array.isArray(data.plans), 'plans should be an array');
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
});
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
// Phase directory exists but has no *-PLAN.md files
touch(phaseDir, 'CONTEXT.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 0);
assert.equal(data.has_plans, false);
assert.deepEqual(data.plans, []);
});
test('returns has_plans false when phase number is not found', () => {
// Phase 99 does not exist in the fixture
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.has_plans, false);
assert.equal(data.plan_count, 0);
});
test('plan paths are relative to project root and posix-style', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plans.length, 1);
// Paths must be forward-slash separated (posix) and relative (not absolute)
const planPath = data.plans[0];
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
});
test('dotted form phase.list-plans (without query prefix) also works', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 1);
});
});

View File

@@ -0,0 +1,174 @@
'use strict';
/**
* Regression test for bug #1445:
* 999.x backlog phases must not be counted toward total_phases.
*
* Root cause:
* deriveProgressFromRoadmap (phase-lifecycle.cts) counted ALL data rows
* matching /^\|\s*\d+/ in the progress table, including 999.x backlog rows.
* Similarly, state.cts's roadmapPhaseCount loop (via extractCurrentMilestone)
* counted 999.x phase headings because it only checked /\d/.test(m[1]).
*
* Fix:
* Both sites now test /^999(?:\.|$)/.test(token) and skip matching rows.
* Mirrors the existing init.cts /^999(?:\.|$)/ filter.
*
* Scenarios:
* A. deriveProgressFromRoadmap with a progress table containing a 999.x row.
* B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount.
*/
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');
const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
// ─── Scenario A: deriveProgressFromRoadmap unit test ────────────────────────
describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
'| 3. Gamma | 0/1 | Planned | |',
'| 999.1 Backlog: Future Idea | 0/0 | Backlog | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(
result.totalPhases,
3,
`total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`,
);
assert.equal(
result.completedPhases,
1,
`completed_phases must be 1. Got ${result.completedPhases}`,
);
});
test('999 exact (no dot) row is also excluded', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 1/1 | Complete | ✅ |',
'| 2. Beta | 1/1 | Complete | ✅ |',
'| 999 Backlog | 0/0 | Backlog | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(
result.totalPhases,
2,
`total_phases must be 2 (not 3) — 999 row must be excluded. Got ${result.totalPhases}`,
);
assert.equal(
result.completedPhases,
2,
`completed_phases must be 2. Got ${result.completedPhases}`,
);
});
test('all-backlog table yields null total_phases (no real phases)', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
'| 999.1 Future A | 0/0 | Backlog | |',
'| 999.2 Future B | 0/0 | Backlog | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(
result.totalPhases,
null,
`total_phases must be null when the only rows are 999.x backlog. Got ${result.totalPhases}`,
);
});
});
// ─── Scenario B: state json total_phases via roadmapPhaseCount ───────────────
describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => {
let tmpDir;
const ROADMAP = [
'## Milestone v1.0: Test Milestone',
'',
'### Phase 01: Alpha',
'**Goal:** first',
'',
'### Phase 02: Beta',
'**Goal:** second',
'',
'### Phase 03: Gamma',
'**Goal:** third',
'',
'### Phase 999.1: Backlog Item A',
'**Goal:** future idea, not counted',
'',
'### Phase 999.2: Backlog Item B',
'**Goal:** another future idea',
].join('\n');
beforeEach(() => {
tmpDir = createTempProject('bug-1445-');
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(
path.join(planning, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'status: executing',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 1',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
].join('\n'),
'utf-8',
);
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
}
// 999.x dirs should exist on disk but must not inflate total_phases
for (const d of ['999.1-backlog-a', '999.2-backlog-b']) {
fs.mkdirSync(path.join(planning, 'phases', d), { recursive: true });
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state json total_phases is 3, not 5 (999.x dirs and headings excluded)', () => {
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const state = JSON.parse(result.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
3,
`total_phases must be 3 (not 5). 999.x backlog phases must be excluded. Got ${state.progress.total_phases}`,
);
});
});

View File

@@ -0,0 +1,156 @@
'use strict';
/**
* Regression test for bug #1446:
* total_phases must correct downward when re-derived; shouldPreserveExistingProgress
* must NOT include total_phases in its ratchet check.
*
* Root cause:
* shouldPreserveExistingProgress (state-document.cts) returned true when
* existingProgress.total_phases > derivedProgress.total_phases, making the
* stored value sticky even when it was wrong (e.g. counted backlog phases).
*
* Fix:
* total_phases is removed from the "existing exceeds derived" check.
* Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
*
* Scenarios:
* A. shouldPreserveExistingProgress unit test — returns false when only total_phases differs.
* B. state sync re-derives a lower total_phases and writes the new value.
*/
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');
const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs');
// ─── Scenario A: unit test ───────────────────────────────────────────────────
describe('bug #1446 — shouldPreserveExistingProgress does not ratchet total_phases', () => {
test('existing total_phases:10 > derived total_phases:7 → returns false (no ratchet)', () => {
const existing = { total_phases: 10, completed_phases: 3, total_plans: 6, completed_plans: 3 };
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
assert.equal(
shouldPreserveExistingProgress(existing, derived),
false,
'total_phases downward correction must NOT trigger shouldPreserveExistingProgress',
);
});
test('existing completed_phases:5 > derived completed_phases:2 → returns true (ratchet still active)', () => {
const existing = { total_phases: 7, completed_phases: 5, total_plans: 6, completed_plans: 3 };
const derived = { total_phases: 7, completed_phases: 2, total_plans: 6, completed_plans: 3 };
assert.equal(
shouldPreserveExistingProgress(existing, derived),
true,
'completed_phases ratchet must still work',
);
});
test('existing total_phases:10 > derived:7 AND completed_phases matches → false (total_phases alone does not preserve)', () => {
const existing = { total_phases: 10, completed_phases: 3 };
const derived = { total_phases: 7, completed_phases: 3 };
assert.equal(
shouldPreserveExistingProgress(existing, derived),
false,
'only-total_phases discrepancy must not trigger preservation',
);
});
test('all derived values equal existing → returns false', () => {
const existing = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
assert.equal(shouldPreserveExistingProgress(existing, derived), false);
});
});
// ─── Scenario B: end-to-end state sync overwrites inflated total_phases ──────
describe('bug #1446 — state sync writes corrected (lower) total_phases', () => {
let tmpDir;
// ROADMAP has 3 real phases only (no 999.x).
const ROADMAP = [
'## Milestone v1.0: Test',
'',
'### Phase 01: Alpha',
'**Goal:** alpha',
'',
'### Phase 02: Beta',
'**Goal:** beta',
'',
'### Phase 03: Gamma',
'**Goal:** gamma',
].join('\n');
beforeEach(() => {
tmpDir = createTempProject('bug-1446-');
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
// STATE.md has a stale inflated total_phases:10 in frontmatter.
fs.writeFileSync(
path.join(planning, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'status: executing',
'progress:',
' total_phases: 10',
' completed_phases: 2',
' total_plans: 6',
' completed_plans: 4',
' percent: 40',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 3',
'Status: Executing Phase 3',
'Last Activity: 2026-01-01',
'Progress: [████░░░░░░] 40%',
].join('\n'),
'utf-8',
);
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
// Mark 01 and 02 as complete (2 summaries)
if (d !== '03-gamma') {
fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8');
}
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state sync corrects total_phases from 10 to 3', () => {
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools(['state', 'json'], tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const state = JSON.parse(jsonResult.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
3,
`total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`,
);
// completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2
assert.ok(
state.progress.completed_phases >= 2,
`completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`,
);
});
});

View File

@@ -0,0 +1,269 @@
// allow-test-rule: source-text-is-the-product see #1464
// Tutorial docs are the product surface users follow. Reading JSON code blocks
// from them and validating through validateCapability is behavioral, not
// source-grep — it proves the manifests work, not just that they "mention" a term.
'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 { validateCapability } = require('../scripts/gen-capability-registry.cjs');
const ROOT = path.join(__dirname, '..');
// ─── Extractor ───────────────────────────────────────────────────────────────
// Required top-level fields that distinguish a complete capability manifest
// from a partial output snippet (list-entry, install-result, etc.).
// Partial output snippets have id+role but lack steps/contributions/gates/config.
const MANIFEST_REQUIRED_KEYS = new Set([
'id', 'role', 'title', 'description', 'tier',
'requires', 'runtimeCompat', 'skills', 'agents',
'config', 'steps', 'contributions', 'gates',
]);
/**
* Extract JSON code blocks from markdown that are complete capability manifests.
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
*/
function extractManifests(mdContent) {
const manifests = [];
const fenceRe = /```json\s*\n([\s\S]*?)```/g;
let match;
while ((match = fenceRe.exec(mdContent)) !== null) {
let parsed;
try {
parsed = JSON.parse(match[1]);
} catch {
continue;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const keys = new Set(Object.keys(parsed));
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
manifests.push(parsed);
}
}
}
return manifests;
}
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
test('build-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in build tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('install-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in install tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('capability-manifest.md reference example passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in reference doc',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
});
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
//
// These are the EXACT failure shapes from issue #1464.
// They must fail validateCapability — proving this test would have caught the bug.
describe('validateCapability catches original #1464 bug shapes', () => {
// #1464 high-1: step missing ref → validateStep rejects it
test('step without ref fails (the original broken tutorial step)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for #1464 regression.',
tier: 'standard',
requires: [],
engines: { gsd: '>=1.6.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [
{
// Missing ref — this was the #1464 high-1 bug in the original tutorial
point: 'plan:pre',
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
assert.ok(
errors.some((e) => /ref/.test(e)),
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
);
});
// #1464 shape: id must match folder name (folderId contract)
test('id not matching folderId fails', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for id/folderId mismatch.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'wrong-folder');
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
assert.ok(
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
);
});
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.deepStrictEqual(
errors,
[],
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
);
});
});
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
describe('extractManifests helper unit tests', () => {
test('returns empty array for plain text with no JSON fences', () => {
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
});
test('skips JSON blocks without all required manifest keys', () => {
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
assert.deepStrictEqual(extractManifests(md), []);
});
function makeCompleteManifest(overrides) {
return {
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
...overrides,
};
}
test('extracts a complete manifest (all required keys present)', () => {
const cap = makeCompleteManifest({ id: 'x' });
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'x');
});
test('skips malformed JSON blocks silently', () => {
const complete = makeCompleteManifest({ id: 'y' });
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'y');
});
test('extracts multiple complete manifests from one doc', () => {
const a = makeCompleteManifest({ id: 'cap-a' });
const b = makeCompleteManifest({ id: 'cap-b' });
const md = [
'```json\n' + JSON.stringify(a) + '\n```',
'```json\n' + JSON.stringify(b) + '\n```',
].join('\n');
const result = extractManifests(md);
assert.strictEqual(result.length, 2);
});
});

View File

@@ -154,11 +154,19 @@ function manifestSkillSet(manifest) {
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(seg);
} else if (key.startsWith('command/')) {
// OpenCode/Kilo: command/gsd-<cmd>.md
const file = key.split('/')[1];
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
} else if (key.startsWith('commands/gsd/')) {
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
const file = key.split('/')[2];
out.add(file.replace(/\.(md|toml)$/, ''));
} else if (key.startsWith('commands/') && key.split('/').length === 2) {
// Claude local (#1367 fix): flat commands/gsd-<cmd>.md
const file = key.split('/')[1];
if (file.startsWith('gsd-') && file.endsWith('.md')) {
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
return out;
@@ -197,6 +205,15 @@ function collectSkillBasenamesOnDisk(configDir) {
}
}
}
// Claude local (#1367 fix): flat gsd-*.md files at commands/ level
const flatCommandsDir = path.join(configDir, 'commands');
if (fs.existsSync(flatCommandsDir)) {
for (const file of fs.readdirSync(flatCommandsDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
return out;
}

View File

@@ -204,21 +204,118 @@ describe('issue-607 legacy-cleanup: planLegacyCleanup', () => {
assert.deepEqual(paths, sorted, 'plan must be sorted by path');
});
// ── only content-references-old-package and legacy-shared-cache reasons ────
// ── only known reasons ─────────────────────────────────────────────────────
test('plan entries only ever have reason content-references-old-package or legacy-shared-cache', () => {
test('plan entries only ever have known reasons', () => {
writeFile(path.join(configDir, 'hooks', 'gsd-worker.js'), '// ' + OLD_PACKAGE_SIGNAL);
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(cachePath, '{}');
// Stale skill file (#1453)
const staleSkillPath = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
writeFile(staleSkillPath, '@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n'); // gsd-allow-legacy-name
// User custom hook — should NOT appear
writeFile(path.join(configDir, 'hooks', 'gsd-my-custom.js'), '// user hook, clean');
const plan = planLegacyCleanup([configDir], { homeDir });
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache']);
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache', 'stale-get-shit-done-path']); // gsd-allow-legacy-name
for (const entry of plan) {
assert.ok(validReasons.has(entry.reason), `unexpected reason: ${entry.reason}`);
}
});
// ── #1453 stale skill path in ~/.agents/skills/gsd-* ──────────────────────
test('#1453: flags a SKILL.md whose content contains a get-shit-done path reference with reason stale-get-shit-done-path', () => { // gsd-allow-legacy-name
// Simulate a stale ~/.agents/skills/gsd-docs-update/SKILL.md left by an
// older GSD install that embedded the pre-rename runtime path.
const staleSkillFile = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
writeFile(
staleSkillFile,
'---\nname: gsd-docs-update\n---\n' +
'@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n' // gsd-allow-legacy-name
);
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === staleSkillFile);
assert.ok(entry, 'expected stale SKILL.md to appear in plan');
assert.equal(entry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
});
test('#1453: does NOT flag a SKILL.md whose content contains the new gsd-core path', () => {
// A freshly installed SKILL.md references the new runtime directory name.
const freshSkillFile = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
writeFile(
freshSkillFile,
'---\nname: gsd-docs-update\n---\n' +
'@$HOME/.codex/gsd-core/workflows/docs-update.md\n'
);
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === freshSkillFile);
assert.equal(entry, undefined, 'fresh SKILL.md (gsd-core path) must NOT appear in plan');
});
test('#1453: does NOT flag SKILL.md files under user-owned (non-gsd-*) skill directories', () => {
// A user-authored skill dir with a custom name must never be touched.
const userSkillFile = path.join(configDir, 'skills', 'my-custom-skill', 'SKILL.md');
writeFile(
userSkillFile,
'---\nname: my-custom-skill\n---\n' +
'This skill uses ' + 'get-shit-done' + ' concepts.\n' // gsd-allow-legacy-name
);
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === userSkillFile);
assert.equal(entry, undefined, 'user-owned (non-gsd-*) SKILL.md must NOT appear in plan');
});
test('#1453: flags SKILL.md with stale path but preserves SKILL.md in the same dir without stale path', () => {
// Two skill dirs: one stale (get-shit-done ref), one fresh (gsd-core ref). // gsd-allow-legacy-name
const staleSkill = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
const freshSkill = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
writeFile(staleSkill, '@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n'); // gsd-allow-legacy-name
writeFile(freshSkill, '@$HOME/.codex/gsd-core/workflows/help.md\n');
const plan = planLegacyCleanup([configDir], { homeDir });
const staleEntry = plan.find((p) => p.path === staleSkill);
const freshEntry = plan.find((p) => p.path === freshSkill);
assert.ok(staleEntry, 'stale SKILL.md must appear in plan');
assert.equal(staleEntry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
assert.equal(freshEntry, undefined, 'fresh SKILL.md must NOT appear in plan');
});
test('#1453: regression — after upgrade to gsd-core 1.5.0, stale ~/.agents/skills/gsd-docs-update/SKILL.md is removed', () => {
// Simulate the exact scenario from issue #1453:
// ~/.agents/skills/gsd-docs-update/SKILL.md references the old get-shit-done runtime. // gsd-allow-legacy-name
// The upgrade installs correctly to ~/.codex/skills/ but leaves the stale
// ~/.agents/skills/ copy which Codex can still discover.
const agentsDir = path.join(homeDir, '.agents');
const staleSkill = path.join(agentsDir, 'skills', 'gsd-docs-update', 'SKILL.md');
writeFile(
staleSkill,
'---\nname: gsd-docs-update\n---\n' +
'@$HOME/.Codex/' + 'get-shit-done' + '/workflows/docs-update.md\n' // gsd-allow-legacy-name
);
// planLegacyCleanup receives ~/.agents as one of the configDirs (as _LEGACY_SCAN_SUBDIR_NAMES
// includes '.agents' — the antigravity local form). The plan should flag the stale skill.
const plan = planLegacyCleanup([agentsDir], { homeDir });
const entry = plan.find((p) => p.path === staleSkill);
assert.ok(entry, 'stale ~/.agents/skills/gsd-docs-update/SKILL.md must appear in plan');
assert.equal(entry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
// Applying the plan removes the stale file
const result = applyLegacyCleanup(plan);
assert.ok(result.removed.includes(staleSkill), 'stale SKILL.md must appear in removed[]');
assert.equal(result.errors.length, 0, 'no errors expected');
assert.equal(require('node:fs').existsSync(staleSkill), false, 'stale SKILL.md must be deleted on disk');
});
});
describe('issue-607 legacy-cleanup: applyLegacyCleanup', () => {

View File

@@ -341,3 +341,33 @@ describe('D: CLI --check exits 0 when manifests are in sync', () => {
);
});
});
// ─── F: version script includes capability-registry regen (#1498) ─────────────
//
// Regression guard for #1498: the `npm version` lifecycle script must regenerate
// capability-registry.cjs after stamping capability manifests. Without this,
// `npm version X.Y.Z` leaves the committed registry stale (capability JSONs get
// new version strings but the registry still has the old ones), causing the
// `gen-capability-registry.cjs --check` test to fail in the RC workflow.
describe('F: npm version script includes gen-capability-registry --write (#1498)', () => {
test('package.json "version" script regenerates capability-registry.cjs after syncing manifests', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
const versionScript = pkg.scripts && pkg.scripts.version;
assert.ok(
typeof versionScript === 'string',
'package.json must have a "version" script',
);
assert.ok(
versionScript.includes('gen-capability-registry.cjs --write'),
'package.json "version" script must include "gen-capability-registry.cjs --write" to keep the registry in sync after npm version bumps capability manifests. ' +
'Got: ' + JSON.stringify(versionScript),
);
assert.ok(
versionScript.includes('git add') && versionScript.includes('capability-registry.cjs'),
'package.json "version" script must stage capability-registry.cjs with "git add" so it is included in the version-bump commit. ' +
'Got: ' + JSON.stringify(versionScript),
);
});
});

View File

@@ -1,15 +1,19 @@
/**
* GSD Tools Tests - New Milestone Clear Phases (#1588)
* GSD Tools Tests - New Milestone Clear Phases (#1588, #1447)
*
* Verifies that `phases clear` removes all phase subdirectories from
* .planning/phases/, leaving the directory itself intact.
*
* Also covers the #1447 uncommitted-changes guard: phases clear must refuse
* to delete phase directories that contain uncommitted work.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
describe('phases clear command', () => {
let tmpDir;
@@ -110,3 +114,101 @@ describe('phases clear command', () => {
assert.ok(!fs.existsSync(phase1), 'phase directory including nested content should be removed');
});
});
// ─── #1447: uncommitted-changes guard ───────────────────────────────────────
describe('phases clear: uncommitted-changes guard (#1447)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('aborts with error when phase dirs contain uncommitted files', () => {
// Add a phase directory with an untracked (uncommitted) file
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
// Do NOT commit — leave as untracked/uncommitted changes
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(!result.success, 'phases clear should fail when uncommitted changes exist');
assert.ok(
result.error.includes('uncommitted') || result.error.includes('aborted'),
`expected error about uncommitted changes, got: ${result.error}`
);
// Phase directory must still exist (was not deleted)
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
});
test('aborts when phase dirs have staged but uncommitted changes', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (staged)');
// Stage the file but do not commit
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(!result.success, 'phases clear should fail when staged-but-uncommitted changes exist');
assert.ok(
result.error.includes('uncommitted') || result.error.includes('aborted'),
`expected error about uncommitted changes, got: ${result.error}`
);
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
});
test('--force bypasses the uncommitted-changes guard and deletes anyway', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
// Do NOT commit
const result = runGsdTools('phases clear --confirm --force', tmpDir);
assert.ok(result.success, `--force should bypass guard and succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
assert.ok(!fs.existsSync(phase1), 'phase directory must be removed when --force is passed');
});
test('succeeds without --force when all phase files are committed', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (committed)');
// Commit the phase files
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "add phase"', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(result.success, `should succeed when phase files are committed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
assert.ok(!fs.existsSync(phase1), 'committed phase directory should be removed');
});
test('guard skips gracefully when not in a git repo (no guard, proceeds normally)', () => {
// Non-git project: createTempProject creates a plain project without git
const nonGitDir = createTempProject();
try {
const phasesDir = path.join(nonGitDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan');
// Without git, the guard cannot check status — it should skip and proceed
const result = runGsdTools('phases clear --confirm', nonGitDir);
assert.ok(result.success, `should succeed in non-git repo: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory in non-git project');
} finally {
cleanup(nonGitDir);
}
});
});

View File

@@ -0,0 +1,58 @@
// allow-test-rule: runtime-contract-is-the-product (see #1073) — this guard asserts the
// ABSENCE of phantom pre-migration issue references in repo text (docs, tests,
// workflows). The file *content* is the product surface here (#1073): dangling
// refs like #2551/#3182 that don't exist in open-gsd/gsd-core (highest real
// issue is in the low thousands of the redux repo, not here) mislead triage and
// manufacture phantom blockers. This test fails CI if such a ref is reintroduced.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Phantom pre-migration (get-shit-done-redux) issue numbers with NO equivalent
// in open-gsd/gsd-core. Matched only with a leading '#' or in an issues/ URL so
// SSH key patterns like `id_ed25519` (which contain the digits "2551") are NOT
// false-positives.
const PHANTOM = ['2551', '3182', '2361'];
const REF_RE = new RegExp(
'(?:#(?:' + PHANTOM.join('|') + ')\\b)|(?:issues/(?:' + PHANTOM.join('|') + ')\\b)',
);
const SCAN_EXT = new Set(['.md', '.cjs', '.js', '.cts', '.ts']);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.changeset']);
// This guard file itself names the phantom numbers (by necessity); exclude it.
const SELF = path.relative(ROOT, __filename);
function walk(dir, acc) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name), acc);
} else if (SCAN_EXT.has(path.extname(entry.name))) {
acc.push(path.join(dir, entry.name));
}
}
return acc;
}
test('no phantom pre-migration issue references remain in repo text (#1073)', () => {
const offenders = [];
for (const file of walk(ROOT, [])) {
const rel = path.relative(ROOT, file);
if (rel === SELF) continue;
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((line, i) => {
if (REF_RE.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
});
}
assert.strictEqual(
offenders.length,
0,
`Phantom issue refs (${PHANTOM.map((n) => '#' + n).join('/')}) found — repoint to a real ` +
`successor (#717/#720) or rewrite as prose (see #1073):\n` + offenders.join('\n'),
);
});

View File

@@ -45,6 +45,7 @@ function makePhase(overrides = {}) {
cmdPhaseInsert: () => {},
cmdPhaseRemove: () => {},
cmdPhaseComplete: () => {},
cmdPhaseListPlans: () => {},
...overrides,
};
}
@@ -280,26 +281,27 @@ describe('phase-command-router — result translation (error path)', () => {
assert.ok(msg !== null);
assert.ok(msg.includes('exactly one phase number'));
});
// #1437 — phase.list-plans routing
test('routes phase list-plans: passes cwd, phaseNum, raw to handler', () => {
const calls = [];
const phase = makePhase({
cmdPhaseListPlans: (cwd, phaseNum, raw) => calls.push({ cwd, phaseNum, raw }),
});
routePhaseCommand({ phase, args: ['phase', 'list-plans', '03'], cwd: '/proj', raw: false, error: (m) => { throw new Error(m); } });
assert.equal(calls.length, 1);
assert.equal(calls[0].cwd, '/proj');
assert.equal(calls[0].phaseNum, '03');
assert.equal(calls[0].raw, false);
});
});
// ─── 3. Unsupported subcommands ────────────────────────────────────────────────
describe('phase-command-router — unsupported subcommands', () => {
test('phase list-plans resolves as unknown subcommand', () => {
let msg = null;
routePhaseCommand({
phase: makePhase(),
args: ['phase', 'list-plans'],
cwd: '/p',
raw: false,
error: (m) => { msg = m; },
});
assert.ok(msg !== null);
assert.ok(msg.includes('Unknown phase subcommand'));
assert.ok(msg.includes('Available:'), `expected "Available:" in: ${msg}`);
});
// #1437: phase list-plans is now a supported subcommand — routing test in § 1.
test('phase list-artifacts resolves as unknown subcommand', () => {
let msg = null;
routePhaseCommand({
@@ -363,7 +365,8 @@ describe('phase-command-router — unknown subcommand', () => {
assert.ok(msg.includes('add'), `expected add in available list: ${msg}`);
assert.ok(msg.includes('complete'), `expected complete in available list: ${msg}`);
assert.ok(!msg.includes('list-plans'), `list-plans must not appear in available list: ${msg}`);
// #1437: list-plans is now a supported command and appears in the available list
assert.ok(msg.includes('list-plans'), `list-plans must appear in available list: ${msg}`);
});
});

View File

@@ -175,20 +175,19 @@ describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
}
});
// REGRESSION (pre-existing heuristic-3 behavior, orthogonal to heuristic 4):
// REGRESSION (#1422): sub_repos explicit config wins over .git implicit signal.
// A sub_repos workspace where the child has BOTH its own .planning/ AND its own
// .git/ — invoked from inside the child — RESOLVES TO THE CHILD (not the parent).
// Pre-existing heuristic-3 precedence: a sub-repo that is itself a full project
// (.git + .planning) resolves to itself; this is orthogonal to heuristic 4 and
// tracked separately. Documents current behavior.
test('sub_repos child with BOTH .planning/ and .git/ resolves to child itself (heuristic-3 precedence)', () => {
// .git/ — invoked from inside the child — MUST resolve to the PARENT workspace
// because the parent's config.json explicitly lists the child in sub_repos.
// The implicit .git heuristic (heuristic-3) must not override explicit sub_repos.
test('sub_repos child with BOTH .planning/ and .git/ resolves to PARENT workspace (sub_repos wins, #1422)', () => {
// Layout:
// workspaceRoot/
// .planning/
// config.json ← sub_repos: ['child']
// child/
// .planning/ ← child has own .planning/
// .git/ ← child ALSO has own .git/ → heuristic-3 makes it self-resolving
// .git/ ← child ALSO has own .git/ → was triggering heuristic-3 prematurely
// src/ ← startDir
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-git-'));
try {
@@ -203,10 +202,33 @@ describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
const childSrc = mkDeep(childDir, 'src');
const result = findProjectRoot(childSrc);
// Pre-existing heuristic-3 precedence: child is a full project (.git + .planning)
// → resolves to the child, not the workspace root.
assert.strictEqual(result, childDir,
'A sub-repo with both .planning/ and .git/ should resolve to itself (heuristic-3 precedence)');
// Explicit sub_repos config in the ancestor workspace must take precedence
// over the implicit .git heuristic — resolves to the workspace root.
assert.strictEqual(result, workspaceRoot,
'sub_repos config in parent workspace must win over child .git: should resolve to workspaceRoot (#1422)');
} finally {
cleanup(workspaceRoot);
}
});
// REGRESSION (#1422): sub_repos child with .git resolves to parent even when
// startDir is nested more than one level inside the child.
test('sub_repos child with .git: startDir nested 2+ levels inside child still resolves to parent (#1422)', () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-nested-'));
try {
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['child'] })
);
const childDir = path.join(workspaceRoot, 'child');
fs.mkdirSync(path.join(childDir, '.planning'), { recursive: true });
fs.mkdirSync(path.join(childDir, '.git'), { recursive: true });
const deepChild = mkDeep(childDir, 'src', 'lib', 'utils');
const result = findProjectRoot(deepChild);
assert.strictEqual(result, workspaceRoot,
'sub_repos config must win over .git even when startDir is deeply nested inside the child (#1422)');
} finally {
cleanup(workspaceRoot);
}

View File

@@ -51,8 +51,8 @@ const GOLDEN = {
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'claude/local': [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, // #1367: flat gsd-<cmd>.md
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── cursor ───────────────────────────────────────────────────────────────────

View File

@@ -29,7 +29,8 @@ function tmpDir(prefix) {
function createFixtureRuntime() {
const base = createTempDir('gsd-surface-apply-');
const runtimeConfigDir = base;
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
// #1367: claude local uses flat commands/ (not commands/gsd/) — commandsDir is commands/.
const commandsDir = path.join(runtimeConfigDir, 'commands');
const agentsDir = path.join(runtimeConfigDir, 'agents');
fs.mkdirSync(commandsDir, { recursive: true });
fs.mkdirSync(agentsDir, { recursive: true });
@@ -59,6 +60,7 @@ function readFrontmatterDescription(markdown) {
describe('applySurface', () => {
test('core profile: only core skills appear in commandsDir', (t) => {
// #1367: claude local uses flat gsd-<stem>.md files at commands/ (not commands/gsd/<stem>.md).
const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime();
t.after(() => cleanup(base));
writeActiveProfile(runtimeConfigDir, 'core');
@@ -72,11 +74,14 @@ describe('applySurface', () => {
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
// After #1367: files are gsd-<stem>.md (not bare stem.md). Strip the gsd- prefix
// to check against the REAL_COMMANDS_DIR (which still uses bare names).
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
for (const file of files) {
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`);
const bareName = file.slice('gsd-'.length); // gsd-help.md → help.md
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)), `unexpected file: ${file} (no source: ${bareName})`);
}
const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort();
const expectedCore = [...resolved.skills].map(stem => `gsd-${stem}.md`).sort();
assert.deepStrictEqual(
[...files].sort(),
expectedCore,
@@ -98,7 +103,8 @@ describe('applySurface', () => {
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
// #1367: files are gsd-<stem>.md in flat commands/
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')));
writeSurface(runtimeConfigDir, {
baseProfile: 'core',
@@ -108,11 +114,11 @@ describe('applySurface', () => {
});
const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')));
assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard');
const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort();
const expectedCore = [...resolvedCore.skills].map(stem => `gsd-${stem}.md`).sort();
assert.deepStrictEqual(
[...afterCore].sort(),
expectedCore,
@@ -120,8 +126,9 @@ describe('applySurface', () => {
);
for (const file of afterCore) {
const bareName = file.slice('gsd-'.length);
assert.ok(
fs.existsSync(path.join(REAL_COMMANDS_DIR, file)),
fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)),
`file in commandsDir not a real skill: ${file}`
);
}
@@ -161,13 +168,14 @@ describe('applySurface', () => {
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
// #1367: flat gsd-<stem>.md files at commands/ (not commands/gsd/<stem>.md)
assert.ok(
fs.existsSync(path.join(commandsDir, 'help.md')),
'help.md should be copied from install source'
fs.existsSync(path.join(commandsDir, 'gsd-help.md')),
'gsd-help.md should be copied from install source (#1367: flat hyphen layout)'
);
assert.ok(
fs.existsSync(path.join(commandsDir, 'new-project.md')),
'new-project.md should be copied from install source'
fs.existsSync(path.join(commandsDir, 'gsd-new-project.md')),
'gsd-new-project.md should be copied from install source (#1367: flat hyphen layout)'
);
});
@@ -231,11 +239,12 @@ describe('applySurface', () => {
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent');
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
assert.ok(files.length > 0, 'commands/gsd should contain staged skill files');
assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest');
// #1367: claude local uses flat commands/ (not commands/gsd/)
const commandsDir = path.join(runtimeConfigDir, 'commands');
assert.ok(fs.existsSync(commandsDir), 'commands/ dir should be created even if initially absent');
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(files.length > 0, 'commands/ should contain staged skill files (gsd-*.md)');
assert.ok(files.includes('gsd-help.md'), 'gsd-help.md should be present after applySurface on missing dest');
});
test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => {

View File

@@ -40,7 +40,7 @@ describe('resolveRuntimeArtifactLayout — claude local', () => {
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); // #1367: flat gsd-<cmd>.md layout
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds[1].kind, 'agents');

View File

@@ -93,7 +93,7 @@ describe('Thinking Partner Integration (#1726)', () => {
});
// Workflow integration tests
// After #2551 progressive-disclosure refactor, the thinking-partner block
// After the discuss-phase progressive-disclosure split (#717), the thinking-partner block
// moved into the per-mode files (default.md, advisor.md) since the prompt
// is mode-specific (only fires inside discuss_areas, after a user answer).
describe('Discuss-phase integration', () => {

View File

@@ -1028,3 +1028,135 @@ describe('validate health — missing phasesDir', () => {
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1472 regression — workstream-aware paths
// ─────────────────────────────────────────────────────────────────────────────
describe('validate health — #1472 workstream-aware path resolution', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('reports healthy when GSD_WORKSTREAM is set and files are in the correct workstream layout', () => {
// Shared-root files at .planning/
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest project.\n\n## Core Value\n\nCore value here.\n\n## Requirements\n\nRequirements here.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true, workflow: { nyquist_validation: true, ai_integration_phase: true } }, null, 2)
);
// Workstream-scoped files at .planning/workstreams/ws-a/
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-a');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Setup\n'
);
fs.writeFileSync(
path.join(wsDir, 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase: 1\n'
);
const wsPhaseDir = path.join(wsDir, 'phases', '01-setup');
fs.mkdirSync(wsPhaseDir, { recursive: true });
const result = runGsdTools('validate health', tmpDir, { GSD_WORKSTREAM: 'ws-a' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// PROJECT.md and config.json must NOT be reported missing (E002/W003)
assert.ok(
!output.errors.some(e => e.code === 'E002'),
`E002 (PROJECT.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
);
assert.ok(
!output.errors.some(e => e.code === 'E003'),
`E003 (ROADMAP.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
);
assert.ok(
!output.errors.some(e => e.code === 'E004'),
`E004 (STATE.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
);
assert.ok(
!output.warnings.some(w => w.code === 'W003'),
`W003 (config.json missing) should not fire with workstream layout: ${JSON.stringify(output.warnings)}`
);
// Status should not be 'broken' due to path misrouting
assert.notStrictEqual(
output.status, 'broken',
`Status should not be broken when files exist in the correct workstream layout: ${JSON.stringify(output)}`
);
});
test('without GSD_WORKSTREAM, shared-root files are found at .planning/ root', () => {
// All files at .planning/ root (no workstream sub-path)
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.errors.some(e => ['E002', 'E003', 'E004'].includes(e.code)),
`No E002/E003/E004 should fire in standard non-workstream layout: ${JSON.stringify(output.errors)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1454 regression — W017 must not fire for the active worktree
// ─────────────────────────────────────────────────────────────────────────────
describe('validate health — #1454 W017 excludes active worktree', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// The active-worktree exclusion guard in the SUT (src/verify.cts) compares
// process.cwd() against each stale-finding path at runtime. The integration
// scenario below covers the observable CLI contract; the unit-level injection
// path is omitted here because bin/lib/verify.cjs is a gitignored tsc artifact
// not present in a fresh worktree.
test('validate health completes without W017 for the cwd itself when inspected as worktree root', () => {
// Set up a minimal healthy project at tmpDir
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
// Run with cwd = tmpDir. The SUT will call process.cwd() which is the test runner's cwd,
// not tmpDir, so any stale worktree that matches tmpDir (as a non-cwd) CAN legitimately
// be flagged. The guard only protects the CURRENT process.cwd().
// What we assert: when there is no real git repo at tmpDir, no W017 fires (git worktree
// list will fail / return empty — the try/catch swallows it silently).
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W017'),
`W017 should not fire for a non-git project dir: ${JSON.stringify(output.warnings)}`
);
});
});

View File

@@ -19,12 +19,12 @@
"discovery-phase.md": 8651,
"discuss-phase-assumptions.md": 26984,
"discuss-phase-power.md": 11273,
"discuss-phase.md": 31965,
"discuss-phase.md": 31990,
"do.md": 10068,
"docs-update.md": 55662,
"edit-phase.md": 12883,
"eval-review.md": 9923,
"execute-phase.md": 93157,
"execute-phase.md": 92851,
"execute-plan.md": 31365,
"explore.md": 10497,
"extract-learnings.md": 12849,

View File

@@ -102,7 +102,7 @@ const DEFAULT_CAP = 40960; // 40 KiB
const NEW_FILE_CAP = 32768; // 32 KiB
// Top-level orchestrators that own end-to-end multi-phase rubrics.
// Grandfathered at current sizes — see PR #2551 for the progressive-disclosure
// Grandfathered at current sizes — see the discuss-phase/modes split (#717) for the progressive-disclosure
// pattern that future shrinks should follow. Byte counts noted for reference.
const XL_WORKFLOWS = new Set([
'execute-phase', // 92880 bytes (grew in #381 CLAUDE_ENV_FILE persist clause)
@@ -210,8 +210,8 @@ describe('SIZE: per-file workflow baseline (issue #1074)', () => {
});
});
describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => {
// Issue #2551 targets discuss-phase.md as a thin dispatcher, separate from
describe('SIZE: discuss-phase progressive disclosure (#717 byte budget)', () => {
// The discuss-phase progressive-disclosure split (#717) targets discuss-phase.md as a thin dispatcher, separate from
// the per-tier grandfathered budgets above. Originally expressed as <500
// lines; re-based to bytes for #717 (500 lines ≈ 28 KB at these files'
// density; set to 30 KB to preserve the thin-dispatcher intent with modest
@@ -221,12 +221,12 @@ describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => {
// Target raised from 30000 to 32000 in #891 (launcher shim expansion added 17 runtime home arms,
// adding ~960 bytes to the preamble; the thin-dispatcher intent is preserved — actual=30935).
const DISCUSS_PHASE_TARGET = 32000;
test(`discuss-phase.md is under ${DISCUSS_PHASE_TARGET} bytes (issue #2551 target)`, () => {
test(`discuss-phase.md is under ${DISCUSS_PHASE_TARGET} bytes (#717 byte budget)`, () => {
const filePath = path.join(WORKFLOWS_DIR, 'discuss-phase.md');
const bytes = byteCount(filePath);
assert.ok(
bytes < DISCUSS_PHASE_TARGET,
`discuss-phase.md is ${bytes} bytes — must be under ${DISCUSS_PHASE_TARGET} per #2551. ` +
`discuss-phase.md is ${bytes} bytes — must be under ${DISCUSS_PHASE_TARGET} per #717. ` +
`Per-mode logic belongs in workflows/discuss-phase/modes/<mode>.md, ` +
`templates in workflows/discuss-phase/templates/.`
);