/**
* 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 = 'Work') {
fs.writeFileSync(
path.join(phaseDir, filename),
[
'---',
...frontmatterLines,
'---',
'',
`# ${filename}`,
'',
`${filename}`,
'',
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);
});
});