* test(#3335): fold the workflow-content & phase-lifecycle fix-* cluster — Wave 3 Folds 13 legacy fix-*.test.cjs regression files (131 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053): - 6 files with no prior target coverage: renamed (git mv) into new suites (spike-manifest-scoping, ship-note, add-todo, workflow-jq-dependency, resolve-execution-dynamic-routing, clock) - 7 files merged into 5 pre-existing suites (worktree-base-ref x2, model-resolver, phase-locator x2, frontmatter, verification-status), deduplicated against existing coverage Zero net test-coverage loss: every source assertion preserved or verified as a genuine pre-existing duplicate. No production code changed. Last fix-* wave (Wave 1 #3341, Wave 2 #3342 already merged); 4 issue-* waves remain in #3315. * test(#3335): fix orthogonal-review findings — Wave 3 fold Standards-axis review + Memtrace graph pass found real defects in the just-folded suites, all fixed here: - phase-locator.test.cjs: pinned an unseeded fast-check property test (CONTRIBUTING.md determinism requirement), matching the sibling test's seed:7 convention. - phase-locator.test.cjs: added assert.ok() presence guards after 9 data.plans.find() calls that were dereferenced unguarded, inconsistent with 5 sibling tests in the same file that already guard correctly. Latent robustness gap — an omitted plan would throw an opaque TypeError instead of a clear assertion failure. - Standardized the fold-wrapper convention (block-scoped __foldDescribe) across worktree-base-ref.test.cjs, verification-status.test.cjs, and phase-locator.test.cjs to match the pattern already established in frontmatter.test.cjs and model-resolver.test.cjs from earlier folds. - worktree-base-ref.test.cjs: moved a mid-file require to the top-of-file require block. - Eliminated duplicated env-isolation helpers: model-resolver.test.cjs and phase-locator.test.cjs each reimplemented GSD_WORKSTREAM/GSD_PROJECT save-restore independently; factored a shared isolateWorkstreamEnv()/ restoreWorkstreamEnv() into tests/helpers.cjs and pointed both call sites at it. No test() count changed in any file. No production code touched. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product #1941
|
||||
// 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 #1941: /gsd-quick worktree executor forks from a stale base —
|
||||
* up to many commits behind, not just the one-commit gap #1265 already covers.
|
||||
*
|
||||
* Root cause: Claude Code's isolation="worktree" forks new worktrees from origin/HEAD, not
|
||||
* the live local HEAD. When prior local commits (e.g. earlier quick tasks in the same
|
||||
* session, or this task's own Step 5.6 pre-dispatch plan commit) advance local HEAD without
|
||||
* an intervening `git push`, origin/HEAD stays pinned to a stale ancestor and the executor's
|
||||
* worktree_branch_check guard halts with a base-mismatch fatal. The fix ports the
|
||||
* worktree.base-check auto-degrade pattern already used by execute-phase (#683/#1369) into
|
||||
* quick.md's single-dispatch path.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('quick: pre-dispatch worktree base re-check (#1941)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/quick.md should exist');
|
||||
});
|
||||
|
||||
test('Step 6 runs worktree.base-check before capturing EXPECTED_BASE', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const step6Idx = content.indexOf('**Step 6: Spawn executor**');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check', step6Idx);
|
||||
const expectedBaseIdx = content.indexOf('EXPECTED_BASE=$(git rev-parse HEAD)', step6Idx);
|
||||
assert.ok(step6Idx !== -1, '"Step 6: Spawn executor" must exist in quick.md');
|
||||
assert.ok(baseCheckIdx !== -1, 'worktree.base-check must be invoked within Step 6');
|
||||
assert.ok(expectedBaseIdx !== -1, 'EXPECTED_BASE capture must exist within Step 6');
|
||||
assert.ok(
|
||||
baseCheckIdx < expectedBaseIdx,
|
||||
'worktree.base-check must run BEFORE EXPECTED_BASE is captured so the degrade decision reflects the most current local HEAD'
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check references #1941 for traceability', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1941'), 'quick.md must reference #1941');
|
||||
});
|
||||
|
||||
test('degrade check clears BOTH USE_WORKTREES and ISOLATION when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 900);
|
||||
assert.ok(block.includes('shouldDegrade'), 'degrade check must branch on shouldDegrade');
|
||||
// Both must move together (#2652). Dispatch keys on ISOLATION while the prompt
|
||||
// guard and worktree manifest key on USE_WORKTREES; clearing only one dispatches
|
||||
// an isolated executor with no base guard and no manifest, then blocks in cleanup
|
||||
// looking for a manifest that was never initialized.
|
||||
assert.ok(block.includes('USE_WORKTREES=false'), 'degrade must set USE_WORKTREES=false');
|
||||
assert.ok(
|
||||
block.includes('ISOLATION=none'),
|
||||
'degrade must ALSO set ISOLATION=none — dispatch reads ISOLATION, so clearing only ' +
|
||||
'USE_WORKTREES still passes the harness isolation flag (#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
// #2652: this assertion previously required `RUNTIME = "claude"`, encoding the
|
||||
// pre-#2584 premise that worktree isolation is Claude-specific. #2584 replaced
|
||||
// that with the negotiated dispatch.isolation capability, so the guard now keys
|
||||
// on the capability — Cursor also declares harness-worktree.
|
||||
test('degrade check guards on the negotiated capability, not a runtime id', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(Math.max(0, baseCheckIdx - 300), baseCheckIdx + 200);
|
||||
assert.ok(
|
||||
block.includes('ISOLATION') && block.includes('harness-worktree'),
|
||||
'degrade check must guard on ISOLATION = harness-worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!/\[\s*"\$RUNTIME"\s*=/.test(block),
|
||||
'degrade check must NOT branch on a RUNTIME literal (#2584/#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check names origin/HEAD as the stale fork base', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const step6Idx = content.indexOf('**Step 6: Spawn executor**');
|
||||
const nextSection = content.indexOf('\n---', step6Idx);
|
||||
const section = content.slice(step6Idx, nextSection === -1 ? undefined : nextSection);
|
||||
assert.ok(section.includes('origin/HEAD'), 'Step 6 must name origin/HEAD as the stale fork base');
|
||||
});
|
||||
});
|
||||
@@ -1,411 +0,0 @@
|
||||
/**
|
||||
* Bug #2297 — `resolve_model_ids:"omit"` must be scoped to the ACTIVE runtime,
|
||||
* not applied blindly whenever it appears anywhere in the merged config.
|
||||
*
|
||||
* Root cause (pre-fix): the installer writes `resolve_model_ids:"omit"` into the
|
||||
* SHARED `~/.gsd/defaults.json` for every runtime that lacks native model
|
||||
* aliases (#1156). Because that file is machine-wide, installing a non-Claude
|
||||
* runtime (e.g. codex) on a box that also runs Claude poisoned Claude's
|
||||
* no-project resolution: Claude would see `resolve_model_ids:"omit"` in the
|
||||
* merged global defaults and return `''` instead of its tier aliases
|
||||
* (opus/sonnet/haiku), silently defeating Claude's adaptive tier distinction.
|
||||
*
|
||||
* Fix (`src/model-resolver.cts` `resolveModelInternal`): the `"omit"` branch
|
||||
* now returns `''` ONLY when either
|
||||
* (a) the PROJECT's own `.planning/config.json` explicitly sets
|
||||
* `resolve_model_ids:"omit"` (user intent — #2517 finding #4, unchanged), OR
|
||||
* (b) the ACTIVE runtime genuinely lacks native model aliases.
|
||||
* A native-alias runtime (currently only `claude`) IGNORES an `"omit"` that
|
||||
* came solely from the shared global defaults and falls through to its tier
|
||||
* aliases. Active-runtime precedence: `process.env.GSD_RUNTIME` -> `config.runtime`
|
||||
* -> per-install `.gsd-runtime` marker (absent in this dev/test tree, so the
|
||||
* chain always bottoms out at `'claude'`) -> `'claude'` (all canonicalized).
|
||||
*
|
||||
* IMPORTANT (empirically verified — see dispatch report): the global-defaults
|
||||
* merge path in `config-loader.cjs` (branch D: "no .planning/ at all") is ONLY
|
||||
* exercised when the project directory has NO `.planning/` directory whatsoever.
|
||||
* The moment a `.planning/` directory exists — even with an empty or absent
|
||||
* `config.json` inside it — the loader takes a different branch that does NOT
|
||||
* merge `~/.gsd/defaults.json` for these fields at all. So Group A below
|
||||
* (which specifically exercises the global-defaults poisoning fix) uses BARE
|
||||
* `fs.mkdtempSync` project dirs with no `.planning/` subdirectory. Group A #4,
|
||||
* Group B, and Group C all need a real per-project config, so those DO create
|
||||
* `.planning/config.json`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
resolveModelInternal,
|
||||
_setInstallRuntimeMarkerForTests,
|
||||
_resetInstallRuntimeMarkerCacheForTests,
|
||||
} = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||
|
||||
// ─── HOME / GSD_HOME / GSD_RUNTIME isolation ────────────────────────────────
|
||||
// config-loader.cjs reads global defaults from
|
||||
// path.join(process.env.GSD_HOME || os.homedir(), '.gsd', 'defaults.json').
|
||||
// Isolate both HOME and GSD_HOME to a fresh tmpdir per test (so a developer's
|
||||
// real ~/.gsd/defaults.json never bleeds into assertions), and save/restore
|
||||
// GSD_RUNTIME since several tests set it directly to drive the active-runtime
|
||||
// chain (#2297's second precedence rung). Also save/restore GSD_WORKSTREAM and
|
||||
// GSD_PROJECT (#2297 correctness-review hermeticity gap): planningDir() reads
|
||||
// both directly from process.env when its ws/project params are omitted, so an
|
||||
// ambient GSD_WORKSTREAM/GSD_PROJECT in a developer's shell could silently
|
||||
// redirect projectExplicitlySetsOmit()'s config-file reads to the wrong layer.
|
||||
let _origHome;
|
||||
let _origUserProfile;
|
||||
let _origGsdHome;
|
||||
let _origGsdRuntime;
|
||||
let _origGsdWorkstream;
|
||||
let _origGsdProject;
|
||||
let _isolatedHome;
|
||||
|
||||
function isolateHome() {
|
||||
_origHome = process.env.HOME;
|
||||
_origUserProfile = process.env.USERPROFILE;
|
||||
_origGsdHome = process.env.GSD_HOME;
|
||||
_origGsdRuntime = process.env.GSD_RUNTIME;
|
||||
_origGsdWorkstream = process.env.GSD_WORKSTREAM;
|
||||
_origGsdProject = process.env.GSD_PROJECT;
|
||||
_isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-home-'));
|
||||
process.env.HOME = _isolatedHome;
|
||||
// Windows resolves the home dir from USERPROFILE, not HOME — set both so the
|
||||
// isolation holds cross-platform (local/require-userprofile-with-home).
|
||||
process.env.USERPROFILE = _isolatedHome;
|
||||
process.env.GSD_HOME = _isolatedHome;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
}
|
||||
|
||||
function restoreHome() {
|
||||
if (_origHome === undefined) delete process.env.HOME; else process.env.HOME = _origHome;
|
||||
if (_origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = _origUserProfile;
|
||||
if (_origGsdHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = _origGsdHome;
|
||||
if (_origGsdRuntime === undefined) delete process.env.GSD_RUNTIME; else process.env.GSD_RUNTIME = _origGsdRuntime;
|
||||
if (_origGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM; else process.env.GSD_WORKSTREAM = _origGsdWorkstream;
|
||||
if (_origGsdProject === undefined) delete process.env.GSD_PROJECT; else process.env.GSD_PROJECT = _origGsdProject;
|
||||
rmDir(_isolatedHome);
|
||||
_isolatedHome = null;
|
||||
}
|
||||
|
||||
function rmDir(dir) {
|
||||
if (typeof dir !== 'string' || dir.length === 0) return;
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- carries the same maxRetries/retryDelay budget as helpers.cleanup; used for both the isolated-home and bare project temp dirs
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||
}
|
||||
|
||||
function writeGlobalDefaults(obj) {
|
||||
fs.mkdirSync(path.join(_isolatedHome, '.gsd'), { recursive: true });
|
||||
fs.writeFileSync(path.join(_isolatedHome, '.gsd', 'defaults.json'), JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// Bare project dir with NO .planning/ subdirectory — needed to exercise the
|
||||
// config-loader's global-defaults merge branch (see file header).
|
||||
function mkProjNoPlanning() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-noplan-'));
|
||||
}
|
||||
|
||||
// Project dir WITH a .planning/config.json — the normal "inside a project" path.
|
||||
function mkProjWithConfig(obj) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(obj, null, 2));
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── Group A: GLOBAL-defaults "omit" is runtime-scoped (the #2297 fix) ─────
|
||||
describe('#2297: global-defaults resolve_model_ids:"omit" is scoped to the active runtime', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('no runtime signal defaults to claude: executor and planner get distinct non-empty tier aliases (acceptance #3)', () => {
|
||||
// Global defaults poison the shared file with "omit" (simulating a
|
||||
// non-Claude runtime having been installed on this machine). With no
|
||||
// .planning/config.json (no project) and no GSD_RUNTIME, the active
|
||||
// runtime falls back to 'claude', which has native aliases and must
|
||||
// ignore the poisoned global "omit" — the adaptive tier distinction
|
||||
// between executor (sonnet) and planner (opus) must survive.
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
|
||||
const executor = resolveModelInternal(projDir, 'gsd-executor');
|
||||
const planner = resolveModelInternal(projDir, 'gsd-planner');
|
||||
|
||||
assert.strictEqual(executor, 'sonnet');
|
||||
assert.strictEqual(planner, 'opus');
|
||||
assert.notStrictEqual(executor, '');
|
||||
assert.notStrictEqual(planner, '');
|
||||
assert.notStrictEqual(executor, planner);
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="claude" explicitly: executor still resolves to "sonnet" (claude ignores global omit)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'claude';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="codex": a non-alias runtime still honors the global omit (acceptance #4)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
});
|
||||
|
||||
// #2297 correctness-review BLOCKER: resolveActiveRuntime() must canonicalize
|
||||
// its candidates via resolveRuntimeNameFromCandidates before checking
|
||||
// RUNTIMES_WITH_NATIVE_ALIASES, or an alias/case variant of "claude" would
|
||||
// fail the Set('claude').has() check and wrongly fall through to honoring the
|
||||
// poisoned global omit. These would FAIL against a non-canonicalizing resolver.
|
||||
test('GSD_RUNTIME="claude-code" (alias, not canonical "claude"): executor and planner still ignore the global omit', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'claude-code';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), 'opus');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="Claude" (case variant): executor still resolves to "sonnet" (canonicalization is case-insensitive)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'Claude';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('project config.runtime="codex" (no resolve_model_ids in project) takes precedence over GSD_RUNTIME/marker in the active-runtime chain', () => {
|
||||
// config.runtime is checked before GSD_RUNTIME / the install marker. This
|
||||
// scenario uses a REAL project (.planning/config.json present), so the
|
||||
// config-loader does NOT merge ~/.gsd/defaults.json for resolve_model_ids
|
||||
// at all here (see file header) — resolution instead reaches the #2517
|
||||
// runtime-tier path (step 3 in resolveModelInternal, which fires before
|
||||
// the omit gate) and returns codex's native sonnet-tier model id directly,
|
||||
// rather than the omit gate's ''. Verified empirically: the built resolver
|
||||
// returns 'gpt-5.6-terra', not ''. Assert it is non-empty and NOT a claude
|
||||
// alias, which is the property this test actually needs to guarantee
|
||||
// (config.runtime, not GSD_RUNTIME/env, drove the resolution).
|
||||
projDir = mkProjWithConfig({ runtime: 'codex' });
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' }); // irrelevant: not merged when .planning/ exists
|
||||
|
||||
const result = resolveModelInternal(projDir, 'gsd-executor');
|
||||
assert.notStrictEqual(result, '');
|
||||
assert.ok(
|
||||
!['sonnet', 'opus', 'haiku'].includes(result),
|
||||
`expected a non-claude-alias result for config.runtime="codex", got ${JSON.stringify(result)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('install-order independence (acceptance #1/#2): a global omit poisoned by a prior non-Claude install does not affect Claude resolution, and Claude retains its adaptive tier distinction', () => {
|
||||
// Resolution depends on the RESOLVING runtime (active runtime at call
|
||||
// time), not on install order — installing codex (or any non-alias
|
||||
// runtime) before/after Claude must never change what Claude itself
|
||||
// resolves to. Global omit present, no project, no runtime signal ->
|
||||
// default 'claude' -> tier aliases survive. Distinct from the first Group A
|
||||
// test above: this asserts install-order independence AND, specifically,
|
||||
// that executor/planner remain DIFFERENT tiers under the poisoned global
|
||||
// omit — i.e. install order never collapses Claude's adaptive tier
|
||||
// distinction into a single omitted value.
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
|
||||
const executor = resolveModelInternal(projDir, 'gsd-executor');
|
||||
const planner = resolveModelInternal(projDir, 'gsd-planner');
|
||||
|
||||
assert.strictEqual(executor, 'sonnet');
|
||||
assert.strictEqual(planner, 'opus');
|
||||
assert.notStrictEqual(executor, planner, 'install-order poisoning must not collapse the adaptive tier distinction');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group B: explicit PROJECT "omit" is still honored for EVERY runtime ───
|
||||
// (#2517 finding #4 — preserved, NOT changed by #2297.)
|
||||
describe('#2297: explicit project-level resolve_model_ids:"omit" is honored regardless of runtime', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('no runtime set, explicit project omit -> "" even though the default runtime is claude', () => {
|
||||
projDir = mkProjWithConfig({ resolve_model_ids: 'omit' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
|
||||
test('runtime:"claude" + explicit project omit -> "" (mirrors #2517 finding #4)', () => {
|
||||
projDir = mkProjWithConfig({ runtime: 'claude', resolve_model_ids: 'omit' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group B2: projectExplicitlySetsOmit is workstream-scope aware (#2297) ──
|
||||
// The root .planning/config.json does NOT set resolve_model_ids, but the
|
||||
// ACTIVE workstream's own config.json does — projectExplicitlySetsOmit()
|
||||
// resolves via planningDir(cwd) (workstream layer wins over root, mirroring
|
||||
// loadConfig's precedence), so the workstream's explicit "omit" must still be
|
||||
// honored even though no global default and the default runtime (claude) would
|
||||
// otherwise have returned a tier alias.
|
||||
describe('#2297: explicit project-level "omit" is honored at the active-workstream config layer', () => {
|
||||
let projDir;
|
||||
let _origGsdWorkstreamForBlock;
|
||||
beforeEach(() => {
|
||||
isolateHome(); // clears GSD_WORKSTREAM/GSD_PROJECT as part of hermeticity
|
||||
projDir = null;
|
||||
_origGsdWorkstreamForBlock = process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (_origGsdWorkstreamForBlock === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = _origGsdWorkstreamForBlock;
|
||||
rmDir(projDir);
|
||||
restoreHome();
|
||||
});
|
||||
|
||||
test('root config has no resolve_model_ids, but the active workstream config sets "omit" -> "" despite default runtime claude', () => {
|
||||
const ws = 'ws-alpha';
|
||||
projDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-ws-'));
|
||||
fs.mkdirSync(path.join(projDir, '.planning'), { recursive: true });
|
||||
// Root config exists but does NOT set resolve_model_ids at all.
|
||||
fs.writeFileSync(
|
||||
path.join(projDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced' }, null, 2)
|
||||
);
|
||||
// The active workstream's own config explicitly sets "omit".
|
||||
const wsConfigDir = path.join(projDir, '.planning', 'workstreams', ws);
|
||||
fs.mkdirSync(wsConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wsConfigDir, 'config.json'),
|
||||
JSON.stringify({ resolve_model_ids: 'omit' }, null, 2)
|
||||
);
|
||||
process.env.GSD_WORKSTREAM = ws;
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group C: explicit `true` still materializes full model ids (acceptance #5) ──
|
||||
describe('#2297: resolve_model_ids:true still materializes full Claude model ids', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('resolve_model_ids:true + balanced profile -> full materialized claude-opus-4-8 id', () => {
|
||||
projDir = mkProjWithConfig({ resolve_model_ids: true, model_profile: 'balanced' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group D: registry parity guard ─────────────────────────────────────────
|
||||
describe('#2297: capability-registry nativeModelAliases parity guard', () => {
|
||||
test('exactly the runtimes with hostBehaviors.nativeModelAliases:true match RUNTIMES_WITH_NATIVE_ALIASES ([\'claude\'])', () => {
|
||||
// The model-resolver hardcodes RUNTIMES_WITH_NATIVE_ALIASES = new Set(['claude'])
|
||||
// rather than reading the registry at runtime. This test keeps that
|
||||
// hardcoded set honest against the generated registry's actual contract:
|
||||
// registry.runtimes[id].runtime.hostBehaviors.nativeModelAliases.
|
||||
// If a future runtime gains nativeModelAliases:true, this fails loudly so
|
||||
// RUNTIMES_WITH_NATIVE_ALIASES in model-resolver.cts is updated in lockstep.
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const nativeAliasRuntimes = Object.keys(registry.runtimes)
|
||||
.filter((id) => registry.runtimes[id]?.runtime?.hostBehaviors?.nativeModelAliases === true)
|
||||
.sort();
|
||||
|
||||
assert.deepStrictEqual(nativeAliasRuntimes, ['claude']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group E: installer writes the per-install .gsd-runtime marker ─────────
|
||||
describe('#2297: installer emits the gsd-core/.gsd-runtime marker (fixture parity)', () => {
|
||||
test('claude and codex install-tree fixtures both list gsd-core/.gsd-runtime', () => {
|
||||
// These fixtures are flat JSON arrays of install-relative paths, generated
|
||||
// by running the real installer (tests/fixtures/install-tree/*.json). Their
|
||||
// presence here proves the installer actually emits the per-install marker
|
||||
// that resolveActiveRuntime()'s precedence chain falls back to.
|
||||
const claudeFixturePath = path.join(__dirname, 'fixtures', 'install-tree', 'claude.json');
|
||||
const codexFixturePath = path.join(__dirname, 'fixtures', 'install-tree', 'codex.json');
|
||||
|
||||
const claudeFixture = JSON.parse(fs.readFileSync(claudeFixturePath, 'utf8'));
|
||||
const codexFixture = JSON.parse(fs.readFileSync(codexFixturePath, 'utf8'));
|
||||
|
||||
assert.ok(Array.isArray(claudeFixture), 'expected claude.json fixture to be a flat array of paths');
|
||||
assert.ok(Array.isArray(codexFixture), 'expected codex.json fixture to be a flat array of paths');
|
||||
|
||||
assert.ok(
|
||||
claudeFixture.includes('gsd-core/.gsd-runtime'),
|
||||
'expected claude.json install-tree fixture to include gsd-core/.gsd-runtime'
|
||||
);
|
||||
assert.ok(
|
||||
codexFixture.includes('gsd-core/.gsd-runtime'),
|
||||
'expected codex.json install-tree fixture to include gsd-core/.gsd-runtime'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group F: the install-marker precedence rung, driven directly (#2297) ──
|
||||
// Previously untested: with no GSD_RUNTIME and no project config.runtime, the
|
||||
// active runtime falls all the way through to the per-install .gsd-runtime
|
||||
// marker (third precedence rung). The dev/source tree has no real marker file,
|
||||
// so these tests drive that rung directly via the _setInstallRuntimeMarkerForTests
|
||||
// / _resetInstallRuntimeMarkerCacheForTests seams exported specifically for this
|
||||
// purpose (#2297 correctness-review gap).
|
||||
describe('#2297: install-marker precedence rung (GSD_RUNTIME and config.runtime both absent)', () => {
|
||||
let projDir;
|
||||
beforeEach(() => {
|
||||
isolateHome(); // also deletes GSD_RUNTIME
|
||||
projDir = null;
|
||||
// Belt-and-suspenders: the marker rung is only reached when GSD_RUNTIME and
|
||||
// config.runtime are both absent; isolateHome() already deletes GSD_RUNTIME.
|
||||
delete process.env.GSD_RUNTIME;
|
||||
});
|
||||
afterEach(() => {
|
||||
rmDir(projDir);
|
||||
restoreHome();
|
||||
// CRITICAL: reset the module-level marker cache after every case in this
|
||||
// block so a set value never leaks into a later case here, or into any
|
||||
// OTHER describe block in this file (readInstallRuntimeMarker() otherwise
|
||||
// memoizes the first value it sees for the lifetime of the process).
|
||||
_resetInstallRuntimeMarkerCacheForTests();
|
||||
});
|
||||
|
||||
test('marker="codex" (non-alias runtime): honors the poisoned global omit -> ""', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('codex');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
});
|
||||
|
||||
test('marker="claude": ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('claude');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('marker="claude-code" (alias): canonicalized to "claude" and still ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('claude-code');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('marker unset (null): falls through to the "claude" default and ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests(null);
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product #2649
|
||||
// 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. Mirrors tests/fix-1941-quick-worktree-stale-base.test.cjs.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2649: /gsd-verify-work's UAT-gap diagnosis step
|
||||
* (workflows/diagnose-issues.md) and execute-phase's single-plan interactive
|
||||
* dispatch (workflows/execute-plan.md Pattern A) spawn worktree-isolated
|
||||
* subagents without first checking whether the harness's worktree fork base has
|
||||
* diverged from live local HEAD — unlike every other worktree-dispatch site.
|
||||
*
|
||||
* Root cause: Claude Code's isolation="worktree" forks new worktrees from
|
||||
* origin/HEAD, not the live local HEAD. When local commits advance HEAD without
|
||||
* an intervening `git push` (the documented GSD steady state), origin/HEAD is
|
||||
* pinned to a stale ancestor and the subagent's worktree_branch_check guard
|
||||
* halts with a base-mismatch fatal mid-investigation, with no auto-degrade. The
|
||||
* fix ports the worktree.base-check auto-degrade pattern (execute-phase #683/
|
||||
* #1369, quick #1941) into these two not-yet-covered dispatch sites.
|
||||
*
|
||||
* The triage for #2649 found execute-plan.md's Pattern A has the identical gap;
|
||||
* per the bug's acceptance criterion 5 it is fixed in the same change (same bug
|
||||
* class, same one-line gate) rather than filed as a separate follow-up.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIAGNOSE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'diagnose-issues.md');
|
||||
const EXECUTE_PLAN_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
|
||||
|
||||
describe('diagnose-issues: pre-dispatch worktree base-check (#2649)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(DIAGNOSE_PATH), 'workflows/diagnose-issues.md should exist');
|
||||
});
|
||||
|
||||
test('spawn_agents step runs worktree.base-check before the Agent() dispatch', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
||||
const spawnIdx = content.indexOf('<step name="spawn_agents">');
|
||||
assert.ok(spawnIdx !== -1, '"spawn_agents" step must exist in diagnose-issues.md');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check', spawnIdx);
|
||||
assert.ok(baseCheckIdx !== -1, 'worktree.base-check must be invoked within the spawn_agents step');
|
||||
// The load-bearing invariant is "base-check BEFORE the Agent() dispatch" so the
|
||||
// degrade decision can drop isolation from the spawn. (Where EXPECTED_BASE is
|
||||
// captured relative to the check is cosmetic — the check only reads HEAD, never
|
||||
// mutates it — so assert the real invariant, not a loose disjunction.)
|
||||
const agentIdx = content.indexOf('Agent(', spawnIdx);
|
||||
assert.ok(agentIdx !== -1, 'spawn_agents must contain an Agent() dispatch');
|
||||
assert.ok(
|
||||
baseCheckIdx < agentIdx,
|
||||
'worktree.base-check must run before the Agent() dispatch so the degrade decision can drop isolation from the spawn',
|
||||
);
|
||||
});
|
||||
|
||||
test('verify-only worktree_branch_check backstop remains embedded in the Agent() prompt', () => {
|
||||
// Acceptance criterion #4: the base-check is a PRE-DISPATCH degrade; the
|
||||
// <worktree_branch_check> guard is a POST-FORK fail-closed backstop. Both
|
||||
// layers must survive — a future edit that dropped the backstop embedding
|
||||
// would re-open the silent-stale-base class. Guard its continued presence.
|
||||
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
||||
const spawnIdx = content.indexOf('<step name="spawn_agents">');
|
||||
assert.ok(spawnIdx !== -1, '"spawn_agents" step must exist');
|
||||
assert.ok(
|
||||
content.indexOf('worktree-branch-check.md', spawnIdx) !== -1,
|
||||
'spawn_agents must still materialize the <worktree_branch_check> backstop after the base-check gate (#2649 acceptance criterion 4)',
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 600);
|
||||
assert.ok(
|
||||
block.includes('shouldDegrade') && block.includes('USE_WORKTREES=false'),
|
||||
'degrade check must override USE_WORKTREES=false when shouldDegrade is true',
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check references #2649 for traceability', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#2649'), 'diagnose-issues.md must reference #2649');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-plan Pattern A: pre-dispatch worktree base-check (#2649)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(EXECUTE_PLAN_PATH), 'workflows/execute-plan.md should exist');
|
||||
});
|
||||
|
||||
test('Pattern A runs the worktree base-check before spawning the executor', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const patternAIdx = content.indexOf('**Pattern A:**');
|
||||
assert.ok(patternAIdx !== -1, '"Pattern A:" must exist in execute-plan.md');
|
||||
// The base-check instruction must appear within the Pattern A description,
|
||||
// before the isolation="worktree" embedding instruction.
|
||||
const patternAEnd = content.indexOf('**Pattern B:**', patternAIdx);
|
||||
const patternA = content.slice(patternAIdx, patternAEnd === -1 ? undefined : patternAEnd);
|
||||
assert.ok(
|
||||
patternA.includes('#2649') && /worktree\.base-check|base-check/.test(patternA),
|
||||
'Pattern A must run the #2649 worktree base-check before dispatching the executor',
|
||||
);
|
||||
assert.ok(
|
||||
patternA.includes('shouldDegrade'),
|
||||
'Pattern A base-check must consult shouldDegrade',
|
||||
);
|
||||
});
|
||||
|
||||
test('Pattern A documents the auto-degrade (drop isolation on shouldDegrade)', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const patternAIdx = content.indexOf('**Pattern A:**');
|
||||
const patternAEnd = content.indexOf('**Pattern B:**', patternAIdx);
|
||||
const patternA = content.slice(patternAIdx, patternAEnd === -1 ? undefined : patternAEnd);
|
||||
assert.ok(
|
||||
/degrad|sequential/i.test(patternA),
|
||||
'Pattern A must document auto-degrading to sequential mode when shouldDegrade is true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,764 +0,0 @@
|
||||
/**
|
||||
* Regression tests for #2830: a halted plan leaves its dependents on the
|
||||
* runnable work list.
|
||||
*
|
||||
* Two independent "which plans are incomplete" readers exist:
|
||||
* - phase.cts's cmdPhasePlanIndex (`gsd-tools phase-plan-index`) — parses
|
||||
* depends_on for wave assignment, but (pre-fix) never propagates a halt.
|
||||
* - phase-locator.cts's searchPhaseInDir/findPhaseInternal — the
|
||||
* phase-location primitive consumed by ~50 symbols across 5 command
|
||||
* routers; (pre-fix) never parsed depends_on at all.
|
||||
*
|
||||
* Both must now report a direct or transitive dependent of a halted plan as
|
||||
* blocked — never offered as ordinary runnable work — while leaving the
|
||||
* pre-existing `incomplete`/`incomplete_plans` fields byte-identical.
|
||||
*
|
||||
* Every assertion below is behavioral (structured JSON from the real CLI /
|
||||
* the real compiled module) — no source-grep or raw-text matching, so no
|
||||
* `allow-test-rule` exemption is needed anywhere in this file.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
|
||||
const planDependencyGraph = require('../gsd-core/bin/lib/plan-dependency-graph.cjs');
|
||||
|
||||
// ─── Fixture builder ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Builds a phase directory with:
|
||||
// 01-01 — halted spike (SUMMARY status: halted)
|
||||
// 01-02 — depends_on 01-01 (direct dependent)
|
||||
// 01-03 — depends_on 01-02 (transitive dependent, 2 hops)
|
||||
// 01-04 — decoupled (no depends_on) — the negative case
|
||||
// Callers can pass extra plan/summary writers for diamond/boundary variants.
|
||||
|
||||
function writePlan(phaseDir, filename, frontmatterLines, taskLine = '<task>Work</task>') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
[
|
||||
'---',
|
||||
...frontmatterLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${filename}`,
|
||||
'',
|
||||
`<objective>${filename}</objective>`,
|
||||
'',
|
||||
taskLine,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSummary(phaseDir, filename, status = 'complete') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
['---', 'phase: 01-alpha', 'plan: 01', `status: ${status}`, 'completed: 2026-08-02', '---', '', '# Summary', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseFixture(tmpDir) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '01-01-PLAN.md', ['wave: 1', 'objective: Halted spike', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '01-01-SUMMARY.md', 'halted');
|
||||
|
||||
writePlan(phaseDir, '01-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Direct dependent', 'autonomous: true', 'depends_on:', ' - 01-01',
|
||||
]);
|
||||
|
||||
writePlan(phaseDir, '01-03-PLAN.md', [
|
||||
'wave: 3', 'objective: Transitive dependent', 'autonomous: true', 'depends_on:', ' - 01-02',
|
||||
]);
|
||||
|
||||
writePlan(phaseDir, '01-04-PLAN.md', ['wave: 1', 'objective: Decoupled plan', 'autonomous: true']);
|
||||
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
// ─── phase-plan-index (cmdPhasePlanIndex, src/phase.cts) ──────────────────
|
||||
|
||||
describe('phase-plan-index: halt propagation (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('direct dependent of a halted plan is blocked, not runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p02 = data.plans.find((p) => p.id === '01-02');
|
||||
assert.ok(p02, '01-02 should be present');
|
||||
assert.deepEqual(p02.blocked_by, ['01-01'], '01-02 should be blocked by the halted 01-01');
|
||||
assert.strictEqual(p02.has_summary, false);
|
||||
assert.ok(!data.runnable.includes('01-02'), '01-02 must NOT be in the runnable view');
|
||||
});
|
||||
|
||||
test('transitive dependent (2 hops) is blocked via chain', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p03 = data.plans.find((p) => p.id === '01-03');
|
||||
assert.ok(p03, '01-03 should be present');
|
||||
assert.deepEqual(p03.blocked_by, ['01-01'], '01-03 should be transitively blocked by 01-01');
|
||||
assert.ok(!data.runnable.includes('01-03'), '01-03 must NOT be in the runnable view');
|
||||
});
|
||||
|
||||
test('transitive dependent at 3 hops stays blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = buildBaseFixture(tmpDir);
|
||||
writePlan(phaseDir, '01-05-PLAN.md', [
|
||||
'wave: 4', 'objective: 3-hop dependent', 'autonomous: true', 'depends_on:', ' - 01-03',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p05 = data.plans.find((p) => p.id === '01-05');
|
||||
assert.ok(p05, '01-05 should be present');
|
||||
assert.deepEqual(p05.blocked_by, ['01-01'], '01-05 should stay blocked at 3 hops');
|
||||
assert.ok(!data.runnable.includes('01-05'));
|
||||
});
|
||||
|
||||
test('diamond dependency is blocked by both halted ancestors', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-diamond');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '02-01-PLAN.md', ['wave: 1', 'objective: Halted A', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-02-PLAN.md', ['wave: 1', 'objective: Halted B', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-02-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Diamond join', 'autonomous: true',
|
||||
'depends_on:', ' - 02-01', ' - 02-02',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '2', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p03 = data.plans.find((p) => p.id === '02-03');
|
||||
assert.ok(p03, '02-03 should be present');
|
||||
assert.deepEqual(
|
||||
[...p03.blocked_by].sort(),
|
||||
['02-01', '02-02'],
|
||||
'02-03 should be blocked by BOTH halted ancestors, deduplicated',
|
||||
);
|
||||
});
|
||||
|
||||
test('unrelated decoupled plan stays runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p04 = data.plans.find((p) => p.id === '01-04');
|
||||
assert.ok(p04, '01-04 should be present');
|
||||
assert.deepEqual(p04.blocked_by, [], '01-04 has no depends_on, so it must not be blocked');
|
||||
assert.ok(data.runnable.includes('01-04'), '01-04 (decoupled) must stay in the runnable view');
|
||||
});
|
||||
|
||||
test('incomplete field stays byte-identical when blocked plans are present', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
// Pre-#2830 semantics: incomplete = every plan without a matching SUMMARY,
|
||||
// blocked or not. 01-01 has a SUMMARY (halted, but still a SUMMARY) so it
|
||||
// is excluded; 01-02/01-03/01-04 have none, so all three are included —
|
||||
// exactly as they would be with no halt-awareness at all.
|
||||
assert.deepEqual(
|
||||
[...data.incomplete].sort(),
|
||||
['01-02', '01-03', '01-04'],
|
||||
'incomplete must list every no-SUMMARY plan regardless of blocked status',
|
||||
);
|
||||
});
|
||||
|
||||
test('dependency on an ordinary incomplete (non-halted) plan is not "blocked"', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-ordinary');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
// 03-01 has NO summary at all (ordinary incomplete, not halted).
|
||||
writePlan(phaseDir, '03-01-PLAN.md', ['wave: 1', 'objective: Ordinary unfinished plan', 'autonomous: true']);
|
||||
writePlan(phaseDir, '03-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on ordinary incomplete plan', 'autonomous: true',
|
||||
'depends_on:', ' - 03-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '3', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '03-01');
|
||||
const p02 = data.plans.find((p) => p.id === '03-02');
|
||||
assert.deepEqual(p01.blocked_by, [], '03-01 (no summary) is not itself halted or blocked');
|
||||
assert.deepEqual(p02.blocked_by, [], '03-02 must NOT be "blocked" by an ordinary (non-halted) dependency');
|
||||
assert.ok(data.runnable.includes('03-02'), '03-02 stays runnable — only a halted upstream blocks');
|
||||
});
|
||||
|
||||
test('unresolved depends_on id is ignored, not blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-unresolved');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '04-01-PLAN.md', [
|
||||
'wave: 1', 'objective: References a nonexistent plan', 'autonomous: true',
|
||||
'depends_on:', ' - 99-99',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '4', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should not throw on an unresolved dependency: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '04-01');
|
||||
assert.deepEqual(p01.blocked_by, [], 'an unresolved depends_on id must not produce a spurious block');
|
||||
assert.ok(data.runnable.includes('04-01'));
|
||||
});
|
||||
|
||||
test('malformed (unterminated) SUMMARY frontmatter fails open to not-halted', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-malformed');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '05-01-PLAN.md', ['wave: 1', 'objective: Has a malformed summary', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '05-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '05-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on 05-01', 'autonomous: true', 'depends_on:', ' - 05-01',
|
||||
]);
|
||||
|
||||
// extractFrontmatter must fail safe on an unterminated frontmatter block
|
||||
// (no closing '---'): isSummaryHalted's try/catch wraps BOTH the
|
||||
// fs.readFileSync call and the extractFrontmatter call, so this exercises
|
||||
// the identical fail-open catch site a genuine fs read error would hit —
|
||||
// see the separate real-fs-fault-injection test below for the read-error
|
||||
// half of that same catch block.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '05-01-SUMMARY.md'),
|
||||
'---\nphase: 05-malformed\nplan: 01\nstatus: halted\n', // no closing '---'
|
||||
);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '5', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index must not throw on malformed frontmatter: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '05-01');
|
||||
const p02 = data.plans.find((p) => p.id === '05-02');
|
||||
assert.strictEqual(p01.halted, false, 'unterminated frontmatter must fail open to not-halted');
|
||||
assert.deepEqual(p02.blocked_by, [], 'dependent of a fail-open-not-halted plan must not be blocked');
|
||||
});
|
||||
|
||||
test('CRLF SUMMARY frontmatter still detects status: halted', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '06-crlf');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '06-01-PLAN.md', ['wave: 1', 'objective: Halted with CRLF summary', 'autonomous: true']);
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '06-01-SUMMARY.md'),
|
||||
['---', 'phase: 06-crlf', 'plan: 01', 'status: halted', 'completed: 2026-08-02', '---', '', '# Summary', ''].join('\r\n'),
|
||||
);
|
||||
writePlan(phaseDir, '06-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on CRLF-summarized halt', 'autonomous: true', 'depends_on:', ' - 06-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '6', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed on CRLF frontmatter: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '06-01');
|
||||
const p02 = data.plans.find((p) => p.id === '06-02');
|
||||
assert.strictEqual(p01.halted, true, 'CRLF SUMMARY frontmatter must still parse status: halted');
|
||||
assert.deepEqual(p02.blocked_by, ['06-01'], 'dependent must be blocked even when the halt was recorded with CRLF newlines');
|
||||
});
|
||||
|
||||
test('status complete does not block dependents', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-complete');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '07-01-PLAN.md', ['wave: 1', 'objective: Ordinary completion', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-01-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on completed plan', 'autonomous: true', 'depends_on:', ' - 07-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '7', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '07-01');
|
||||
const p02 = data.plans.find((p) => p.id === '07-02');
|
||||
assert.strictEqual(p01.halted, false);
|
||||
assert.deepEqual(p02.blocked_by, []);
|
||||
assert.ok(data.runnable.includes('07-02'));
|
||||
});
|
||||
|
||||
test('dependency cycle detection is unaffected by halt propagation', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '08-cycle');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '08-01-PLAN.md', [
|
||||
'wave: 1', 'objective: Cycle A', 'autonomous: true', 'depends_on:', ' - 08-02',
|
||||
]);
|
||||
writePlan(phaseDir, '08-02-PLAN.md', [
|
||||
'wave: 1', 'objective: Cycle B', 'autonomous: true', 'depends_on:', ' - 08-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '8', '--raw'], tmpDir);
|
||||
// CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs" bans
|
||||
// regex-matching a child process's human-readable stderr/reason prose —
|
||||
// assert on the typed failure signal (`result.success`) instead. To keep
|
||||
// this test specific to the CYCLE (not "failed for any reason"), pair it
|
||||
// with a differential fixture: the identical dependency shape with the
|
||||
// cycle edge removed must succeed, isolating the cycle as the cause of
|
||||
// the failure above without parsing error prose.
|
||||
assert.strictEqual(result.success, false, 'a dependency cycle must still fail the command');
|
||||
|
||||
const acyclicDir = path.join(tmpDir, '.planning', 'phases', '09-nocycle');
|
||||
fs.mkdirSync(acyclicDir, { recursive: true });
|
||||
writePlan(acyclicDir, '09-01-PLAN.md', ['wave: 1', 'objective: No cycle A', 'autonomous: true']);
|
||||
writePlan(acyclicDir, '09-02-PLAN.md', [
|
||||
'wave: 1', 'objective: No cycle B', 'autonomous: true', 'depends_on:', ' - 09-01',
|
||||
]);
|
||||
const acyclicResult = runGsdTools(['phase-plan-index', '9', '--raw'], tmpDir);
|
||||
assert.strictEqual(
|
||||
acyclicResult.success,
|
||||
true,
|
||||
`the identical dependency shape without the cycle edge must succeed, isolating the cycle as the cause of the failure above: ${acyclicResult.error}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findPhaseInternal / searchPhaseInDir (src/phase-locator.cts) ─────────
|
||||
|
||||
describe('findPhaseInternal: halt propagation (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('direct dependent of a halted plan is blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(result, 'expected a result');
|
||||
assert.deepEqual(result.blocked_by['01-02-PLAN.md'], ['01-01'], '01-02 should be blocked by halted 01-01');
|
||||
assert.ok(!result.runnable_plans.includes('01-02-PLAN.md'));
|
||||
});
|
||||
|
||||
test('transitive dependent is blocked via chain', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(result.blocked_by['01-03-PLAN.md'], ['01-01'], '01-03 should be transitively blocked');
|
||||
assert.ok(!result.runnable_plans.includes('01-03-PLAN.md'));
|
||||
});
|
||||
|
||||
test('diamond dependency blocked by both halted ancestors', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-diamond');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '02-01-PLAN.md', ['wave: 1', 'objective: Halted A', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-02-PLAN.md', ['wave: 1', 'objective: Halted B', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-02-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Diamond join', 'autonomous: true',
|
||||
'depends_on:', ' - 02-01', ' - 02-02',
|
||||
]);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '2');
|
||||
assert.ok(result, 'expected a result');
|
||||
assert.deepEqual(
|
||||
[...result.blocked_by['02-03-PLAN.md']].sort(),
|
||||
['02-01', '02-02'],
|
||||
'diamond join should be blocked by both halted ancestors, deduplicated',
|
||||
);
|
||||
});
|
||||
|
||||
test('unrelated decoupled plan stays runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(result.runnable_plans.includes('01-04-PLAN.md'), '01-04 (decoupled) must stay runnable');
|
||||
assert.strictEqual(result.blocked_by['01-04-PLAN.md'], undefined);
|
||||
});
|
||||
|
||||
test('incomplete_plans stays byte-identical when blocked plans are present', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(
|
||||
[...result.incomplete_plans].sort(),
|
||||
['01-02-PLAN.md', '01-03-PLAN.md', '01-04-PLAN.md'],
|
||||
'incomplete_plans must list every no-SUMMARY plan regardless of blocked status',
|
||||
);
|
||||
});
|
||||
|
||||
test('halted_plans reports the halted plan itself by filename', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(result.halted_plans, ['01-01-PLAN.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parity: the two implementations must agree ───────────────────────────
|
||||
|
||||
describe('parity: phase-plan-index and findPhaseInternal agree on blocking (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('same fixture yields the same blocked-plan set and cause set from both readers', () => {
|
||||
tmpDir = createTempProject('gsd-2830-parity-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const cliResult = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
assert.ok(cliResult.success, `phase-plan-index should succeed: ${cliResult.error}`);
|
||||
const cliData = JSON.parse(cliResult.output);
|
||||
|
||||
const locatorResult = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(locatorResult, 'findPhaseInternal should return a result');
|
||||
|
||||
// Build { planId -> sorted cause list } from each reader and require them
|
||||
// to be structurally identical (id-mapped, since one reader keys by bare
|
||||
// id and the other by filename).
|
||||
const cliBlocked = {};
|
||||
for (const plan of cliData.plans) {
|
||||
if (plan.blocked_by.length > 0) cliBlocked[plan.id] = [...plan.blocked_by].sort();
|
||||
}
|
||||
|
||||
const locatorBlocked = {};
|
||||
for (const [filename, causes] of Object.entries(locatorResult.blocked_by)) {
|
||||
const planId = filename.replace(/-PLAN\.md$/i, '').replace(/^PLAN\.md$/i, '');
|
||||
locatorBlocked[planId] = [...causes].sort();
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
locatorBlocked,
|
||||
cliBlocked,
|
||||
'phase-plan-index and findPhaseInternal must report the exact same blocked-plan-id -> cause-set mapping',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeHaltPropagation — direct module tests + fast-check property ───
|
||||
|
||||
describe('computeHaltPropagation: graph invariants (#2830)', () => {
|
||||
test('a node with no depends_on and not halted is never blocked', () => {
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation([
|
||||
{ id: 'A', resolvedDependsOn: [], halted: false },
|
||||
]);
|
||||
assert.strictEqual(blockedBy.get('A'), undefined);
|
||||
});
|
||||
|
||||
test('a halted node is never blocked by itself', () => {
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation([
|
||||
{ id: 'A', resolvedDependsOn: [], halted: true },
|
||||
]);
|
||||
assert.strictEqual(blockedBy.get('A'), undefined, 'a halted plan is halted, not "blocked"');
|
||||
});
|
||||
|
||||
test('precomputedOrder (the phase.cts call shape) yields the same result as self-derived order', () => {
|
||||
// phase.cts's cmdPhasePlanIndex passes computeDependencyLevels's own
|
||||
// topological order in as `precomputedOrder` so this function does not
|
||||
// re-run Kahn's algorithm. Assert both call shapes agree.
|
||||
const nodes = [
|
||||
{ id: 'A', resolvedDependsOn: [], halted: true },
|
||||
{ id: 'B', resolvedDependsOn: ['A'], halted: false },
|
||||
{ id: 'C', resolvedDependsOn: ['B'], halted: false },
|
||||
];
|
||||
const selfDerived = planDependencyGraph.computeHaltPropagation(nodes);
|
||||
const withPrecomputed = planDependencyGraph.computeHaltPropagation(nodes, ['A', 'B', 'C']);
|
||||
assert.deepEqual([...withPrecomputed.blockedBy.entries()], [...selfDerived.blockedBy.entries()]);
|
||||
assert.deepEqual(withPrecomputed.order, ['A', 'B', 'C']);
|
||||
assert.strictEqual(withPrecomputed.visited, 3);
|
||||
});
|
||||
|
||||
// Generate a random DAG over N nodes: edges only point from a lower index
|
||||
// to a higher index (guarantees acyclicity by construction, independent of
|
||||
// the code under test), with a random halted flag per node.
|
||||
//
|
||||
// Acyclic BY CONSTRUCTION: `to` is always drawn strictly above `from`, so
|
||||
// no `.filter()` is involved. A filter here is not merely slower — with
|
||||
// n === 1 the predicate `from < to` is unsatisfiable and fast-check retries
|
||||
// generation forever, which hung the whole suite (the runner sets
|
||||
// --test-timeout=0, so it never dies). Hoisted to describe scope so the
|
||||
// regression test below can sample the identical arbitrary.
|
||||
const dagArb = fc.integer({ min: 1, max: 12 }).chain((n) => {
|
||||
const ids = Array.from({ length: n }, (_, i) => `N${i}`);
|
||||
const edgeArb = n < 2
|
||||
? fc.constant([])
|
||||
: fc.array(
|
||||
fc.integer({ min: 0, max: n - 2 }).chain((from) =>
|
||||
fc.integer({ min: from + 1, max: n - 1 }).map((to) => ({ from, to }))),
|
||||
{ maxLength: n * 2 },
|
||||
);
|
||||
const haltedArb = fc.array(fc.boolean(), { minLength: n, maxLength: n });
|
||||
return fc.record({ ids: fc.constant(ids), edges: edgeArb, halted: haltedArb });
|
||||
});
|
||||
|
||||
test('fast-check — blocked set matches reachability from halted nodes', () => {
|
||||
fc.assert(
|
||||
fc.property(dagArb, ({ ids, edges, halted }) => {
|
||||
const dependsOn = new Map(ids.map((id) => [id, []]));
|
||||
for (const { from, to } of edges) {
|
||||
dependsOn.get(ids[from]).push(ids[to]);
|
||||
}
|
||||
const nodes = ids.map((id, i) => ({
|
||||
id,
|
||||
resolvedDependsOn: dependsOn.get(id),
|
||||
halted: halted[i],
|
||||
}));
|
||||
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation(nodes);
|
||||
|
||||
// Reference model: reachability via depends_on edges from a halted node.
|
||||
const haltedSet = new Set(nodes.filter((n) => n.halted).map((n) => n.id));
|
||||
const dependsOnMap = new Map(nodes.map((n) => [n.id, n.resolvedDependsOn]));
|
||||
function reachableHaltedCauses(id, seen = new Set()) {
|
||||
const causes = new Set();
|
||||
for (const dep of dependsOnMap.get(id) ?? []) {
|
||||
if (seen.has(dep)) continue;
|
||||
seen.add(dep);
|
||||
if (haltedSet.has(dep)) causes.add(dep);
|
||||
for (const c of reachableHaltedCauses(dep, seen)) causes.add(c);
|
||||
}
|
||||
return causes;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const expected = [...reachableHaltedCauses(n.id)].sort();
|
||||
const actual = [...(blockedBy.get(n.id) ?? [])].sort();
|
||||
assert.deepEqual(
|
||||
actual,
|
||||
expected,
|
||||
`node ${n.id}: computeHaltPropagation blockedBy must equal halted-reachability`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
test('the DAG generator terminates on the degenerate single-node case (regression: unsatisfiable filter hung the suite)', () => {
|
||||
// Bounded, non-hanging sample: if edgeArb regresses to a `.filter(from < to)`
|
||||
// over a forced-equal {from, to} pair (n === 1), fast-check would retry
|
||||
// generation forever and this assertion would never run. A small,
|
||||
// explicit numRuns/seed keeps the check itself deterministic and fast.
|
||||
const samples = fc.sample(dagArb, { numRuns: 20, seed: 7 });
|
||||
assert.ok(samples.length === 20, 'fc.sample must return the requested number of samples without hanging');
|
||||
const singleNodeSamples = samples.filter(({ ids }) => ids.length === 1);
|
||||
assert.ok(singleNodeSamples.length > 0, 'the sample must include at least one degenerate single-node case');
|
||||
for (const { edges } of singleNodeSamples) {
|
||||
assert.deepEqual(edges, [], 'the single-node case must yield an empty edge list, not an unsatisfiable filter');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── init execute-phase (cmdInitExecutePhase, src/init.cts) ───────────────
|
||||
//
|
||||
// #2830 names `init execute-phase` as the exact regressed consumer: the
|
||||
// locator already computed halted_plans/blocked_by/runnable_plans, but the
|
||||
// command built its output by explicit field enumeration, silently dropping
|
||||
// all three. This drives the real CLI end to end (not the locator directly)
|
||||
// to prove the passthrough, modeled on the "init execute-phase JSON output"
|
||||
// fixture shape in tests/tdd-mode.test.cjs (ROADMAP.md + a phase directory
|
||||
// resolvable by number).
|
||||
|
||||
describe('init execute-phase: halt propagation passthrough (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('halted_plans, blocked_by, runnable_plans are forwarded; incomplete fields stay byte-identical', () => {
|
||||
tmpDir = createTempProject('gsd-2830-init-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
|
||||
// Guard: if the phase was not resolved, every assertion below is vacuous.
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
json.halted_plans.includes('01-01-PLAN.md'),
|
||||
'halted_plans must include the halted plan',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '01-02-PLAN.md'),
|
||||
'blocked_by must have an entry for the direct dependent',
|
||||
);
|
||||
assert.deepEqual(
|
||||
json.blocked_by['01-02-PLAN.md'],
|
||||
['01-01'],
|
||||
"blocked_by['01-02-PLAN.md'] must name 01-01 as the blocking cause",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('01-02-PLAN.md'),
|
||||
'the dependent must NOT be offered as runnable work',
|
||||
);
|
||||
assert.ok(
|
||||
json.runnable_plans.includes('01-04-PLAN.md'),
|
||||
'the decoupled plan (no dependency on the halted plan) must stay runnable',
|
||||
);
|
||||
|
||||
// Back-compat: incomplete_plans/incomplete_count must be exactly what they
|
||||
// were pre-#2830 — every no-SUMMARY plan, blocked or not, still counted.
|
||||
assert.deepEqual(
|
||||
[...json.incomplete_plans].sort(),
|
||||
['01-02-PLAN.md', '01-03-PLAN.md', '01-04-PLAN.md'],
|
||||
'incomplete_plans must be unchanged by halt-awareness',
|
||||
);
|
||||
assert.strictEqual(
|
||||
json.incomplete_count,
|
||||
3,
|
||||
'incomplete_count must be unchanged by halt-awareness',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2830 review findings ─────────────────────────────────────────────────
|
||||
//
|
||||
// Adversarial review of the #2830 fix found two confirmed defects:
|
||||
// 1. (BLOCKER) computeHaltPropagation's Kahn pass silently drops cycle
|
||||
// participants (and anything downstream of them) from BOTH `order` and
|
||||
// `blockedBy` — phase.cts hard-fails on a cycle before this ever
|
||||
// matters, but phase-locator.cts (consumed by `init execute-phase`)
|
||||
// does not pre-check, so a plan directly depends_on-ing a halted plan
|
||||
// inside a cycle was reported as ordinary runnable.
|
||||
// 2. (MAJOR) the summary templates presented `status: halted` as a
|
||||
// trailing `#`-comment on the value line, but `extractFrontmatter`
|
||||
// does not strip trailing YAML comments — an executor mimicking the
|
||||
// template's own presentation wrote a halt that silently read back as
|
||||
// not-halted.
|
||||
|
||||
describe('#2830 review findings', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('defect 1: cycle repro — 08-02 depends_on [halted 08-01, 08-03]; 08-03 depends_on [08-02]', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-cycle-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '08-cyclehalt');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '08-01-PLAN.md', ['wave: 1', 'objective: Halted upstream', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '08-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '08-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on halted + cyclic peer', 'autonomous: true',
|
||||
'depends_on:', ' - 08-01', ' - 08-03',
|
||||
]);
|
||||
writePlan(phaseDir, '08-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Cyclic peer of 08-02', 'autonomous: true', 'depends_on:', ' - 08-02',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '8', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('08-02-PLAN.md'),
|
||||
'a plan that directly depends_on a halted plan must never be offered as runnable, cycle or not',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '08-02-PLAN.md'),
|
||||
'a plan must never silently vanish from both runnable_plans and blocked_by',
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(json.blocked_by['08-02-PLAN.md']) && json.blocked_by['08-02-PLAN.md'].length > 0,
|
||||
'blocked_by entry must be non-empty, not a vacuous placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 1: self-dependency (A depends_on A, nothing halted) must not be silently runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-self-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '09-selfdep');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '09-01-PLAN.md', [
|
||||
'wave: 1', 'objective: Depends on itself', 'autonomous: true', 'depends_on:', ' - 09-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '9', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('09-01-PLAN.md'),
|
||||
'a self-dependent plan must not be silently offered as runnable',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '09-01-PLAN.md'),
|
||||
'a self-dependent plan must never silently vanish from both runnable_plans and blocked_by',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 2: SUMMARY status with an inline YAML comment still blocks the dependent', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-comment-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-inlinecomment');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '10-01-PLAN.md', ['wave: 1', 'objective: Halted, recorded with an inline comment', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '10-01-SUMMARY.md', 'halted # designed stop');
|
||||
writePlan(phaseDir, '10-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on the inline-commented halt', 'autonomous: true', 'depends_on:', ' - 10-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '10', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
json.halted_plans.includes('10-01-PLAN.md'),
|
||||
'status: halted # designed stop must still be recognized as halted',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '10-02-PLAN.md'),
|
||||
'the dependent of an inline-commented halt must be blocked',
|
||||
);
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('10-02-PLAN.md'),
|
||||
'the dependent of an inline-commented halt must not be offered as runnable',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 2: isHaltedStatus strips an unquoted trailing YAML comment before comparing', () => {
|
||||
const { isHaltedStatus } = planDependencyGraph;
|
||||
assert.strictEqual(isHaltedStatus('halted'), true);
|
||||
assert.strictEqual(isHaltedStatus('Halted'), true);
|
||||
assert.strictEqual(isHaltedStatus('halted '), true);
|
||||
assert.strictEqual(isHaltedStatus('halted # designed stop'), true);
|
||||
assert.strictEqual(isHaltedStatus('halted#nospace'), false, 'a `#` with no preceding whitespace is not a YAML comment');
|
||||
assert.strictEqual(isHaltedStatus('complete'), false);
|
||||
assert.strictEqual(isHaltedStatus('complete # done'), false);
|
||||
assert.strictEqual(isHaltedStatus(''), false);
|
||||
assert.strictEqual(isHaltedStatus(undefined), false);
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2847)
|
||||
// agents/gsd-planner.md is the deployed runtime prompt contract — the planner
|
||||
// agent literally executes this markdown. Testing its text content tests the
|
||||
// deployed contract, per the CONTRIBUTING.md exception matrix and the existing
|
||||
// precedent in tests/plan-phase-drift-guard.test.cjs and
|
||||
// tests/edge-probe-planner-contract.test.cjs.
|
||||
|
||||
/**
|
||||
* Regression tests for #2847
|
||||
*
|
||||
* "--gaps does not load planner-gap-closure.md, so generated gap plans may
|
||||
* miss gap_closure metadata"
|
||||
*
|
||||
* Root cause: the planner's only machine-checked validation gate
|
||||
* (`gsd_run query frontmatter.validate "$PLAN_PATH" --schema plan`) never
|
||||
* required `gap_closure`. The only place `gap_closure: true` was actually
|
||||
* documented as required was prose in a conditionally-loaded reference file
|
||||
* (gsd-core/references/planner-gap-closure.md) plus an unvalidated checklist
|
||||
* item — neither backed by a deterministic gate.
|
||||
*
|
||||
* Fix:
|
||||
* - src/frontmatter.cts: new `plan-gap-closure` FRONTMATTER_SCHEMAS entry
|
||||
* (covered behaviorally in tests/frontmatter-cli.test.cjs and
|
||||
* tests/frontmatter.unit.test.cjs — this file covers the prompt-level wiring
|
||||
* that selects it).
|
||||
* - agents/gsd-planner.md `<step name="validate_plan">`: the bash invocation
|
||||
* now reads `--schema "$SCHEMA"` — a real shell-variable reference, bound in
|
||||
* the same style as the file's existing `"$PLAN_PATH"` convention — instead
|
||||
* of a hardcoded literal. An earlier revision left the bash line unconditional
|
||||
* (`--schema plan)`, a copy-executable no-op) while only the prose sentence
|
||||
* above it mentioned the conditional; that revision satisfied every
|
||||
* substring-presence check but never actually selected plan-gap-closure at
|
||||
* runtime. Caught by review, not by tests — see the describe block below for
|
||||
* the executable-content assertions written specifically to catch it.
|
||||
*
|
||||
* Deliberately NOT touched: gsd-core/workflows/plan-phase.md's
|
||||
* `<downstream_consumer>` block. An earlier draft of this fix added a
|
||||
* gap_closure mention there too (mirroring plan-phase.md's existing
|
||||
* `<review_incorporation_contract>` mode-scoped-block pattern for reviews
|
||||
* mode), but plan-phase.md sits only 36 bytes under the hard ADR-857
|
||||
* PRE_PHASE6 ceiling (tests/phase6-capstone-conformance.test.cjs,
|
||||
* `PRE_PHASE6['plan-phase.md'] = 94519`) and cannot absorb the ~330-byte
|
||||
* addition. The `<step name="validate_plan">` fix in gsd-planner.md is the
|
||||
* actual call site and is sufficient on its own: the planner already tracks
|
||||
* gap_closure mode internally (its own `<step name="identify_phase">` switches
|
||||
* to gap_closure_mode on `--gaps`), so the schema selection does not depend on
|
||||
* plan-phase.md's prose at all. See .gsd/bug/fix-2847-gap-closure-frontmatter/10-diagnosis.md.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PLANNER_AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||
|
||||
function readFile(p) {
|
||||
return fs.readFileSync(p, 'utf-8');
|
||||
}
|
||||
|
||||
function extractStep(content, stepName) {
|
||||
const marker = `<step name="${stepName}">`;
|
||||
const start = content.indexOf(marker);
|
||||
if (start === -1) return null;
|
||||
const end = content.indexOf('</step>', start);
|
||||
if (end === -1) return null;
|
||||
return content.slice(start, end + '</step>'.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the FIRST ```bash ... ``` fenced block from a step's text. Returns null
|
||||
* if no fenced bash block is found.
|
||||
*/
|
||||
function extractFirstBashBlock(stepText) {
|
||||
const m = /```bash\r?\n([\s\S]*?)```/.exec(stepText);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the literal line, within a bash block, that invokes `frontmatter.validate`.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
function findValidateInvocationLine(bashBlock) {
|
||||
if (!bashBlock) return null;
|
||||
return bashBlock.split('\n').find((l) => l.includes('frontmatter.validate')) || null;
|
||||
}
|
||||
|
||||
// ─── agents/gsd-planner.md: validate_plan step BINDS schema to mode (#2847) ──
|
||||
//
|
||||
// This describe block asserts on the EXECUTABLE content of the step — the literal
|
||||
// argument passed to `--schema` in the fenced bash block the agent actually runs —
|
||||
// not on whether explanatory words appear anywhere in the step's prose. A prose
|
||||
// sentence like "use plan-gap-closure in gap_closure mode, else plan" sitting next
|
||||
// to an UNCONDITIONAL `--schema plan)` line satisfies every substring-presence
|
||||
// check imaginable while the agent still only ever executes `--schema plan`. That
|
||||
// exact shape shipped in an earlier revision of this fix and was caught by review,
|
||||
// not by tests — these tests are written specifically to catch it mechanically:
|
||||
// verified RED against that revision (`--schema plan)` hardcoded in the bash
|
||||
// block, `--schema plan-gap-closure` only in the prose sentence above it) before
|
||||
// the bash block was changed to `--schema "$SCHEMA"`.
|
||||
|
||||
describe('#2847: gsd-planner.md validate_plan step BINDS --schema to gap_closure mode (executable content, not prose)', () => {
|
||||
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
||||
const validateStep = extractStep(plannerContent, 'validate_plan');
|
||||
const bashBlock = extractFirstBashBlock(validateStep || '');
|
||||
const invocationLine = findValidateInvocationLine(bashBlock);
|
||||
|
||||
test('validate_plan step exists and has a fenced bash block invoking frontmatter.validate', () => {
|
||||
assert.ok(validateStep, '<step name="validate_plan"> must exist in agents/gsd-planner.md');
|
||||
assert.ok(bashBlock, 'validate_plan step must have a ```bash fenced block');
|
||||
assert.ok(invocationLine, 'validate_plan step bash block must invoke frontmatter.validate');
|
||||
});
|
||||
|
||||
test('the --schema argument in the bash invocation is NOT a hardcoded literal', () => {
|
||||
// Precondition: both regex checks below use `.test(invocationLine)`, and
|
||||
// RegExp#test coerces a null/undefined argument to the STRING "null"/
|
||||
// "undefined" rather than throwing — neither hardcoded-literal pattern
|
||||
// matches that string, so both negative assertions would pass vacuously
|
||||
// (reporting "not hardcoded") even if the step or its bash block were
|
||||
// deleted entirely. Fail loudly on that precondition first so a deleted
|
||||
// step is reported as exactly that, not as a false "fix confirmed".
|
||||
assert.ok(invocationLine, 'precondition: invocationLine must be found (see the first test in this block)');
|
||||
|
||||
// This is the exact regression: a prior revision had this line read
|
||||
// `--schema plan)` verbatim — a plain, hardcoded, always-the-same-value
|
||||
// literal that an agent executes as-is regardless of mode. Reject BOTH
|
||||
// possible hardcoded literals explicitly, not just one, so a fix that
|
||||
// flips the hardcoded default to plan-gap-closure (breaking standard mode
|
||||
// instead of gap_closure mode) is caught too.
|
||||
assert.ok(
|
||||
!/--schema\s+plan\)/.test(invocationLine),
|
||||
`bash invocation must not hardcode --schema plan — found: ${invocationLine}`
|
||||
);
|
||||
assert.ok(
|
||||
!/--schema\s+plan-gap-closure\)/.test(invocationLine),
|
||||
`bash invocation must not hardcode --schema plan-gap-closure — found: ${invocationLine}`
|
||||
);
|
||||
});
|
||||
|
||||
test('the --schema argument in the bash invocation IS a shell variable reference', () => {
|
||||
// A variable reference means the value is resolved at execution time from
|
||||
// whatever the agent has bound it to, not printed once in the template and
|
||||
// copy-executed unchanged. Matches --schema "$SCHEMA", --schema $SCHEMA,
|
||||
// or --schema "${SCHEMA}".
|
||||
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
||||
assert.ok(
|
||||
varMatch,
|
||||
`bash invocation's --schema argument must be a shell variable (e.g. --schema "$SCHEMA"), not a literal — found: ${invocationLine}`
|
||||
);
|
||||
});
|
||||
|
||||
test('the bound variable is actually conditioned on gap_closure mode in the step prose, and both target schema names are named', () => {
|
||||
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
||||
assert.ok(varMatch, 'precondition: --schema must reference a variable (see previous test)');
|
||||
const varName = varMatch[1];
|
||||
|
||||
// The SAME variable name the bash block reads must appear in the step's prose
|
||||
// (outside the bash block) — otherwise the "binding" is a variable nothing
|
||||
// ever explains how to set, which is not meaningfully better than a literal.
|
||||
const proseOutsideBash = validateStep.replace(/```bash\r?\n[\s\S]*?```/, '');
|
||||
assert.ok(
|
||||
proseOutsideBash.includes(`$${varName}`) || proseOutsideBash.includes(`\`$${varName}\``),
|
||||
`step prose must explain how $${varName} is set — the bash block references it but nothing binds it`
|
||||
);
|
||||
|
||||
// Both concrete schema names this variable can resolve to must be named
|
||||
// somewhere in the step, and gap_closure mode must be the stated condition
|
||||
// for choosing between them. Match the plain `plan` schema as a standalone
|
||||
// backtick-quoted token (`` `plan` ``), not the bare substring "plan" —
|
||||
// a bare-substring check is satisfied incidentally by "verify.plan-structure"
|
||||
// a few lines below even if the plain-plan branch were deleted entirely from
|
||||
// the prose, which would make this assertion unable to ever fail.
|
||||
assert.ok(validateStep.includes('plan-gap-closure'), 'step must name the plan-gap-closure schema');
|
||||
assert.ok(
|
||||
/`plan`/.test(validateStep),
|
||||
'step must name the plain plan schema, as a standalone `plan` token, as the other branch'
|
||||
);
|
||||
assert.ok(/gap_closure mode/i.test(validateStep), 'step must condition the choice on gap_closure mode by name');
|
||||
});
|
||||
|
||||
test('the plan-structure validation call below (unrelated step) is unaffected', () => {
|
||||
// Regression guard for the fix itself: confirm the edit did not touch the
|
||||
// sibling verify.plan-structure invocation in the same step.
|
||||
assert.ok(
|
||||
validateStep.includes('verify.plan-structure "$PLAN_PATH"'),
|
||||
'validate_plan step must still invoke verify.plan-structure unchanged'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-file consistency: schema name used by both files matches (#2847) ──
|
||||
|
||||
describe('#2847: schema name consistency between gsd-planner.md and src/frontmatter.cts', () => {
|
||||
test('gsd-planner.md references the exact schema name "plan-gap-closure"', () => {
|
||||
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
||||
assert.ok(
|
||||
plannerContent.includes('plan-gap-closure'),
|
||||
'agents/gsd-planner.md must reference the literal schema name "plan-gap-closure" ' +
|
||||
'(the exact key registered in FRONTMATTER_SCHEMAS in src/frontmatter.cts) — a ' +
|
||||
'mismatched name would fail at runtime with "Unknown schema"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #2847 review: two describe blocks previously lived here —
|
||||
// "planner-gap-closure.md reference is untouched" and "plan-phase.md is
|
||||
// deliberately unmodified by this fix" — both deleted. Neither file is
|
||||
// touched by this fix, so both assertions were already GREEN at the RED
|
||||
// commit (5e5897cd2f17ebf2fc55757bae651bbbeb236289): they pinned untouched
|
||||
// files rather than providing regression coverage for anything this change
|
||||
// altered. The plan-phase.md one was worse than merely unhelpful — it
|
||||
// permanently forbade any FUTURE legitimate `gap_closure` mention in
|
||||
// plan-phase.md, a trap for whoever eventually frees up that file's byte
|
||||
// budget and has a real reason to add one. The design decision itself (why
|
||||
// plan-phase.md is untouched — the ADR-857 PRE_PHASE6 byte ceiling) remains
|
||||
// documented in the file-level comment above and in
|
||||
// .gsd/bug/fix-2847-gap-closure-frontmatter/10-diagnosis.md; it just isn't
|
||||
// asserted as a permanent negative here.
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Regression tests for #2855: the phase-locator's archived-milestone fallback
|
||||
* hardcoded the project-root `.planning/milestones/` tree instead of routing
|
||||
* through the workstream-aware `planningDir(cwd)` helper. A pending phase in
|
||||
* workstream A, whose own `phases/` directory does not exist yet, would
|
||||
* silently resolve to a same-numbered phase archived under the ROOT tree
|
||||
* (an unrelated workstream's history, or a flat-mode project's archive) —
|
||||
* complete with stale plan/summary counts and an "archived" status for a
|
||||
* phase that is actually brand new.
|
||||
*
|
||||
* Root cause: src/phase-locator.cts:139 (`findPhaseInternal`) and
|
||||
* src/phase-locator.cts:167 (`getArchivedPhaseDirs`) both used
|
||||
* `path.join(cwd, '.planning', 'milestones')` instead of
|
||||
* `path.join(planningDir(cwd), 'milestones')` — the same seam the
|
||||
* active-phase search (line 132) and the archive-write path
|
||||
* (`archivePhaseDirectories`, src/milestone.cts) already use.
|
||||
*
|
||||
* Ambient-env hermeticity: GSD_WORKSTREAM/GSD_PROJECT are read directly from
|
||||
* process.env by planningDir() when omitted, so every test here explicitly
|
||||
* saves and restores both (pattern from
|
||||
* tests/fix-2297-resolve-model-ids-runtime-scoping.test.cjs) to avoid leaking
|
||||
* state across tests or picking up a developer's ambient shell env.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
let _origWorkstream;
|
||||
let _origProject;
|
||||
|
||||
function isolateWorkstreamEnv() {
|
||||
_origWorkstream = process.env.GSD_WORKSTREAM;
|
||||
_origProject = process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
}
|
||||
|
||||
function restoreWorkstreamEnv() {
|
||||
if (_origWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = _origWorkstream;
|
||||
if (_origProject === undefined) delete process.env.GSD_PROJECT;
|
||||
else process.env.GSD_PROJECT = _origProject;
|
||||
}
|
||||
|
||||
describe('#2855: findPhaseInternal does not leak cross-workstream archived phases', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateWorkstreamEnv(); });
|
||||
afterEach(() => {
|
||||
restoreWorkstreamEnv();
|
||||
if (tmpDir) { cleanup(tmpDir); tmpDir = null; }
|
||||
});
|
||||
|
||||
test('does not leak root-tree archived phase into an unrelated workstream', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
// Root archive holds phase 03 (unrelated workstream's / flat-mode history).
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootArchive, 'SOME-SUMMARY.md'), '# stale');
|
||||
|
||||
// Workstream "beta" exists with an empty phases/ dir — phase 03 is pending.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases'), { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.strictEqual(result, null, 'pending workstream phase must not resolve to the root archive');
|
||||
});
|
||||
|
||||
test('does not leak root archive when workstream phases dir is entirely absent', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v2.0-phases', '01-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
// Brand-new workstream: no .planning/workstreams/gamma/ directory at all yet.
|
||||
process.env.GSD_WORKSTREAM = 'gamma';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.strictEqual(result, null, 'a workstream with no directory yet must not resolve to the root archive');
|
||||
});
|
||||
|
||||
// Issue #2855 AC1: "...regardless of whether workstream A's roadmap already
|
||||
// lists the phase and when it doesn't yet." findPhaseInternal never reads
|
||||
// ROADMAP.md (it is a pure filesystem lookup), so this dimension cannot
|
||||
// change its behavior — demonstrated directly rather than left as an
|
||||
// inference from reading the source.
|
||||
for (const roadmapHasEntry of [true, false]) {
|
||||
test(`does not leak root archive whether or not the workstream's ROADMAP.md already lists the phase (roadmapHasEntry=${roadmapHasEntry})`, () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'beta');
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
if (roadmapHasEntry) {
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'ROADMAP.md'),
|
||||
['# Roadmap', '', '### Phase 03: Pending Work', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.strictEqual(result, null, 'ROADMAP.md presence/absence must not affect the archive-leak guard');
|
||||
});
|
||||
}
|
||||
|
||||
test('still finds a phase genuinely archived under the active workstream\'s own tree', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '03-real');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(result !== null, 'workstream\'s own archived phase must still resolve');
|
||||
assert.strictEqual(result.found, true);
|
||||
assert.strictEqual(result.archived, 'v1.0');
|
||||
assert.strictEqual(
|
||||
result.directory,
|
||||
'.planning/workstreams/beta/milestones/v1.0-phases/03-real',
|
||||
);
|
||||
});
|
||||
|
||||
test('flat/non-workstream project archive resolution is unchanged', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-flat');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
// No GSD_WORKSTREAM / GSD_PROJECT set — flat mode.
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(result !== null, 'flat-mode archive lookup must be unaffected by the fix');
|
||||
assert.strictEqual(result.found, true);
|
||||
assert.strictEqual(result.archived, 'v1.0');
|
||||
assert.strictEqual(result.directory, '.planning/milestones/v1.0-phases/03-flat');
|
||||
});
|
||||
|
||||
test('two workstreams with same-numbered archived phases never cross-resolve', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const alphaArchive = path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'milestones', 'v1.0-phases', '03-alpha-work');
|
||||
const betaArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '03-beta-work');
|
||||
fs.mkdirSync(alphaArchive, { recursive: true });
|
||||
fs.mkdirSync(betaArchive, { recursive: true });
|
||||
|
||||
process.env.GSD_WORKSTREAM = 'alpha';
|
||||
const alphaResult = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(alphaResult !== null);
|
||||
assert.strictEqual(alphaResult.phase_name, 'alpha-work');
|
||||
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
const betaResult = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(betaResult !== null);
|
||||
assert.strictEqual(betaResult.phase_name, 'beta-work');
|
||||
});
|
||||
|
||||
test('project+workstream combination scopes the archive fallback', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '05-root-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
const scopedArchive = path.join(tmpDir, '.planning', 'proj-x', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '05-scoped');
|
||||
fs.mkdirSync(scopedArchive, { recursive: true });
|
||||
|
||||
process.env.GSD_PROJECT = 'proj-x';
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '5');
|
||||
assert.ok(result !== null, 'project+workstream scoped archive must resolve');
|
||||
assert.strictEqual(result.phase_name, 'scoped');
|
||||
assert.strictEqual(
|
||||
result.directory,
|
||||
'.planning/proj-x/workstreams/beta/milestones/v1.0-phases/05-scoped',
|
||||
);
|
||||
});
|
||||
|
||||
test('workstream-scoped archived directory is posix-style', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '07-posix');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '7');
|
||||
assert.ok(result !== null);
|
||||
assert.ok(!result.directory.includes('\\'), 'directory must use forward slashes on every platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2855: getArchivedPhaseDirs does not leak cross-workstream archived phases', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateWorkstreamEnv(); });
|
||||
afterEach(() => {
|
||||
restoreWorkstreamEnv();
|
||||
if (tmpDir) { cleanup(tmpDir); tmpDir = null; }
|
||||
});
|
||||
|
||||
test('does not leak root-tree archive entries under an active workstream', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases'), { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.deepEqual(result, [], 'getArchivedPhaseDirs must not surface the root archive for a workstream');
|
||||
});
|
||||
|
||||
test('still finds phases genuinely archived under the active workstream\'s own tree', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v2.0-phases', '04-own');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].name, '04-own');
|
||||
assert.strictEqual(result[0].milestone, 'v2.0');
|
||||
// basePath is posix-normalized (toPosixPath) — a forward-slash literal is
|
||||
// the correct cross-platform expectation, not path.join.
|
||||
assert.strictEqual(
|
||||
result[0].basePath,
|
||||
'.planning/workstreams/beta/milestones/v2.0-phases',
|
||||
);
|
||||
});
|
||||
|
||||
test('flat/non-workstream project resolution is unchanged', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v2.1.0-phases');
|
||||
fs.mkdirSync(path.join(archiveDir, '03-auth'), { recursive: true });
|
||||
// No GSD_WORKSTREAM / GSD_PROJECT set — flat mode.
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.strictEqual(result.length, 1);
|
||||
const entry = result[0];
|
||||
assert.strictEqual(entry.name, '03-auth');
|
||||
assert.strictEqual(entry.milestone, 'v2.1.0');
|
||||
// basePath is posix-normalized (toPosixPath) — a forward-slash literal is
|
||||
// the correct cross-platform expectation, not path.join.
|
||||
assert.strictEqual(entry.basePath, '.planning/milestones/v2.1.0-phases');
|
||||
assert.strictEqual(entry.fullPath, path.join(archiveDir, '03-auth'));
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product see #3174
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* quick verification-status read contract (#3174)
|
||||
*
|
||||
* quick's verification step used to read the verifier's result with a raw
|
||||
* `grep "^status:" F | cut -d: -f2 | tr -d ' '` and route it through arms
|
||||
* passed / human_needed / gaps_found only. That read failed two ways,
|
||||
* both measured against the old pipeline.
|
||||
*
|
||||
* Matched NO arm: a missing report; most off-schema values; a `status:` line
|
||||
* in BOTH the frontmatter and the prose (two lines); and — on a CRLF
|
||||
* checkout — a perfectly valid `passed`, which arrives as `passed\r`.
|
||||
*
|
||||
* Matched the SUCCESS arm when it should not have: a stale report still
|
||||
* reading `passed` (staleness was never evaluated); a report whose only
|
||||
* `status:` line sits in its prose; and an off-schema value carrying a colon
|
||||
* (`passed:bogus`), which `cut -d: -f2` splits at that colon, leaving the
|
||||
* pipeline to yield `passed` once `tr -d ' '` strips the leading space.
|
||||
*
|
||||
* The unanchored match is the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class the
|
||||
* code side already fixed by name.
|
||||
*
|
||||
* These tests pin the five properties that keep the replacement honest.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_VERIFICATION = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'quick', 'steps', 'quick-verification.md',
|
||||
);
|
||||
// The canonical launcher preamble. scripts/sync-runtime-launcher.cjs rewrites
|
||||
// every workflow's bootstrap from this file, so THIS is the authority — not
|
||||
// whichever sibling step file happens to carry a copy today.
|
||||
const LAUNCHER_SNIPPET = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh',
|
||||
);
|
||||
|
||||
const SHIM_ANCHOR = '_GSD_SHIM_NAME="gsd-tools.cjs"';
|
||||
|
||||
describe('quick verification status read (#3174)', () => {
|
||||
test('status is read through the canonical query, not a raw frontmatter grep', () => {
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const queryIdx = content.indexOf('gsd_run query verification.status "${QUICK_DIR}"');
|
||||
|
||||
assert.ok(queryIdx !== -1, 'quick-verification.md must read status via the verification.status query');
|
||||
assert.ok(
|
||||
!content.includes('grep "^status:"'),
|
||||
'the raw frontmatter-scalar grep must not return — it matches body lines too (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP)',
|
||||
);
|
||||
});
|
||||
|
||||
test('the query call is preceded by the runtime shim bootstrap in this step file', () => {
|
||||
// Step files are read and executed as their own units, so quick.md's
|
||||
// bootstrap does not reach here. Without this the call resolves to
|
||||
// nothing, 2>/dev/null swallows it, and the default arm is taken forever.
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const shimIdx = content.indexOf(SHIM_ANCHOR);
|
||||
const queryIdx = content.indexOf('gsd_run query verification.status');
|
||||
|
||||
assert.ok(shimIdx !== -1, 'the step file must carry its own runtime shim bootstrap');
|
||||
assert.ok(queryIdx > shimIdx, 'the shim bootstrap must precede the gsd_run call');
|
||||
});
|
||||
|
||||
test('the shim bootstrap is the canonical launcher preamble, not a fork of it', () => {
|
||||
// Anchored on _runtime-launcher.snippet.sh rather than on a sibling step
|
||||
// file: sync-runtime-launcher.cjs regenerates every workflow from the
|
||||
// snippet, so a synchronized launcher update keeps this green (correct),
|
||||
// and a sibling that legitimately stops calling gsd_run cannot fail us.
|
||||
const lineWithShim = (file) => fs.readFileSync(file, 'utf-8')
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith(SHIM_ANCHOR));
|
||||
|
||||
const mine = lineWithShim(QUICK_VERIFICATION);
|
||||
const canonical = lineWithShim(LAUNCHER_SNIPPET);
|
||||
|
||||
assert.ok(canonical, '_runtime-launcher.snippet.sh must carry the canonical preamble');
|
||||
assert.equal(mine, canonical, 'the bootstrap must match the canonical launcher snippet verbatim');
|
||||
});
|
||||
|
||||
test('status extraction does not depend on jq', () => {
|
||||
// #2589: a `| jq -r '.field'` pipe yields an empty variable with no
|
||||
// diagnostic wherever jq is absent (the Windows/Git-Bash default), which
|
||||
// would route a passing verification into the recovery arm.
|
||||
//
|
||||
// Scoped to the executable fence on purpose: the surrounding prose cites
|
||||
// the jq form in order to explain why it is not used, and an assertion
|
||||
// over the whole file would fire on its own rationale.
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const fences = content.match(/```bash\r?\n[\s\S]*?```/g) || [];
|
||||
const statusFence = fences.find((f) => f.includes('gsd_run query verification.status'));
|
||||
|
||||
assert.ok(statusFence, 'the status read must live in a bash fence');
|
||||
assert.ok(
|
||||
statusFence.includes('--pick status'),
|
||||
'the bare status must be picked by the query itself',
|
||||
);
|
||||
assert.ok(!/\|\s*jq\b/.test(statusFence), 'the status-read fence must not pipe through jq');
|
||||
});
|
||||
|
||||
test('the routing table carries a terminal arm for missing / unknown / stale', () => {
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const gapsIdx = content.indexOf('| `gaps_found` |');
|
||||
const fallbackIdx = content.indexOf('| anything else');
|
||||
|
||||
assert.ok(gapsIdx !== -1, 'the three verifier-status arms must remain');
|
||||
assert.ok(fallbackIdx > gapsIdx, 'a terminal arm must follow the verifier-status arms');
|
||||
|
||||
const fallbackRow = content.slice(fallbackIdx, content.indexOf('\n', fallbackIdx));
|
||||
for (const sentinel of ['missing', 'unknown', 'stale']) {
|
||||
assert.ok(
|
||||
fallbackRow.includes(sentinel),
|
||||
`the terminal arm must name the ${sentinel} sentinel the query can return`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
fallbackRow.includes('VERIFICATION_STATUS'),
|
||||
'the terminal arm must set the display string consumed by the quick index row and banner',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2053,3 +2053,193 @@ test('extractFrontmatter handles large frontmatter blocks without body bleed', (
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-2847-gap-closure-frontmatter.test.cjs — test-hygiene sweep #3335 (H3 Wave 3)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:fix-2847-gap-closure-frontmatter (test-hygiene sweep #3335 H3 Wave 3)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2847)
|
||||
// agents/gsd-planner.md is the deployed runtime prompt contract — testing its
|
||||
// text content tests the deployed contract (CONTRIBUTING.md exception matrix).
|
||||
//
|
||||
// Regression tests for #2847: "--gaps does not load planner-gap-closure.md,
|
||||
// so generated gap plans may miss gap_closure metadata". Root cause: the
|
||||
// planner's only machine-checked gate (`frontmatter.validate ... --schema
|
||||
// plan`) never required gap_closure — the requirement lived only in
|
||||
// conditionally-loaded prose. Fix: src/frontmatter.cts gained a
|
||||
// `plan-gap-closure` schema (covered behaviorally in frontmatter-cli.test.cjs
|
||||
// / frontmatter.unit.test.cjs); this block covers the prompt-level wiring in
|
||||
// agents/gsd-planner.md's <step name="validate_plan"> that selects it via a
|
||||
// real `--schema "$SCHEMA"` shell-variable reference instead of a hardcoded
|
||||
// literal. An earlier revision left the bash line unconditional
|
||||
// (`--schema plan)`) while only prose mentioned the conditional — caught by
|
||||
// review, not tests; the assertions below target executable content
|
||||
// specifically to catch that shape. See
|
||||
// .gsd/bug/fix-2847-gap-closure-frontmatter/10-diagnosis.md.
|
||||
//
|
||||
// Deliberately NOT touched: gsd-core/workflows/plan-phase.md sits under the
|
||||
// ADR-857 PRE_PHASE6 byte ceiling and cannot absorb a gap_closure mention;
|
||||
// the gsd-planner.md validate_plan step is the actual call site and is
|
||||
// sufficient on its own.
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PLANNER_AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||
|
||||
function readFile(p) {
|
||||
return fs.readFileSync(p, 'utf-8');
|
||||
}
|
||||
|
||||
function extractStep(content, stepName) {
|
||||
const marker = `<step name="${stepName}">`;
|
||||
const start = content.indexOf(marker);
|
||||
if (start === -1) return null;
|
||||
const end = content.indexOf('</step>', start);
|
||||
if (end === -1) return null;
|
||||
return content.slice(start, end + '</step>'.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the FIRST ```bash ... ``` fenced block from a step's text. Returns null
|
||||
* if no fenced bash block is found.
|
||||
*/
|
||||
function extractFirstBashBlock(stepText) {
|
||||
const m = /```bash\r?\n([\s\S]*?)```/.exec(stepText);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the literal line, within a bash block, that invokes `frontmatter.validate`.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
function findValidateInvocationLine(bashBlock) {
|
||||
if (!bashBlock) return null;
|
||||
return bashBlock.split('\n').find((l) => l.includes('frontmatter.validate')) || null;
|
||||
}
|
||||
|
||||
// ─── agents/gsd-planner.md: validate_plan step BINDS schema to mode (#2847) ──
|
||||
//
|
||||
// This describe block asserts on the EXECUTABLE content of the step — the literal
|
||||
// argument passed to `--schema` in the fenced bash block the agent actually runs —
|
||||
// not on whether explanatory words appear anywhere in the step's prose. A prose
|
||||
// sentence like "use plan-gap-closure in gap_closure mode, else plan" sitting next
|
||||
// to an UNCONDITIONAL `--schema plan)` line satisfies every substring-presence
|
||||
// check imaginable while the agent still only ever executes `--schema plan`. That
|
||||
// exact shape shipped in an earlier revision of this fix and was caught by review,
|
||||
// not by tests — these tests are written specifically to catch it mechanically:
|
||||
// verified RED against that revision (`--schema plan)` hardcoded in the bash
|
||||
// block, `--schema plan-gap-closure` only in the prose sentence above it) before
|
||||
// the bash block was changed to `--schema "$SCHEMA"`.
|
||||
|
||||
describe('#2847: gsd-planner.md validate_plan step BINDS --schema to gap_closure mode (executable content, not prose)', () => {
|
||||
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
||||
const validateStep = extractStep(plannerContent, 'validate_plan');
|
||||
const bashBlock = extractFirstBashBlock(validateStep || '');
|
||||
const invocationLine = findValidateInvocationLine(bashBlock);
|
||||
|
||||
test('validate_plan step exists and has a fenced bash block invoking frontmatter.validate', () => {
|
||||
assert.ok(validateStep, '<step name="validate_plan"> must exist in agents/gsd-planner.md');
|
||||
assert.ok(bashBlock, 'validate_plan step must have a ```bash fenced block');
|
||||
assert.ok(invocationLine, 'validate_plan step bash block must invoke frontmatter.validate');
|
||||
});
|
||||
|
||||
test('the --schema argument in the bash invocation is NOT a hardcoded literal', () => {
|
||||
// Precondition: both regex checks below use `.test(invocationLine)`, and
|
||||
// RegExp#test coerces a null/undefined argument to the STRING "null"/
|
||||
// "undefined" rather than throwing — neither hardcoded-literal pattern
|
||||
// matches that string, so both negative assertions would pass vacuously
|
||||
// (reporting "not hardcoded") even if the step or its bash block were
|
||||
// deleted entirely. Fail loudly on that precondition first so a deleted
|
||||
// step is reported as exactly that, not as a false "fix confirmed".
|
||||
assert.ok(invocationLine, 'precondition: invocationLine must be found (see the first test in this block)');
|
||||
|
||||
// This is the exact regression: a prior revision had this line read
|
||||
// `--schema plan)` verbatim — a plain, hardcoded, always-the-same-value
|
||||
// literal that an agent executes as-is regardless of mode. Reject BOTH
|
||||
// possible hardcoded literals explicitly, not just one, so a fix that
|
||||
// flips the hardcoded default to plan-gap-closure (breaking standard mode
|
||||
// instead of gap_closure mode) is caught too.
|
||||
assert.ok(
|
||||
!/--schema\s+plan\)/.test(invocationLine),
|
||||
`bash invocation must not hardcode --schema plan — found: ${invocationLine}`
|
||||
);
|
||||
assert.ok(
|
||||
!/--schema\s+plan-gap-closure\)/.test(invocationLine),
|
||||
`bash invocation must not hardcode --schema plan-gap-closure — found: ${invocationLine}`
|
||||
);
|
||||
});
|
||||
|
||||
test('the --schema argument in the bash invocation IS a shell variable reference', () => {
|
||||
// A variable reference means the value is resolved at execution time from
|
||||
// whatever the agent has bound it to, not printed once in the template and
|
||||
// copy-executed unchanged. Matches --schema "$SCHEMA", --schema $SCHEMA,
|
||||
// or --schema "${SCHEMA}".
|
||||
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
||||
assert.ok(
|
||||
varMatch,
|
||||
`bash invocation's --schema argument must be a shell variable (e.g. --schema "$SCHEMA"), not a literal — found: ${invocationLine}`
|
||||
);
|
||||
});
|
||||
|
||||
test('the bound variable is actually conditioned on gap_closure mode in the step prose, and both target schema names are named', () => {
|
||||
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
||||
assert.ok(varMatch, 'precondition: --schema must reference a variable (see previous test)');
|
||||
const varName = varMatch[1];
|
||||
|
||||
// The SAME variable name the bash block reads must appear in the step's prose
|
||||
// (outside the bash block) — otherwise the "binding" is a variable nothing
|
||||
// ever explains how to set, which is not meaningfully better than a literal.
|
||||
const proseOutsideBash = validateStep.replace(/```bash\r?\n[\s\S]*?```/, '');
|
||||
assert.ok(
|
||||
proseOutsideBash.includes(`$${varName}`) || proseOutsideBash.includes(`\`$${varName}\``),
|
||||
`step prose must explain how $${varName} is set — the bash block references it but nothing binds it`
|
||||
);
|
||||
|
||||
// Both concrete schema names this variable can resolve to must be named
|
||||
// somewhere in the step, and gap_closure mode must be the stated condition
|
||||
// for choosing between them. Match the plain `plan` schema as a standalone
|
||||
// backtick-quoted token (`` `plan` ``), not the bare substring "plan" —
|
||||
// a bare-substring check is satisfied incidentally by "verify.plan-structure"
|
||||
// a few lines below even if the plain-plan branch were deleted entirely from
|
||||
// the prose, which would make this assertion unable to ever fail.
|
||||
assert.ok(validateStep.includes('plan-gap-closure'), 'step must name the plan-gap-closure schema');
|
||||
assert.ok(
|
||||
/`plan`/.test(validateStep),
|
||||
'step must name the plain plan schema, as a standalone `plan` token, as the other branch'
|
||||
);
|
||||
assert.ok(/gap_closure mode/i.test(validateStep), 'step must condition the choice on gap_closure mode by name');
|
||||
});
|
||||
|
||||
test('the plan-structure validation call below (unrelated step) is unaffected', () => {
|
||||
// Regression guard for the fix itself: confirm the edit did not touch the
|
||||
// sibling verify.plan-structure invocation in the same step.
|
||||
assert.ok(
|
||||
validateStep.includes('verify.plan-structure "$PLAN_PATH"'),
|
||||
'validate_plan step must still invoke verify.plan-structure unchanged'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-file consistency: schema name used by both files matches (#2847) ──
|
||||
|
||||
describe('#2847: schema name consistency between gsd-planner.md and src/frontmatter.cts', () => {
|
||||
test('gsd-planner.md references the exact schema name "plan-gap-closure"', () => {
|
||||
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
||||
assert.ok(
|
||||
plannerContent.includes('plan-gap-closure'),
|
||||
'agents/gsd-planner.md must reference the literal schema name "plan-gap-closure" ' +
|
||||
'(the exact key registered in FRONTMATTER_SCHEMAS in src/frontmatter.cts) — a ' +
|
||||
'mismatched name would fail at runtime with "Unknown schema"'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -921,6 +921,39 @@ function clearSessionEnv() {
|
||||
for (const k of SESSION_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save + clear GSD_WORKSTREAM and GSD_PROJECT on process.env, paired with
|
||||
* restoreWorkstreamEnv(). planningDir() reads both directly from
|
||||
* process.env when its params are omitted, so a test asserting
|
||||
* workstream/project-scoped behavior must isolate them from ambient shell
|
||||
* state (and from whatever an earlier test in the same process left behind).
|
||||
*
|
||||
* Previously duplicated as a local isolateWorkstreamEnv()/restoreWorkstreamEnv()
|
||||
* pair in tests/phase-locator.test.cjs, and as the GSD_WORKSTREAM/GSD_PROJECT
|
||||
* slice of tests/model-resolver.test.cjs's broader isolateHome()/restoreHome()
|
||||
* (which still isolates HOME/USERPROFILE/GSD_HOME/GSD_RUNTIME locally — that
|
||||
* part is genuinely specific to model-resolver's tests and stays there).
|
||||
*
|
||||
* Module-level save slot (not a returned snapshot) to match the exact
|
||||
* no-arg isolate()/restore() call shape both prior local copies used.
|
||||
*/
|
||||
let _origGsdWorkstream;
|
||||
let _origGsdProject;
|
||||
|
||||
function isolateWorkstreamEnv() {
|
||||
_origGsdWorkstream = process.env.GSD_WORKSTREAM;
|
||||
_origGsdProject = process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
}
|
||||
|
||||
function restoreWorkstreamEnv() {
|
||||
if (_origGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = _origGsdWorkstream;
|
||||
if (_origGsdProject === undefined) delete process.env.GSD_PROJECT;
|
||||
else process.env.GSD_PROJECT = _origGsdProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so
|
||||
* never receives TEST_ENV_BASE on its own.
|
||||
@@ -965,7 +998,7 @@ function installSpawnEnv(overrides = {}) {
|
||||
return { ...process.env, ...testEnvBase(), HOME: home, USERPROFILE: home, ...overrides };
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome };
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome };
|
||||
|
||||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||||
// forces the built-lib require, so a test file that needs neither can still
|
||||
|
||||
@@ -4372,3 +4372,410 @@ describe('#2229 PROPERTY: resolveTierFromConfig never throws and always returns
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-2297-resolve-model-ids-runtime-scoping.test.cjs
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-2297-resolve-model-ids-runtime-scoping', () => {
|
||||
/**
|
||||
* Bug #2297 — `resolve_model_ids:"omit"` must be scoped to the ACTIVE runtime,
|
||||
* not applied blindly whenever it appears anywhere in the merged config.
|
||||
*
|
||||
* Root cause (pre-fix): the installer writes `resolve_model_ids:"omit"` into
|
||||
* the SHARED `~/.gsd/defaults.json` for every runtime that lacks native model
|
||||
* aliases (#1156). Because that file is machine-wide, installing a non-Claude
|
||||
* runtime (e.g. codex) on a box that also runs Claude poisoned Claude's
|
||||
* no-project resolution: Claude would see `resolve_model_ids:"omit"` in the
|
||||
* merged global defaults and return `''` instead of its tier aliases
|
||||
* (opus/sonnet/haiku), silently defeating Claude's adaptive tier distinction.
|
||||
*
|
||||
* Fix (`resolveModelInternal`): the `"omit"` branch now returns `''` ONLY
|
||||
* when either (a) the PROJECT's own `.planning/config.json` explicitly sets
|
||||
* `resolve_model_ids:"omit"` (user intent — #2517 finding #4, unchanged), or
|
||||
* (b) the ACTIVE runtime genuinely lacks native model aliases. A native-alias
|
||||
* runtime (currently only `claude`) ignores an `"omit"` that came solely from
|
||||
* the shared global defaults and falls through to its tier aliases.
|
||||
* Active-runtime precedence: `process.env.GSD_RUNTIME` -> `config.runtime` ->
|
||||
* per-install `.gsd-runtime` marker -> `'claude'` (all canonicalized).
|
||||
*
|
||||
* NOTE: the global-defaults merge path in config-loader.cjs (branch D: "no
|
||||
* .planning/ at all") only fires when the project dir has NO `.planning/`
|
||||
* whatsoever — the moment `.planning/` exists, `~/.gsd/defaults.json` is not
|
||||
* merged for these fields at all. Group A below therefore uses bare
|
||||
* `fs.mkdtempSync` project dirs with no `.planning/` subdir; Group A #4,
|
||||
* Group B, and Group C need a real per-project config and create
|
||||
* `.planning/config.json`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
resolveModelInternal,
|
||||
_setInstallRuntimeMarkerForTests,
|
||||
_resetInstallRuntimeMarkerCacheForTests,
|
||||
} = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||
|
||||
const { isolateWorkstreamEnv, restoreWorkstreamEnv } = require('./helpers.cjs');
|
||||
|
||||
// HOME / GSD_HOME / GSD_RUNTIME isolation — config-loader.cjs reads global
|
||||
// defaults from path.join(process.env.GSD_HOME || os.homedir(), '.gsd', 'defaults.json').
|
||||
// Isolate HOME and GSD_HOME to a fresh tmpdir per test, and save/restore
|
||||
// GSD_RUNTIME (several tests set it directly to drive the active-runtime
|
||||
// chain) plus GSD_WORKSTREAM/GSD_PROJECT via the shared helpers.cjs
|
||||
// isolateWorkstreamEnv()/restoreWorkstreamEnv() pair (planningDir() reads both
|
||||
// directly from process.env when its params are omitted, so an ambient value
|
||||
// in a developer's shell could redirect projectExplicitlySetsOmit()'s reads).
|
||||
let _origHome;
|
||||
let _origUserProfile;
|
||||
let _origGsdHome;
|
||||
let _origGsdRuntime;
|
||||
let _isolatedHome;
|
||||
|
||||
function isolateHome() {
|
||||
_origHome = process.env.HOME;
|
||||
_origUserProfile = process.env.USERPROFILE;
|
||||
_origGsdHome = process.env.GSD_HOME;
|
||||
_origGsdRuntime = process.env.GSD_RUNTIME;
|
||||
isolateWorkstreamEnv();
|
||||
_isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-home-'));
|
||||
process.env.HOME = _isolatedHome;
|
||||
// Windows resolves the home dir from USERPROFILE, not HOME.
|
||||
process.env.USERPROFILE = _isolatedHome;
|
||||
process.env.GSD_HOME = _isolatedHome;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
}
|
||||
|
||||
function restoreHome() {
|
||||
if (_origHome === undefined) delete process.env.HOME; else process.env.HOME = _origHome;
|
||||
if (_origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = _origUserProfile;
|
||||
if (_origGsdHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = _origGsdHome;
|
||||
if (_origGsdRuntime === undefined) delete process.env.GSD_RUNTIME; else process.env.GSD_RUNTIME = _origGsdRuntime;
|
||||
restoreWorkstreamEnv();
|
||||
rmDir(_isolatedHome);
|
||||
_isolatedHome = null;
|
||||
}
|
||||
|
||||
function rmDir(dir) {
|
||||
if (typeof dir !== 'string' || dir.length === 0) return;
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- carries the same maxRetries/retryDelay budget as helpers.cleanup; used for both the isolated-home and bare project temp dirs
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||
}
|
||||
|
||||
function writeGlobalDefaults(obj) {
|
||||
fs.mkdirSync(path.join(_isolatedHome, '.gsd'), { recursive: true });
|
||||
fs.writeFileSync(path.join(_isolatedHome, '.gsd', 'defaults.json'), JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// Bare project dir with NO .planning/ subdirectory — needed to exercise the
|
||||
// config-loader's global-defaults merge branch (see header comment above).
|
||||
function mkProjNoPlanning() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-noplan-'));
|
||||
}
|
||||
|
||||
// Project dir WITH a .planning/config.json — the normal "inside a project" path.
|
||||
function mkProjWithConfig(obj) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(obj, null, 2));
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── Group A: GLOBAL-defaults "omit" is runtime-scoped (the #2297 fix) ─────
|
||||
describe('#2297: global-defaults resolve_model_ids:"omit" is scoped to the active runtime', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('no runtime signal defaults to claude: executor and planner get distinct non-empty tier aliases (acceptance #3)', () => {
|
||||
// Global defaults poison the shared file with "omit" (simulating a
|
||||
// non-Claude runtime having been installed on this machine). With no
|
||||
// .planning/config.json (no project) and no GSD_RUNTIME, the active
|
||||
// runtime falls back to 'claude', which has native aliases and must
|
||||
// ignore the poisoned global "omit" — the adaptive tier distinction
|
||||
// between executor (sonnet) and planner (opus) must survive.
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
|
||||
const executor = resolveModelInternal(projDir, 'gsd-executor');
|
||||
const planner = resolveModelInternal(projDir, 'gsd-planner');
|
||||
|
||||
assert.strictEqual(executor, 'sonnet');
|
||||
assert.strictEqual(planner, 'opus');
|
||||
assert.notStrictEqual(executor, '');
|
||||
assert.notStrictEqual(planner, '');
|
||||
assert.notStrictEqual(executor, planner);
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="claude" explicitly: executor still resolves to "sonnet" (claude ignores global omit)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'claude';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="codex": a non-alias runtime still honors the global omit (acceptance #4)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
});
|
||||
|
||||
// #2297 correctness-review BLOCKER: resolveActiveRuntime() must canonicalize
|
||||
// its candidates via resolveRuntimeNameFromCandidates before checking
|
||||
// RUNTIMES_WITH_NATIVE_ALIASES, or an alias/case variant of "claude" would
|
||||
// fail the Set('claude').has() check and wrongly fall through to honoring the
|
||||
// poisoned global omit. These would FAIL against a non-canonicalizing resolver.
|
||||
test('GSD_RUNTIME="claude-code" (alias, not canonical "claude"): executor and planner still ignore the global omit', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'claude-code';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), 'opus');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="Claude" (case variant): executor still resolves to "sonnet" (canonicalization is case-insensitive)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'Claude';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('project config.runtime="codex" (no resolve_model_ids in project) takes precedence over GSD_RUNTIME/marker in the active-runtime chain', () => {
|
||||
// config.runtime is checked before GSD_RUNTIME / the install marker. This
|
||||
// scenario uses a REAL project (.planning/config.json present), so the
|
||||
// config-loader does NOT merge ~/.gsd/defaults.json for resolve_model_ids
|
||||
// at all here (see header comment above) — resolution instead reaches the
|
||||
// #2517 runtime-tier path (step 3 in resolveModelInternal, which fires
|
||||
// before the omit gate) and returns codex's native sonnet-tier model id
|
||||
// directly, rather than the omit gate's ''. Verified empirically: the
|
||||
// built resolver returns 'gpt-5.6-terra', not ''. Assert it is non-empty
|
||||
// and NOT a claude alias, which is the property this test actually needs
|
||||
// to guarantee (config.runtime, not GSD_RUNTIME/env, drove the resolution).
|
||||
projDir = mkProjWithConfig({ runtime: 'codex' });
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' }); // irrelevant: not merged when .planning/ exists
|
||||
|
||||
const result = resolveModelInternal(projDir, 'gsd-executor');
|
||||
assert.notStrictEqual(result, '');
|
||||
assert.ok(
|
||||
!['sonnet', 'opus', 'haiku'].includes(result),
|
||||
`expected a non-claude-alias result for config.runtime="codex", got ${JSON.stringify(result)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('install-order independence (acceptance #1/#2): a global omit poisoned by a prior non-Claude install does not affect Claude resolution, and Claude retains its adaptive tier distinction', () => {
|
||||
// Resolution depends on the RESOLVING runtime (active runtime at call
|
||||
// time), not on install order — installing codex (or any non-alias
|
||||
// runtime) before/after Claude must never change what Claude itself
|
||||
// resolves to. Global omit present, no project, no runtime signal ->
|
||||
// default 'claude' -> tier aliases survive. Distinct from the first Group A
|
||||
// test above: this asserts install-order independence AND, specifically,
|
||||
// that executor/planner remain DIFFERENT tiers under the poisoned global
|
||||
// omit — i.e. install order never collapses Claude's adaptive tier
|
||||
// distinction into a single omitted value.
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
|
||||
const executor = resolveModelInternal(projDir, 'gsd-executor');
|
||||
const planner = resolveModelInternal(projDir, 'gsd-planner');
|
||||
|
||||
assert.strictEqual(executor, 'sonnet');
|
||||
assert.strictEqual(planner, 'opus');
|
||||
assert.notStrictEqual(executor, planner, 'install-order poisoning must not collapse the adaptive tier distinction');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group B: explicit PROJECT "omit" is still honored for EVERY runtime ───
|
||||
// (#2517 finding #4 — preserved, NOT changed by #2297.)
|
||||
describe('#2297: explicit project-level resolve_model_ids:"omit" is honored regardless of runtime', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('no runtime set, explicit project omit -> "" even though the default runtime is claude', () => {
|
||||
projDir = mkProjWithConfig({ resolve_model_ids: 'omit' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
|
||||
test('runtime:"claude" + explicit project omit -> "" (mirrors #2517 finding #4)', () => {
|
||||
projDir = mkProjWithConfig({ runtime: 'claude', resolve_model_ids: 'omit' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group B2: projectExplicitlySetsOmit is workstream-scope aware (#2297) ──
|
||||
// The root .planning/config.json does NOT set resolve_model_ids, but the
|
||||
// ACTIVE workstream's own config.json does — projectExplicitlySetsOmit()
|
||||
// resolves via planningDir(cwd) (workstream layer wins over root, mirroring
|
||||
// loadConfig's precedence), so the workstream's explicit "omit" must still be
|
||||
// honored even though no global default and the default runtime (claude) would
|
||||
// otherwise have returned a tier alias.
|
||||
describe('#2297: explicit project-level "omit" is honored at the active-workstream config layer', () => {
|
||||
let projDir;
|
||||
let _origGsdWorkstreamForBlock;
|
||||
beforeEach(() => {
|
||||
isolateHome(); // clears GSD_WORKSTREAM/GSD_PROJECT as part of hermeticity
|
||||
projDir = null;
|
||||
_origGsdWorkstreamForBlock = process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (_origGsdWorkstreamForBlock === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = _origGsdWorkstreamForBlock;
|
||||
rmDir(projDir);
|
||||
restoreHome();
|
||||
});
|
||||
|
||||
test('root config has no resolve_model_ids, but the active workstream config sets "omit" -> "" despite default runtime claude', () => {
|
||||
const ws = 'ws-alpha';
|
||||
projDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2297-proj-ws-'));
|
||||
fs.mkdirSync(path.join(projDir, '.planning'), { recursive: true });
|
||||
// Root config exists but does NOT set resolve_model_ids at all.
|
||||
fs.writeFileSync(
|
||||
path.join(projDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced' }, null, 2)
|
||||
);
|
||||
// The active workstream's own config explicitly sets "omit".
|
||||
const wsConfigDir = path.join(projDir, '.planning', 'workstreams', ws);
|
||||
fs.mkdirSync(wsConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wsConfigDir, 'config.json'),
|
||||
JSON.stringify({ resolve_model_ids: 'omit' }, null, 2)
|
||||
);
|
||||
process.env.GSD_WORKSTREAM = ws;
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group C: explicit `true` still materializes full model ids (acceptance #5) ──
|
||||
describe('#2297: resolve_model_ids:true still materializes full Claude model ids', () => {
|
||||
let projDir;
|
||||
beforeEach(() => { isolateHome(); projDir = null; });
|
||||
afterEach(() => { rmDir(projDir); restoreHome(); });
|
||||
|
||||
test('resolve_model_ids:true + balanced profile -> full materialized claude-opus-4-8 id', () => {
|
||||
projDir = mkProjWithConfig({ resolve_model_ids: true, model_profile: 'balanced' });
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-planner'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group D: registry parity guard ─────────────────────────────────────────
|
||||
describe('#2297: capability-registry nativeModelAliases parity guard', () => {
|
||||
test('exactly the runtimes with hostBehaviors.nativeModelAliases:true match RUNTIMES_WITH_NATIVE_ALIASES ([\'claude\'])', () => {
|
||||
// The model-resolver hardcodes RUNTIMES_WITH_NATIVE_ALIASES = new Set(['claude'])
|
||||
// rather than reading the registry at runtime. This test keeps that
|
||||
// hardcoded set honest against the generated registry's actual contract:
|
||||
// registry.runtimes[id].runtime.hostBehaviors.nativeModelAliases.
|
||||
// If a future runtime gains nativeModelAliases:true, this fails loudly so
|
||||
// RUNTIMES_WITH_NATIVE_ALIASES in model-resolver.cts is updated in lockstep.
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const nativeAliasRuntimes = Object.keys(registry.runtimes)
|
||||
.filter((id) => registry.runtimes[id]?.runtime?.hostBehaviors?.nativeModelAliases === true)
|
||||
.sort();
|
||||
|
||||
assert.deepStrictEqual(nativeAliasRuntimes, ['claude']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group E: installer writes the per-install .gsd-runtime marker ─────────
|
||||
describe('#2297: installer emits the gsd-core/.gsd-runtime marker (fixture parity)', () => {
|
||||
test('claude and codex install-tree fixtures both list gsd-core/.gsd-runtime', () => {
|
||||
// These fixtures are flat JSON arrays of install-relative paths, generated
|
||||
// by running the real installer (tests/fixtures/install-tree/*.json). Their
|
||||
// presence here proves the installer actually emits the per-install marker
|
||||
// that resolveActiveRuntime()'s precedence chain falls back to.
|
||||
const claudeFixturePath = path.join(__dirname, 'fixtures', 'install-tree', 'claude.json');
|
||||
const codexFixturePath = path.join(__dirname, 'fixtures', 'install-tree', 'codex.json');
|
||||
|
||||
const claudeFixture = JSON.parse(fs.readFileSync(claudeFixturePath, 'utf8'));
|
||||
const codexFixture = JSON.parse(fs.readFileSync(codexFixturePath, 'utf8'));
|
||||
|
||||
assert.ok(Array.isArray(claudeFixture), 'expected claude.json fixture to be a flat array of paths');
|
||||
assert.ok(Array.isArray(codexFixture), 'expected codex.json fixture to be a flat array of paths');
|
||||
|
||||
assert.ok(
|
||||
claudeFixture.includes('gsd-core/.gsd-runtime'),
|
||||
'expected claude.json install-tree fixture to include gsd-core/.gsd-runtime'
|
||||
);
|
||||
assert.ok(
|
||||
codexFixture.includes('gsd-core/.gsd-runtime'),
|
||||
'expected codex.json install-tree fixture to include gsd-core/.gsd-runtime'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group F: the install-marker precedence rung, driven directly (#2297) ──
|
||||
// Previously untested: with no GSD_RUNTIME and no project config.runtime, the
|
||||
// active runtime falls all the way through to the per-install .gsd-runtime
|
||||
// marker (third precedence rung). The dev/source tree has no real marker file,
|
||||
// so these tests drive that rung directly via the _setInstallRuntimeMarkerForTests
|
||||
// / _resetInstallRuntimeMarkerCacheForTests seams exported specifically for this
|
||||
// purpose (#2297 correctness-review gap).
|
||||
describe('#2297: install-marker precedence rung (GSD_RUNTIME and config.runtime both absent)', () => {
|
||||
let projDir;
|
||||
beforeEach(() => {
|
||||
isolateHome(); // also deletes GSD_RUNTIME
|
||||
projDir = null;
|
||||
// Belt-and-suspenders: the marker rung is only reached when GSD_RUNTIME and
|
||||
// config.runtime are both absent; isolateHome() already deletes GSD_RUNTIME.
|
||||
delete process.env.GSD_RUNTIME;
|
||||
});
|
||||
afterEach(() => {
|
||||
rmDir(projDir);
|
||||
restoreHome();
|
||||
// CRITICAL: reset the module-level marker cache after every case in this
|
||||
// block so a set value never leaks into a later case here, or into any
|
||||
// OTHER describe block in this file (readInstallRuntimeMarker() otherwise
|
||||
// memoizes the first value it sees for the lifetime of the process).
|
||||
_resetInstallRuntimeMarkerCacheForTests();
|
||||
});
|
||||
|
||||
test('marker="codex" (non-alias runtime): honors the poisoned global omit -> ""', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('codex');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
});
|
||||
|
||||
test('marker="claude": ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('claude');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('marker="claude-code" (alias): canonicalized to "claude" and still ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('claude-code');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('marker unset (null): falls through to the "claude" default and ignores the poisoned global omit -> "sonnet"', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests(null);
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,13 +18,17 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, afterEach } = require('node:test');
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const planDependencyGraph = require('../gsd-core/bin/lib/plan-dependency-graph.cjs');
|
||||
const {
|
||||
runGsdTools, createTempProject, cleanup, isolateWorkstreamEnv, restoreWorkstreamEnv,
|
||||
} = require('./helpers.cjs');
|
||||
|
||||
// ─── findPhaseInternal — basic active-phase lookup ────────────────────────────
|
||||
|
||||
@@ -457,3 +461,958 @@ describe('#2237: ambiguous phase-directory collision', () => {
|
||||
assert.ok(!result.ambiguous_matches, 'single match must not set ambiguous_matches');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Folded from tests/fix-2830-halted-plan-dependents.test.cjs (#3335 H3 fold).
|
||||
//
|
||||
// #2830: a halted plan must leave its direct/transitive dependents blocked,
|
||||
// never offered as ordinary runnable work. Two independent "which plans are
|
||||
// incomplete" readers exist — phase.cts's cmdPhasePlanIndex (parses
|
||||
// depends_on for wave assignment) and phase-locator.cts's
|
||||
// searchPhaseInDir/findPhaseInternal (the phase-location primitive) — both
|
||||
// must report a halted dependent as blocked while leaving the pre-existing
|
||||
// incomplete/incomplete_plans fields byte-identical. computeHaltPropagation
|
||||
// (plan-dependency-graph.cts) tests stay in this file: no dedicated
|
||||
// plan-dependency-graph test file exists yet, and #3335 names this file as
|
||||
// the fold target.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-2830-halted-plan-dependents', () => {
|
||||
|
||||
// Fixture: 01-01 halted spike (SUMMARY status: halted), 01-02 depends_on
|
||||
// 01-01 (direct dependent), 01-03 depends_on 01-02 (transitive, 2 hops),
|
||||
// 01-04 decoupled (no depends_on — the negative case).
|
||||
|
||||
function writePlan(phaseDir, filename, frontmatterLines, taskLine = '<task>Work</task>') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
[
|
||||
'---',
|
||||
...frontmatterLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${filename}`,
|
||||
'',
|
||||
`<objective>${filename}</objective>`,
|
||||
'',
|
||||
taskLine,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSummary(phaseDir, filename, status = 'complete') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
['---', 'phase: 01-alpha', 'plan: 01', `status: ${status}`, 'completed: 2026-08-02', '---', '', '# Summary', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseFixture(tmpDir) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '01-01-PLAN.md', ['wave: 1', 'objective: Halted spike', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '01-01-SUMMARY.md', 'halted');
|
||||
|
||||
writePlan(phaseDir, '01-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Direct dependent', 'autonomous: true', 'depends_on:', ' - 01-01',
|
||||
]);
|
||||
|
||||
writePlan(phaseDir, '01-03-PLAN.md', [
|
||||
'wave: 3', 'objective: Transitive dependent', 'autonomous: true', 'depends_on:', ' - 01-02',
|
||||
]);
|
||||
|
||||
writePlan(phaseDir, '01-04-PLAN.md', ['wave: 1', 'objective: Decoupled plan', 'autonomous: true']);
|
||||
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
describe('phase-plan-index: halt propagation (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('direct dependent of a halted plan is blocked, not runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p02 = data.plans.find((p) => p.id === '01-02');
|
||||
assert.ok(p02, '01-02 should be present');
|
||||
assert.deepEqual(p02.blocked_by, ['01-01'], '01-02 should be blocked by the halted 01-01');
|
||||
assert.strictEqual(p02.has_summary, false);
|
||||
assert.ok(!data.runnable.includes('01-02'), '01-02 must NOT be in the runnable view');
|
||||
});
|
||||
|
||||
test('transitive dependent (2 hops) is blocked via chain', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p03 = data.plans.find((p) => p.id === '01-03');
|
||||
assert.ok(p03, '01-03 should be present');
|
||||
assert.deepEqual(p03.blocked_by, ['01-01'], '01-03 should be transitively blocked by 01-01');
|
||||
assert.ok(!data.runnable.includes('01-03'), '01-03 must NOT be in the runnable view');
|
||||
});
|
||||
|
||||
test('transitive dependent at 3 hops stays blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = buildBaseFixture(tmpDir);
|
||||
writePlan(phaseDir, '01-05-PLAN.md', [
|
||||
'wave: 4', 'objective: 3-hop dependent', 'autonomous: true', 'depends_on:', ' - 01-03',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p05 = data.plans.find((p) => p.id === '01-05');
|
||||
assert.ok(p05, '01-05 should be present');
|
||||
assert.deepEqual(p05.blocked_by, ['01-01'], '01-05 should stay blocked at 3 hops');
|
||||
assert.ok(!data.runnable.includes('01-05'));
|
||||
});
|
||||
|
||||
test('diamond dependency is blocked by both halted ancestors', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-diamond');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '02-01-PLAN.md', ['wave: 1', 'objective: Halted A', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-02-PLAN.md', ['wave: 1', 'objective: Halted B', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-02-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Diamond join', 'autonomous: true',
|
||||
'depends_on:', ' - 02-01', ' - 02-02',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '2', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p03 = data.plans.find((p) => p.id === '02-03');
|
||||
assert.ok(p03, '02-03 should be present');
|
||||
assert.deepEqual(
|
||||
[...p03.blocked_by].sort(),
|
||||
['02-01', '02-02'],
|
||||
'02-03 should be blocked by BOTH halted ancestors, deduplicated',
|
||||
);
|
||||
});
|
||||
|
||||
test('unrelated decoupled plan stays runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p04 = data.plans.find((p) => p.id === '01-04');
|
||||
assert.ok(p04, '01-04 should be present');
|
||||
assert.deepEqual(p04.blocked_by, [], '01-04 has no depends_on, so it must not be blocked');
|
||||
assert.ok(data.runnable.includes('01-04'), '01-04 (decoupled) must stay in the runnable view');
|
||||
});
|
||||
|
||||
test('incomplete field stays byte-identical when blocked plans are present', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
// Pre-#2830 semantics: incomplete = every plan without a matching SUMMARY,
|
||||
// blocked or not. 01-01 has a SUMMARY (halted, but still a SUMMARY) so it
|
||||
// is excluded; 01-02/01-03/01-04 have none, so all three are included —
|
||||
// exactly as they would be with no halt-awareness at all.
|
||||
assert.deepEqual(
|
||||
[...data.incomplete].sort(),
|
||||
['01-02', '01-03', '01-04'],
|
||||
'incomplete must list every no-SUMMARY plan regardless of blocked status',
|
||||
);
|
||||
});
|
||||
|
||||
test('dependency on an ordinary incomplete (non-halted) plan is not "blocked"', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-ordinary');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
// 03-01 has NO summary at all (ordinary incomplete, not halted).
|
||||
writePlan(phaseDir, '03-01-PLAN.md', ['wave: 1', 'objective: Ordinary unfinished plan', 'autonomous: true']);
|
||||
writePlan(phaseDir, '03-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on ordinary incomplete plan', 'autonomous: true',
|
||||
'depends_on:', ' - 03-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '3', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '03-01');
|
||||
assert.ok(p01, '03-01 should be present');
|
||||
const p02 = data.plans.find((p) => p.id === '03-02');
|
||||
assert.ok(p02, '03-02 should be present');
|
||||
assert.deepEqual(p01.blocked_by, [], '03-01 (no summary) is not itself halted or blocked');
|
||||
assert.deepEqual(p02.blocked_by, [], '03-02 must NOT be "blocked" by an ordinary (non-halted) dependency');
|
||||
assert.ok(data.runnable.includes('03-02'), '03-02 stays runnable — only a halted upstream blocks');
|
||||
});
|
||||
|
||||
test('unresolved depends_on id is ignored, not blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-unresolved');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '04-01-PLAN.md', [
|
||||
'wave: 1', 'objective: References a nonexistent plan', 'autonomous: true',
|
||||
'depends_on:', ' - 99-99',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '4', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should not throw on an unresolved dependency: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '04-01');
|
||||
assert.ok(p01, '04-01 should be present');
|
||||
assert.deepEqual(p01.blocked_by, [], 'an unresolved depends_on id must not produce a spurious block');
|
||||
assert.ok(data.runnable.includes('04-01'));
|
||||
});
|
||||
|
||||
test('malformed (unterminated) SUMMARY frontmatter fails open to not-halted', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-malformed');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '05-01-PLAN.md', ['wave: 1', 'objective: Has a malformed summary', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '05-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '05-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on 05-01', 'autonomous: true', 'depends_on:', ' - 05-01',
|
||||
]);
|
||||
|
||||
// extractFrontmatter must fail safe on an unterminated frontmatter block
|
||||
// (no closing '---'): isSummaryHalted's try/catch wraps BOTH the
|
||||
// fs.readFileSync call and the extractFrontmatter call, so this exercises
|
||||
// the identical fail-open catch site a genuine fs read error would hit.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '05-01-SUMMARY.md'),
|
||||
'---\nphase: 05-malformed\nplan: 01\nstatus: halted\n', // no closing '---'
|
||||
);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '5', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index must not throw on malformed frontmatter: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '05-01');
|
||||
assert.ok(p01, '05-01 should be present');
|
||||
const p02 = data.plans.find((p) => p.id === '05-02');
|
||||
assert.ok(p02, '05-02 should be present');
|
||||
assert.strictEqual(p01.halted, false, 'unterminated frontmatter must fail open to not-halted');
|
||||
assert.deepEqual(p02.blocked_by, [], 'dependent of a fail-open-not-halted plan must not be blocked');
|
||||
});
|
||||
|
||||
test('CRLF SUMMARY frontmatter still detects status: halted', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '06-crlf');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '06-01-PLAN.md', ['wave: 1', 'objective: Halted with CRLF summary', 'autonomous: true']);
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '06-01-SUMMARY.md'),
|
||||
['---', 'phase: 06-crlf', 'plan: 01', 'status: halted', 'completed: 2026-08-02', '---', '', '# Summary', ''].join('\r\n'),
|
||||
);
|
||||
writePlan(phaseDir, '06-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on CRLF-summarized halt', 'autonomous: true', 'depends_on:', ' - 06-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '6', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed on CRLF frontmatter: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '06-01');
|
||||
assert.ok(p01, '06-01 should be present');
|
||||
const p02 = data.plans.find((p) => p.id === '06-02');
|
||||
assert.ok(p02, '06-02 should be present');
|
||||
assert.strictEqual(p01.halted, true, 'CRLF SUMMARY frontmatter must still parse status: halted');
|
||||
assert.deepEqual(p02.blocked_by, ['06-01'], 'dependent must be blocked even when the halt was recorded with CRLF newlines');
|
||||
});
|
||||
|
||||
test('status complete does not block dependents', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-complete');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '07-01-PLAN.md', ['wave: 1', 'objective: Ordinary completion', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-01-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on completed plan', 'autonomous: true', 'depends_on:', ' - 07-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '7', '--raw'], tmpDir);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const p01 = data.plans.find((p) => p.id === '07-01');
|
||||
assert.ok(p01, '07-01 should be present');
|
||||
const p02 = data.plans.find((p) => p.id === '07-02');
|
||||
assert.ok(p02, '07-02 should be present');
|
||||
assert.strictEqual(p01.halted, false);
|
||||
assert.deepEqual(p02.blocked_by, []);
|
||||
assert.ok(data.runnable.includes('07-02'));
|
||||
});
|
||||
|
||||
test('dependency cycle detection is unaffected by halt propagation', () => {
|
||||
tmpDir = createTempProject('gsd-2830-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '08-cycle');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '08-01-PLAN.md', [
|
||||
'wave: 1', 'objective: Cycle A', 'autonomous: true', 'depends_on:', ' - 08-02',
|
||||
]);
|
||||
writePlan(phaseDir, '08-02-PLAN.md', [
|
||||
'wave: 1', 'objective: Cycle B', 'autonomous: true', 'depends_on:', ' - 08-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '8', '--raw'], tmpDir);
|
||||
// CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs" bans
|
||||
// regex-matching a child process's human-readable stderr/reason prose —
|
||||
// assert on the typed failure signal (`result.success`) instead. Pair it
|
||||
// with a differential fixture: the identical dependency shape with the
|
||||
// cycle edge removed must succeed, isolating the cycle as the cause.
|
||||
assert.strictEqual(result.success, false, 'a dependency cycle must still fail the command');
|
||||
|
||||
const acyclicDir = path.join(tmpDir, '.planning', 'phases', '09-nocycle');
|
||||
fs.mkdirSync(acyclicDir, { recursive: true });
|
||||
writePlan(acyclicDir, '09-01-PLAN.md', ['wave: 1', 'objective: No cycle A', 'autonomous: true']);
|
||||
writePlan(acyclicDir, '09-02-PLAN.md', [
|
||||
'wave: 1', 'objective: No cycle B', 'autonomous: true', 'depends_on:', ' - 09-01',
|
||||
]);
|
||||
const acyclicResult = runGsdTools(['phase-plan-index', '9', '--raw'], tmpDir);
|
||||
assert.strictEqual(
|
||||
acyclicResult.success,
|
||||
true,
|
||||
`the identical dependency shape without the cycle edge must succeed, isolating the cycle as the cause of the failure above: ${acyclicResult.error}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPhaseInternal: halt propagation (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('direct dependent of a halted plan is blocked', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(result, 'expected a result');
|
||||
assert.deepEqual(result.blocked_by['01-02-PLAN.md'], ['01-01'], '01-02 should be blocked by halted 01-01');
|
||||
assert.ok(!result.runnable_plans.includes('01-02-PLAN.md'));
|
||||
});
|
||||
|
||||
test('transitive dependent is blocked via chain', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(result.blocked_by['01-03-PLAN.md'], ['01-01'], '01-03 should be transitively blocked');
|
||||
assert.ok(!result.runnable_plans.includes('01-03-PLAN.md'));
|
||||
});
|
||||
|
||||
test('diamond dependency blocked by both halted ancestors', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-diamond');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '02-01-PLAN.md', ['wave: 1', 'objective: Halted A', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-02-PLAN.md', ['wave: 1', 'objective: Halted B', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '02-02-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '02-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Diamond join', 'autonomous: true',
|
||||
'depends_on:', ' - 02-01', ' - 02-02',
|
||||
]);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '2');
|
||||
assert.ok(result, 'expected a result');
|
||||
assert.deepEqual(
|
||||
[...result.blocked_by['02-03-PLAN.md']].sort(),
|
||||
['02-01', '02-02'],
|
||||
'diamond join should be blocked by both halted ancestors, deduplicated',
|
||||
);
|
||||
});
|
||||
|
||||
test('unrelated decoupled plan stays runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(result.runnable_plans.includes('01-04-PLAN.md'), '01-04 (decoupled) must stay runnable');
|
||||
assert.strictEqual(result.blocked_by['01-04-PLAN.md'], undefined);
|
||||
});
|
||||
|
||||
test('incomplete_plans stays byte-identical when blocked plans are present', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(
|
||||
[...result.incomplete_plans].sort(),
|
||||
['01-02-PLAN.md', '01-03-PLAN.md', '01-04-PLAN.md'],
|
||||
'incomplete_plans must list every no-SUMMARY plan regardless of blocked status',
|
||||
);
|
||||
});
|
||||
|
||||
test('halted_plans reports the halted plan itself by filename', () => {
|
||||
tmpDir = createTempProject('gsd-2830-pl-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.deepEqual(result.halted_plans, ['01-01-PLAN.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parity: phase-plan-index and findPhaseInternal agree on blocking (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('same fixture yields the same blocked-plan set and cause set from both readers', () => {
|
||||
tmpDir = createTempProject('gsd-2830-parity-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const cliResult = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
|
||||
assert.ok(cliResult.success, `phase-plan-index should succeed: ${cliResult.error}`);
|
||||
const cliData = JSON.parse(cliResult.output);
|
||||
|
||||
const locatorResult = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.ok(locatorResult, 'findPhaseInternal should return a result');
|
||||
|
||||
// Build { planId -> sorted cause list } from each reader and require them
|
||||
// to be structurally identical (id-mapped, since one reader keys by bare
|
||||
// id and the other by filename).
|
||||
const cliBlocked = {};
|
||||
for (const plan of cliData.plans) {
|
||||
if (plan.blocked_by.length > 0) cliBlocked[plan.id] = [...plan.blocked_by].sort();
|
||||
}
|
||||
|
||||
const locatorBlocked = {};
|
||||
for (const [filename, causes] of Object.entries(locatorResult.blocked_by)) {
|
||||
const planId = filename.replace(/-PLAN\.md$/i, '').replace(/^PLAN\.md$/i, '');
|
||||
locatorBlocked[planId] = [...causes].sort();
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
locatorBlocked,
|
||||
cliBlocked,
|
||||
'phase-plan-index and findPhaseInternal must report the exact same blocked-plan-id -> cause-set mapping',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHaltPropagation: graph invariants (#2830)', () => {
|
||||
test('a node with no depends_on and not halted is never blocked', () => {
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation([
|
||||
{ id: 'A', resolvedDependsOn: [], halted: false },
|
||||
]);
|
||||
assert.strictEqual(blockedBy.get('A'), undefined);
|
||||
});
|
||||
|
||||
test('a halted node is never blocked by itself', () => {
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation([
|
||||
{ id: 'A', resolvedDependsOn: [], halted: true },
|
||||
]);
|
||||
assert.strictEqual(blockedBy.get('A'), undefined, 'a halted plan is halted, not "blocked"');
|
||||
});
|
||||
|
||||
test('precomputedOrder (the phase.cts call shape) yields the same result as self-derived order', () => {
|
||||
// phase.cts's cmdPhasePlanIndex passes computeDependencyLevels's own
|
||||
// topological order in as `precomputedOrder` so this function does not
|
||||
// re-run Kahn's algorithm. Assert both call shapes agree.
|
||||
const nodes = [
|
||||
{ id: 'A', resolvedDependsOn: [], halted: true },
|
||||
{ id: 'B', resolvedDependsOn: ['A'], halted: false },
|
||||
{ id: 'C', resolvedDependsOn: ['B'], halted: false },
|
||||
];
|
||||
const selfDerived = planDependencyGraph.computeHaltPropagation(nodes);
|
||||
const withPrecomputed = planDependencyGraph.computeHaltPropagation(nodes, ['A', 'B', 'C']);
|
||||
assert.deepEqual([...withPrecomputed.blockedBy.entries()], [...selfDerived.blockedBy.entries()]);
|
||||
assert.deepEqual(withPrecomputed.order, ['A', 'B', 'C']);
|
||||
assert.strictEqual(withPrecomputed.visited, 3);
|
||||
});
|
||||
|
||||
// Random DAG over N nodes: edges only point from a lower index to a higher
|
||||
// index (guarantees acyclicity by construction, independent of the code
|
||||
// under test), with a random halted flag per node.
|
||||
//
|
||||
// Acyclic BY CONSTRUCTION: `to` is always drawn strictly above `from`, so
|
||||
// no `.filter()` is involved. A filter here is not merely slower — with
|
||||
// n === 1 the predicate `from < to` is unsatisfiable and fast-check retries
|
||||
// generation forever, which hung the whole suite (the runner sets
|
||||
// --test-timeout=0, so it never dies). Hoisted to describe scope so the
|
||||
// regression test below can sample the identical arbitrary.
|
||||
const dagArb = fc.integer({ min: 1, max: 12 }).chain((n) => {
|
||||
const ids = Array.from({ length: n }, (_, i) => `N${i}`);
|
||||
const edgeArb = n < 2
|
||||
? fc.constant([])
|
||||
: fc.array(
|
||||
fc.integer({ min: 0, max: n - 2 }).chain((from) =>
|
||||
fc.integer({ min: from + 1, max: n - 1 }).map((to) => ({ from, to }))),
|
||||
{ maxLength: n * 2 },
|
||||
);
|
||||
const haltedArb = fc.array(fc.boolean(), { minLength: n, maxLength: n });
|
||||
return fc.record({ ids: fc.constant(ids), edges: edgeArb, halted: haltedArb });
|
||||
});
|
||||
|
||||
test('fast-check — blocked set matches reachability from halted nodes', () => {
|
||||
fc.assert(
|
||||
fc.property(dagArb, ({ ids, edges, halted }) => {
|
||||
const dependsOn = new Map(ids.map((id) => [id, []]));
|
||||
for (const { from, to } of edges) {
|
||||
dependsOn.get(ids[from]).push(ids[to]);
|
||||
}
|
||||
const nodes = ids.map((id, i) => ({
|
||||
id,
|
||||
resolvedDependsOn: dependsOn.get(id),
|
||||
halted: halted[i],
|
||||
}));
|
||||
|
||||
const { blockedBy } = planDependencyGraph.computeHaltPropagation(nodes);
|
||||
|
||||
// Reference model: reachability via depends_on edges from a halted node.
|
||||
const haltedSet = new Set(nodes.filter((n) => n.halted).map((n) => n.id));
|
||||
const dependsOnMap = new Map(nodes.map((n) => [n.id, n.resolvedDependsOn]));
|
||||
function reachableHaltedCauses(id, seen = new Set()) {
|
||||
const causes = new Set();
|
||||
for (const dep of dependsOnMap.get(id) ?? []) {
|
||||
if (seen.has(dep)) continue;
|
||||
seen.add(dep);
|
||||
if (haltedSet.has(dep)) causes.add(dep);
|
||||
for (const c of reachableHaltedCauses(dep, seen)) causes.add(c);
|
||||
}
|
||||
return causes;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const expected = [...reachableHaltedCauses(n.id)].sort();
|
||||
const actual = [...(blockedBy.get(n.id) ?? [])].sort();
|
||||
assert.deepEqual(
|
||||
actual,
|
||||
expected,
|
||||
`node ${n.id}: computeHaltPropagation blockedBy must equal halted-reachability`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 50, seed: 7 },
|
||||
);
|
||||
});
|
||||
|
||||
test('the DAG generator terminates on the degenerate single-node case (regression: unsatisfiable filter hung the suite)', () => {
|
||||
// Bounded, non-hanging sample: if edgeArb regresses to a `.filter(from < to)`
|
||||
// over a forced-equal {from, to} pair (n === 1), fast-check would retry
|
||||
// generation forever and this assertion would never run.
|
||||
const samples = fc.sample(dagArb, { numRuns: 20, seed: 7 });
|
||||
assert.ok(samples.length === 20, 'fc.sample must return the requested number of samples without hanging');
|
||||
const singleNodeSamples = samples.filter(({ ids }) => ids.length === 1);
|
||||
assert.ok(singleNodeSamples.length > 0, 'the sample must include at least one degenerate single-node case');
|
||||
for (const { edges } of singleNodeSamples) {
|
||||
assert.deepEqual(edges, [], 'the single-node case must yield an empty edge list, not an unsatisfiable filter');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// init execute-phase (cmdInitExecutePhase, src/init.cts): the locator already
|
||||
// computed halted_plans/blocked_by/runnable_plans, but the command built its
|
||||
// output by explicit field enumeration, silently dropping all three. This
|
||||
// drives the real CLI end to end (not the locator directly) to prove the
|
||||
// passthrough.
|
||||
describe('init execute-phase: halt propagation passthrough (#2830)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('halted_plans, blocked_by, runnable_plans are forwarded; incomplete fields stay byte-identical', () => {
|
||||
tmpDir = createTempProject('gsd-2830-init-');
|
||||
buildBaseFixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
|
||||
// Guard: if the phase was not resolved, every assertion below is vacuous.
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
json.halted_plans.includes('01-01-PLAN.md'),
|
||||
'halted_plans must include the halted plan',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '01-02-PLAN.md'),
|
||||
'blocked_by must have an entry for the direct dependent',
|
||||
);
|
||||
assert.deepEqual(
|
||||
json.blocked_by['01-02-PLAN.md'],
|
||||
['01-01'],
|
||||
"blocked_by['01-02-PLAN.md'] must name 01-01 as the blocking cause",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('01-02-PLAN.md'),
|
||||
'the dependent must NOT be offered as runnable work',
|
||||
);
|
||||
assert.ok(
|
||||
json.runnable_plans.includes('01-04-PLAN.md'),
|
||||
'the decoupled plan (no dependency on the halted plan) must stay runnable',
|
||||
);
|
||||
|
||||
// Back-compat: incomplete_plans/incomplete_count must be exactly what they
|
||||
// were pre-#2830 — every no-SUMMARY plan, blocked or not, still counted.
|
||||
assert.deepEqual(
|
||||
[...json.incomplete_plans].sort(),
|
||||
['01-02-PLAN.md', '01-03-PLAN.md', '01-04-PLAN.md'],
|
||||
'incomplete_plans must be unchanged by halt-awareness',
|
||||
);
|
||||
assert.strictEqual(
|
||||
json.incomplete_count,
|
||||
3,
|
||||
'incomplete_count must be unchanged by halt-awareness',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Adversarial review of the #2830 fix found two confirmed defects:
|
||||
// 1. (BLOCKER) computeHaltPropagation's Kahn pass silently drops cycle
|
||||
// participants (and anything downstream of them) from BOTH `order` and
|
||||
// `blockedBy` — phase.cts hard-fails on a cycle before this ever
|
||||
// matters, but phase-locator.cts (consumed by `init execute-phase`)
|
||||
// does not pre-check, so a plan directly depends_on-ing a halted plan
|
||||
// inside a cycle was reported as ordinary runnable.
|
||||
// 2. (MAJOR) the summary templates presented `status: halted` as a
|
||||
// trailing `#`-comment on the value line, but `extractFrontmatter`
|
||||
// does not strip trailing YAML comments — an executor mimicking the
|
||||
// template's own presentation wrote a halt that silently read back as
|
||||
// not-halted.
|
||||
describe('#2830 review findings', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('defect 1: cycle repro — 08-02 depends_on [halted 08-01, 08-03]; 08-03 depends_on [08-02]', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-cycle-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '08-cyclehalt');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '08-01-PLAN.md', ['wave: 1', 'objective: Halted upstream', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '08-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '08-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on halted + cyclic peer', 'autonomous: true',
|
||||
'depends_on:', ' - 08-01', ' - 08-03',
|
||||
]);
|
||||
writePlan(phaseDir, '08-03-PLAN.md', [
|
||||
'wave: 2', 'objective: Cyclic peer of 08-02', 'autonomous: true', 'depends_on:', ' - 08-02',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '8', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('08-02-PLAN.md'),
|
||||
'a plan that directly depends_on a halted plan must never be offered as runnable, cycle or not',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '08-02-PLAN.md'),
|
||||
'a plan must never silently vanish from both runnable_plans and blocked_by',
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(json.blocked_by['08-02-PLAN.md']) && json.blocked_by['08-02-PLAN.md'].length > 0,
|
||||
'blocked_by entry must be non-empty, not a vacuous placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 1: self-dependency (A depends_on A, nothing halted) must not be silently runnable', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-self-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '09-selfdep');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '09-01-PLAN.md', [
|
||||
'wave: 1', 'objective: Depends on itself', 'autonomous: true', 'depends_on:', ' - 09-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '9', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('09-01-PLAN.md'),
|
||||
'a self-dependent plan must not be silently offered as runnable',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '09-01-PLAN.md'),
|
||||
'a self-dependent plan must never silently vanish from both runnable_plans and blocked_by',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 2: SUMMARY status with an inline YAML comment still blocks the dependent', () => {
|
||||
tmpDir = createTempProject('gsd-2830-review-comment-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-inlinecomment');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
writePlan(phaseDir, '10-01-PLAN.md', ['wave: 1', 'objective: Halted, recorded with an inline comment', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '10-01-SUMMARY.md', 'halted # designed stop');
|
||||
writePlan(phaseDir, '10-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Depends on the inline-commented halt', 'autonomous: true', 'depends_on:', ' - 10-01',
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['init', 'execute-phase', '10', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `init execute-phase should succeed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.phase_found, true, 'phase must be found for the rest of this test to be meaningful');
|
||||
|
||||
assert.ok(
|
||||
json.halted_plans.includes('10-01-PLAN.md'),
|
||||
'status: halted # designed stop must still be recognized as halted',
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(json.blocked_by, '10-02-PLAN.md'),
|
||||
'the dependent of an inline-commented halt must be blocked',
|
||||
);
|
||||
assert.ok(
|
||||
!json.runnable_plans.includes('10-02-PLAN.md'),
|
||||
'the dependent of an inline-commented halt must not be offered as runnable',
|
||||
);
|
||||
});
|
||||
|
||||
test('defect 2: isHaltedStatus strips an unquoted trailing YAML comment before comparing', () => {
|
||||
const { isHaltedStatus } = planDependencyGraph;
|
||||
assert.strictEqual(isHaltedStatus('halted'), true);
|
||||
assert.strictEqual(isHaltedStatus('Halted'), true);
|
||||
assert.strictEqual(isHaltedStatus('halted '), true);
|
||||
assert.strictEqual(isHaltedStatus('halted # designed stop'), true);
|
||||
assert.strictEqual(isHaltedStatus('halted#nospace'), false, 'a `#` with no preceding whitespace is not a YAML comment');
|
||||
assert.strictEqual(isHaltedStatus('complete'), false);
|
||||
assert.strictEqual(isHaltedStatus('complete # done'), false);
|
||||
assert.strictEqual(isHaltedStatus(''), false);
|
||||
assert.strictEqual(isHaltedStatus(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Folded from tests/fix-2855-phase-locator-workstream-archive-scope.test.cjs
|
||||
// (#3335 H3 fold).
|
||||
//
|
||||
// #2855: the archived-milestone fallback hardcoded the project-root
|
||||
// .planning/milestones/ tree instead of routing through the
|
||||
// workstream-aware planningDir(cwd) helper (the same seam the active-phase
|
||||
// search and the archive-write path already use). A pending phase in one
|
||||
// workstream, whose own phases/ dir does not exist yet, would silently
|
||||
// resolve to a same-numbered phase archived under the ROOT tree (an
|
||||
// unrelated workstream's history, or a flat-mode project's archive).
|
||||
//
|
||||
// GSD_WORKSTREAM/GSD_PROJECT are read directly from process.env by
|
||||
// planningDir() when omitted, so every test here explicitly saves/restores
|
||||
// both to avoid leaking state across tests or picking up ambient shell env.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-2855-phase-locator-workstream-archive-scope', () => {
|
||||
|
||||
describe('#2855: findPhaseInternal does not leak cross-workstream archived phases', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateWorkstreamEnv(); });
|
||||
afterEach(() => {
|
||||
restoreWorkstreamEnv();
|
||||
if (tmpDir) { cleanup(tmpDir); tmpDir = null; }
|
||||
});
|
||||
|
||||
test('does not leak root-tree archived phase into an unrelated workstream', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
// Root archive holds phase 03 (unrelated workstream's / flat-mode history).
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootArchive, 'SOME-SUMMARY.md'), '# stale');
|
||||
|
||||
// Workstream "beta" exists with an empty phases/ dir — phase 03 is pending.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases'), { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.strictEqual(result, null, 'pending workstream phase must not resolve to the root archive');
|
||||
});
|
||||
|
||||
test('does not leak root archive when workstream phases dir is entirely absent', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v2.0-phases', '01-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
// Brand-new workstream: no .planning/workstreams/gamma/ directory at all yet.
|
||||
process.env.GSD_WORKSTREAM = 'gamma';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||
assert.strictEqual(result, null, 'a workstream with no directory yet must not resolve to the root archive');
|
||||
});
|
||||
|
||||
// Issue #2855 AC1: "...regardless of whether workstream A's roadmap already
|
||||
// lists the phase and when it doesn't yet." findPhaseInternal never reads
|
||||
// ROADMAP.md (it is a pure filesystem lookup), so this dimension cannot
|
||||
// change its behavior — demonstrated directly rather than left as an
|
||||
// inference from reading the source.
|
||||
for (const roadmapHasEntry of [true, false]) {
|
||||
test(`does not leak root archive whether or not the workstream's ROADMAP.md already lists the phase (roadmapHasEntry=${roadmapHasEntry})`, () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'beta');
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
if (roadmapHasEntry) {
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'ROADMAP.md'),
|
||||
['# Roadmap', '', '### Phase 03: Pending Work', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.strictEqual(result, null, 'ROADMAP.md presence/absence must not affect the archive-leak guard');
|
||||
});
|
||||
}
|
||||
|
||||
test('still finds a phase genuinely archived under the active workstream\'s own tree', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '03-real');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(result !== null, 'workstream\'s own archived phase must still resolve');
|
||||
assert.strictEqual(result.found, true);
|
||||
assert.strictEqual(result.archived, 'v1.0');
|
||||
assert.strictEqual(
|
||||
result.directory,
|
||||
'.planning/workstreams/beta/milestones/v1.0-phases/03-real',
|
||||
);
|
||||
});
|
||||
|
||||
test('flat/non-workstream project archive resolution is unchanged', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-flat');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
// No GSD_WORKSTREAM / GSD_PROJECT set — flat mode.
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(result !== null, 'flat-mode archive lookup must be unaffected by the fix');
|
||||
assert.strictEqual(result.found, true);
|
||||
assert.strictEqual(result.archived, 'v1.0');
|
||||
assert.strictEqual(result.directory, '.planning/milestones/v1.0-phases/03-flat');
|
||||
});
|
||||
|
||||
test('two workstreams with same-numbered archived phases never cross-resolve', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const alphaArchive = path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'milestones', 'v1.0-phases', '03-alpha-work');
|
||||
const betaArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '03-beta-work');
|
||||
fs.mkdirSync(alphaArchive, { recursive: true });
|
||||
fs.mkdirSync(betaArchive, { recursive: true });
|
||||
|
||||
process.env.GSD_WORKSTREAM = 'alpha';
|
||||
const alphaResult = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(alphaResult !== null);
|
||||
assert.strictEqual(alphaResult.phase_name, 'alpha-work');
|
||||
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
const betaResult = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||
assert.ok(betaResult !== null);
|
||||
assert.strictEqual(betaResult.phase_name, 'beta-work');
|
||||
});
|
||||
|
||||
test('project+workstream combination scopes the archive fallback', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '05-root-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
const scopedArchive = path.join(tmpDir, '.planning', 'proj-x', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '05-scoped');
|
||||
fs.mkdirSync(scopedArchive, { recursive: true });
|
||||
|
||||
process.env.GSD_PROJECT = 'proj-x';
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '5');
|
||||
assert.ok(result !== null, 'project+workstream scoped archive must resolve');
|
||||
assert.strictEqual(result.phase_name, 'scoped');
|
||||
assert.strictEqual(
|
||||
result.directory,
|
||||
'.planning/proj-x/workstreams/beta/milestones/v1.0-phases/05-scoped',
|
||||
);
|
||||
});
|
||||
|
||||
test('workstream-scoped archived directory is posix-style', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v1.0-phases', '07-posix');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '7');
|
||||
assert.ok(result !== null);
|
||||
assert.ok(!result.directory.includes('\\'), 'directory must use forward slashes on every platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2855: getArchivedPhaseDirs does not leak cross-workstream archived phases', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateWorkstreamEnv(); });
|
||||
afterEach(() => {
|
||||
restoreWorkstreamEnv();
|
||||
if (tmpDir) { cleanup(tmpDir); tmpDir = null; }
|
||||
});
|
||||
|
||||
test('does not leak root-tree archive entries under an active workstream', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const rootArchive = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-legacy');
|
||||
fs.mkdirSync(rootArchive, { recursive: true });
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases'), { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.deepEqual(result, [], 'getArchivedPhaseDirs must not surface the root archive for a workstream');
|
||||
});
|
||||
|
||||
test('still finds phases genuinely archived under the active workstream\'s own tree', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const ownArchive = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'milestones', 'v2.0-phases', '04-own');
|
||||
fs.mkdirSync(ownArchive, { recursive: true });
|
||||
process.env.GSD_WORKSTREAM = 'beta';
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].name, '04-own');
|
||||
assert.strictEqual(result[0].milestone, 'v2.0');
|
||||
// basePath is posix-normalized (toPosixPath) — a forward-slash literal is
|
||||
// the correct cross-platform expectation, not path.join.
|
||||
assert.strictEqual(
|
||||
result[0].basePath,
|
||||
'.planning/workstreams/beta/milestones/v2.0-phases',
|
||||
);
|
||||
});
|
||||
|
||||
test('flat/non-workstream project resolution is unchanged', () => {
|
||||
tmpDir = createTempProject('gsd-2855-');
|
||||
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v2.1.0-phases');
|
||||
fs.mkdirSync(path.join(archiveDir, '03-auth'), { recursive: true });
|
||||
// No GSD_WORKSTREAM / GSD_PROJECT set — flat mode.
|
||||
|
||||
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||
assert.strictEqual(result.length, 1);
|
||||
const entry = result[0];
|
||||
assert.strictEqual(entry.name, '03-auth');
|
||||
assert.strictEqual(entry.milestone, 'v2.1.0');
|
||||
// basePath is posix-normalized (toPosixPath) — a forward-slash literal is
|
||||
// the correct cross-platform expectation, not path.join.
|
||||
assert.strictEqual(entry.basePath, '.planning/milestones/v2.1.0-phases');
|
||||
assert.strictEqual(entry.fullPath, path.join(archiveDir, '03-auth'));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1201,3 +1201,120 @@ describe('#2868: verification status CLI drives the execute-phase stranded-phase
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-3174-quick-verification-status-read', () => {
|
||||
// allow-test-rule: source-text-is-the-product see #3174
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
//
|
||||
// #3174: quick's verification step used to read the verifier's result with a
|
||||
// raw `grep "^status:" F | cut -d: -f2 | tr -d ' '` and route it through arms
|
||||
// passed / human_needed / gaps_found only. That read failed two ways, both
|
||||
// measured against the old pipeline: it matched NO arm on a missing report,
|
||||
// most off-schema values, a `status:` line in both frontmatter and prose, or
|
||||
// (on a CRLF checkout) a valid `passed` arriving as `passed\r`; and it
|
||||
// matched the SUCCESS arm when it should not have on a stale `passed`
|
||||
// report (staleness was never evaluated), a report whose only `status:` line
|
||||
// sits in its prose, or an off-schema value carrying a colon
|
||||
// (`passed:bogus`), which `cut -d: -f2` splits at that colon. The unanchored
|
||||
// match is the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class the code side
|
||||
// already fixed by name. These tests pin the five properties that keep the
|
||||
// replacement honest.
|
||||
describe('quick verification status read (#3174)', () => {
|
||||
const QUICK_VERIFICATION = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'quick', 'steps', 'quick-verification.md',
|
||||
);
|
||||
// The canonical launcher preamble. scripts/sync-runtime-launcher.cjs rewrites
|
||||
// every workflow's bootstrap from this file, so THIS is the authority — not
|
||||
// whichever sibling step file happens to carry a copy today.
|
||||
const LAUNCHER_SNIPPET = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh',
|
||||
);
|
||||
|
||||
const SHIM_ANCHOR = '_GSD_SHIM_NAME="gsd-tools.cjs"';
|
||||
|
||||
test('status is read through the canonical query, not a raw frontmatter grep', () => {
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const queryIdx = content.indexOf('gsd_run query verification.status "${QUICK_DIR}"');
|
||||
|
||||
assert.ok(queryIdx !== -1, 'quick-verification.md must read status via the verification.status query');
|
||||
assert.ok(
|
||||
!content.includes('grep "^status:"'),
|
||||
'the raw frontmatter-scalar grep must not return — it matches body lines too (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP)',
|
||||
);
|
||||
});
|
||||
|
||||
test('the query call is preceded by the runtime shim bootstrap in this step file', () => {
|
||||
// Step files are read and executed as their own units, so quick.md's
|
||||
// bootstrap does not reach here. Without this the call resolves to
|
||||
// nothing, 2>/dev/null swallows it, and the default arm is taken forever.
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const shimIdx = content.indexOf(SHIM_ANCHOR);
|
||||
const queryIdx = content.indexOf('gsd_run query verification.status');
|
||||
|
||||
assert.ok(shimIdx !== -1, 'the step file must carry its own runtime shim bootstrap');
|
||||
assert.ok(queryIdx > shimIdx, 'the shim bootstrap must precede the gsd_run call');
|
||||
});
|
||||
|
||||
test('the shim bootstrap is the canonical launcher preamble, not a fork of it', () => {
|
||||
// Anchored on _runtime-launcher.snippet.sh rather than on a sibling step
|
||||
// file: sync-runtime-launcher.cjs regenerates every workflow from the
|
||||
// snippet, so a synchronized launcher update keeps this green (correct),
|
||||
// and a sibling that legitimately stops calling gsd_run cannot fail us.
|
||||
const lineWithShim = (file) => fs.readFileSync(file, 'utf-8')
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith(SHIM_ANCHOR));
|
||||
|
||||
const mine = lineWithShim(QUICK_VERIFICATION);
|
||||
const canonical = lineWithShim(LAUNCHER_SNIPPET);
|
||||
|
||||
assert.ok(canonical, '_runtime-launcher.snippet.sh must carry the canonical preamble');
|
||||
assert.equal(mine, canonical, 'the bootstrap must match the canonical launcher snippet verbatim');
|
||||
});
|
||||
|
||||
test('status extraction does not depend on jq', () => {
|
||||
// #2589: a `| jq -r '.field'` pipe yields an empty variable with no
|
||||
// diagnostic wherever jq is absent (the Windows/Git-Bash default), which
|
||||
// would route a passing verification into the recovery arm.
|
||||
//
|
||||
// Scoped to the executable fence on purpose: the surrounding prose cites
|
||||
// the jq form in order to explain why it is not used, and an assertion
|
||||
// over the whole file would fire on its own rationale.
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const fences = content.match(/```bash\r?\n[\s\S]*?```/g) || [];
|
||||
const statusFence = fences.find((f) => f.includes('gsd_run query verification.status'));
|
||||
|
||||
assert.ok(statusFence, 'the status read must live in a bash fence');
|
||||
assert.ok(
|
||||
statusFence.includes('--pick status'),
|
||||
'the bare status must be picked by the query itself',
|
||||
);
|
||||
assert.ok(!/\|\s*jq\b/.test(statusFence), 'the status-read fence must not pipe through jq');
|
||||
});
|
||||
|
||||
test('the routing table carries a terminal arm for missing / unknown / stale', () => {
|
||||
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
||||
const gapsIdx = content.indexOf('| `gaps_found` |');
|
||||
const fallbackIdx = content.indexOf('| anything else');
|
||||
|
||||
assert.ok(gapsIdx !== -1, 'the three verifier-status arms must remain');
|
||||
assert.ok(fallbackIdx > gapsIdx, 'a terminal arm must follow the verifier-status arms');
|
||||
|
||||
const fallbackRow = content.slice(fallbackIdx, content.indexOf('\n', fallbackIdx));
|
||||
for (const sentinel of ['missing', 'unknown', 'stale']) {
|
||||
assert.ok(
|
||||
fallbackRow.includes(sentinel),
|
||||
`the terminal arm must name the ${sentinel} sentinel the query can return`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
fallbackRow.includes('VERIFICATION_STATUS'),
|
||||
'the terminal arm must set the display string consumed by the quick index row and banner',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { makeFaultyGit } = require('./helpers/faulty-deps.cjs');
|
||||
@@ -1037,3 +1038,199 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── workflow dispatch-site coverage: worktree.base-check gates (folded from
|
||||
// fix-1941-quick-worktree-stale-base.test.cjs and
|
||||
// fix-2649-diagnose-issues-worktree-stale-base.test.cjs, #3335) ──────────────
|
||||
//
|
||||
// allow-test-rule: source-text-is-the-product #1941 #2649
|
||||
// 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.
|
||||
//
|
||||
// Root cause shared by #1941 and #2649: Claude Code's isolation="worktree"
|
||||
// forks new worktrees from origin/HEAD, not the live local HEAD. When prior
|
||||
// local commits advance local HEAD without an intervening `git push`,
|
||||
// origin/HEAD stays pinned to a stale ancestor and the executor's
|
||||
// worktree_branch_check guard halts with a base-mismatch fatal. The fix ports
|
||||
// the worktree.base-check auto-degrade pattern (originally execute-phase
|
||||
// #683/#1369) into each not-yet-covered dispatch site: quick.md's
|
||||
// single-dispatch path (#1941), and diagnose-issues.md's spawn_agents step
|
||||
// plus execute-plan.md's Pattern A single-plan dispatch (#2649, fixed
|
||||
// together per that bug's acceptance criterion 5 — same bug class, same
|
||||
// one-line gate).
|
||||
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-1941-quick-worktree-stale-base', () => {
|
||||
|
||||
const QUICK_WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('quick: pre-dispatch worktree base re-check (#1941)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(QUICK_WORKFLOW_PATH), 'workflows/quick.md should exist');
|
||||
});
|
||||
|
||||
test('Step 6 runs worktree.base-check before capturing EXPECTED_BASE', () => {
|
||||
const content = fs.readFileSync(QUICK_WORKFLOW_PATH, 'utf-8');
|
||||
const step6Idx = content.indexOf('**Step 6: Spawn executor**');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check', step6Idx);
|
||||
const expectedBaseIdx = content.indexOf('EXPECTED_BASE=$(git rev-parse HEAD)', step6Idx);
|
||||
assert.ok(step6Idx !== -1, '"Step 6: Spawn executor" must exist in quick.md');
|
||||
assert.ok(baseCheckIdx !== -1, 'worktree.base-check must be invoked within Step 6');
|
||||
assert.ok(expectedBaseIdx !== -1, 'EXPECTED_BASE capture must exist within Step 6');
|
||||
assert.ok(
|
||||
baseCheckIdx < expectedBaseIdx,
|
||||
'worktree.base-check must run BEFORE EXPECTED_BASE is captured so the degrade decision reflects the most current local HEAD'
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check references #1941 for traceability', () => {
|
||||
const content = fs.readFileSync(QUICK_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1941'), 'quick.md must reference #1941');
|
||||
});
|
||||
|
||||
test('degrade check clears BOTH USE_WORKTREES and ISOLATION when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(QUICK_WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 900);
|
||||
assert.ok(block.includes('shouldDegrade'), 'degrade check must branch on shouldDegrade');
|
||||
// Both must move together (#2652). Dispatch keys on ISOLATION while the prompt
|
||||
// guard and worktree manifest key on USE_WORKTREES; clearing only one dispatches
|
||||
// an isolated executor with no base guard and no manifest, then blocks in cleanup
|
||||
// looking for a manifest that was never initialized.
|
||||
assert.ok(block.includes('USE_WORKTREES=false'), 'degrade must set USE_WORKTREES=false');
|
||||
assert.ok(
|
||||
block.includes('ISOLATION=none'),
|
||||
'degrade must ALSO set ISOLATION=none — dispatch reads ISOLATION, so clearing only ' +
|
||||
'USE_WORKTREES still passes the harness isolation flag (#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
// #2652: this assertion previously required `RUNTIME = "claude"`, encoding the
|
||||
// pre-#2584 premise that worktree isolation is Claude-specific. #2584 replaced
|
||||
// that with the negotiated dispatch.isolation capability, so the guard now keys
|
||||
// on the capability — Cursor also declares harness-worktree.
|
||||
test('degrade check guards on the negotiated capability, not a runtime id', () => {
|
||||
const content = fs.readFileSync(QUICK_WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(Math.max(0, baseCheckIdx - 300), baseCheckIdx + 200);
|
||||
assert.ok(
|
||||
block.includes('ISOLATION') && block.includes('harness-worktree'),
|
||||
'degrade check must guard on ISOLATION = harness-worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!/\[\s*"\$RUNTIME"\s*=/.test(block),
|
||||
'degrade check must NOT branch on a RUNTIME literal (#2584/#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check names origin/HEAD as the stale fork base', () => {
|
||||
const content = fs.readFileSync(QUICK_WORKFLOW_PATH, 'utf-8');
|
||||
const step6Idx = content.indexOf('**Step 6: Spawn executor**');
|
||||
const nextSection = content.indexOf('\n---', step6Idx);
|
||||
const section = content.slice(step6Idx, nextSection === -1 ? undefined : nextSection);
|
||||
assert.ok(section.includes('origin/HEAD'), 'Step 6 must name origin/HEAD as the stale fork base');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:fix-2649-diagnose-issues-worktree-stale-base', () => {
|
||||
|
||||
const DIAGNOSE_ISSUES_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'diagnose-issues.md');
|
||||
const EXECUTE_PLAN_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
|
||||
|
||||
describe('diagnose-issues: pre-dispatch worktree base-check (#2649)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(DIAGNOSE_ISSUES_PATH), 'workflows/diagnose-issues.md should exist');
|
||||
});
|
||||
|
||||
test('spawn_agents step runs worktree.base-check before the Agent() dispatch', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_ISSUES_PATH, 'utf-8');
|
||||
const spawnIdx = content.indexOf('<step name="spawn_agents">');
|
||||
assert.ok(spawnIdx !== -1, '"spawn_agents" step must exist in diagnose-issues.md');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check', spawnIdx);
|
||||
assert.ok(baseCheckIdx !== -1, 'worktree.base-check must be invoked within the spawn_agents step');
|
||||
// The load-bearing invariant is "base-check BEFORE the Agent() dispatch" so the
|
||||
// degrade decision can drop isolation from the spawn. (Where EXPECTED_BASE is
|
||||
// captured relative to the check is cosmetic — the check only reads HEAD, never
|
||||
// mutates it — so assert the real invariant, not a loose disjunction.)
|
||||
const agentIdx = content.indexOf('Agent(', spawnIdx);
|
||||
assert.ok(agentIdx !== -1, 'spawn_agents must contain an Agent() dispatch');
|
||||
assert.ok(
|
||||
baseCheckIdx < agentIdx,
|
||||
'worktree.base-check must run before the Agent() dispatch so the degrade decision can drop isolation from the spawn',
|
||||
);
|
||||
});
|
||||
|
||||
test('verify-only worktree_branch_check backstop remains embedded in the Agent() prompt', () => {
|
||||
// Acceptance criterion #4: the base-check is a PRE-DISPATCH degrade; the
|
||||
// <worktree_branch_check> guard is a POST-FORK fail-closed backstop. Both
|
||||
// layers must survive — a future edit that dropped the backstop embedding
|
||||
// would re-open the silent-stale-base class. Guard its continued presence.
|
||||
const content = fs.readFileSync(DIAGNOSE_ISSUES_PATH, 'utf-8');
|
||||
const spawnIdx = content.indexOf('<step name="spawn_agents">');
|
||||
assert.ok(spawnIdx !== -1, '"spawn_agents" step must exist');
|
||||
assert.ok(
|
||||
content.indexOf('worktree-branch-check.md', spawnIdx) !== -1,
|
||||
'spawn_agents must still materialize the <worktree_branch_check> backstop after the base-check gate (#2649 acceptance criterion 4)',
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_ISSUES_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 600);
|
||||
assert.ok(
|
||||
block.includes('shouldDegrade') && block.includes('USE_WORKTREES=false'),
|
||||
'degrade check must override USE_WORKTREES=false when shouldDegrade is true',
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check references #2649 for traceability', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_ISSUES_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#2649'), 'diagnose-issues.md must reference #2649');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-plan Pattern A: pre-dispatch worktree base-check (#2649)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(EXECUTE_PLAN_PATH), 'workflows/execute-plan.md should exist');
|
||||
});
|
||||
|
||||
test('Pattern A runs the worktree base-check before spawning the executor', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const patternAIdx = content.indexOf('**Pattern A:**');
|
||||
assert.ok(patternAIdx !== -1, '"Pattern A:" must exist in execute-plan.md');
|
||||
// The base-check instruction must appear within the Pattern A description,
|
||||
// before the isolation="worktree" embedding instruction.
|
||||
const patternAEnd = content.indexOf('**Pattern B:**', patternAIdx);
|
||||
const patternA = content.slice(patternAIdx, patternAEnd === -1 ? undefined : patternAEnd);
|
||||
assert.ok(
|
||||
patternA.includes('#2649') && /worktree\.base-check|base-check/.test(patternA),
|
||||
'Pattern A must run the #2649 worktree base-check before dispatching the executor',
|
||||
);
|
||||
assert.ok(
|
||||
patternA.includes('shouldDegrade'),
|
||||
'Pattern A base-check must consult shouldDegrade',
|
||||
);
|
||||
});
|
||||
|
||||
test('Pattern A documents the auto-degrade (drop isolation on shouldDegrade)', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const patternAIdx = content.indexOf('**Pattern A:**');
|
||||
const patternAEnd = content.indexOf('**Pattern B:**', patternAIdx);
|
||||
const patternA = content.slice(patternAIdx, patternAEnd === -1 ? undefined : patternAEnd);
|
||||
assert.ok(
|
||||
/degrad|sequential/i.test(patternA),
|
||||
'Pattern A must document auto-degrading to sequential mode when shouldDegrade is true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user