Merge branch 'next' into fix/1477-surface-source-marker
This commit is contained in:
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal file
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal 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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
118
tests/enh-1494-workflow-config-key-docs.test.cjs
Normal file
118
tests/enh-1494-workflow-config-key-docs.test.cjs
Normal 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)`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
166
tests/fix-1369-wave-stale-base.test.cjs
Normal file
166
tests/fix-1369-wave-stale-base.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
125
tests/fix-1437-phase-list-plans.test.cjs
Normal file
125
tests/fix-1437-phase-list-plans.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
174
tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs
Normal file
174
tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs
Normal 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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
156
tests/fix-1446-total-phases-corrects-downward.test.cjs
Normal file
156
tests/fix-1446-total-phases-corrects-downward.test.cjs
Normal 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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
269
tests/fix-1464-docs-manifest-validation.test.cjs
Normal file
269
tests/fix-1464-docs-manifest-validation.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
58
tests/no-phantom-issue-refs.test.cjs
Normal file
58
tests/no-phantom-issue-refs.test.cjs
Normal 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'),
|
||||
);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/.`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user