Files
msd-core/tests/fix-2830-halted-plan-dependents.test.cjs
Tom Boucher ef823ca9d9 fix(#2830): propagate a halted plan to its transitive dependents (#3038)
* test(#2830): add failing regression tests for halted-plan dependent blocking

Add tests/fix-2830-halted-plan-dependents.test.cjs covering direct,
transitive (2 and 3 hop), and diamond dependents of a halted plan across
both independent "which plans are incomplete" readers (phase-plan-index's
cmdPhasePlanIndex and findPhaseInternal/searchPhaseInDir), the negative
case (an unrelated decoupled plan stays runnable), and a parity check that
the two readers agree. Uses only modules that already exist at this
commit (gsd-tools.cjs via subprocess, the pre-existing phase-locator.cjs)
so the test file loads and runs cleanly on a fresh clone of this exact
commit. These fail against current behavior: neither reader has any
concept of a halted plan or a blocked_by/runnable view yet.

* fix(#2830): a halted plan no longer leaves its dependents on the runnable work list

A plan that reaches a designed stop still writes a SUMMARY, so both
"which plans are incomplete" readers saw it as an ordinary completion and
reported its dependents as ordinary runnable work — never checking
whether an upstream plan had halted rather than finished.

- New `status: halted` frontmatter value, documented in all four SUMMARY
  templates alongside the existing `status: complete`.
- New shared src/plan-dependency-graph.cts: a single computeHaltPropagation
  pass that both phase.cts's cmdPhasePlanIndex (wave-grouping) and
  phase-locator.cts's searchPhaseInDir (the phase-location primitive, ~50
  dependent symbols across 5 command routers) now call, so the
  two-implementation divergence that caused this bug cannot recur. It
  accepts an optional precomputedOrder so cmdPhasePlanIndex — which already
  runs Kahn's algorithm in computeDependencyLevels for wave assignment —
  passes that order straight through instead of a second traversal;
  searchPhaseInDir (no prior traversal) lets the module derive its own.
  The two small duplicated predicates each reader would otherwise carry
  (is this status "halted"?, which summary file matches which plan id?)
  are centralized in the same module as isHaltedStatus/buildSummaryFileIndex.
- Additive fields only: `halted`/`blocked_by`/`runnable` on
  cmdPhasePlanIndex's plans[] and top level, `halted_plans`/`blocked_by`/
  `runnable_plans` on searchPhaseInDir's result. The pre-existing
  `incomplete`/`incomplete_plans` fields are unchanged in meaning and
  membership.
- execute-phase.md's discover_and_group_plans step now also skips any
  plan whose `blocked_by` is non-empty, reporting it by name with its
  blocking chain, in addition to (not instead of) the existing
  has_summary skip rule.

Extends tests/fix-2830-halted-plan-dependents.test.cjs (introduced in the
prior commit) with direct unit coverage of computeHaltPropagation
(including the precomputedOrder call shape) and a fast-check property
test — both only possible once this commit's new module exists.

Closes #2830

* fix(#2830): surface the halt-aware view from init execute-phase

The adopted work made phase-locator compute halted_plans / blocked_by /
runnable_plans, but cmdInitExecutePhase builds its output by explicitly
enumerating fields, so all three were computed and then silently dropped at
the exact consumer the issue names as regressed.

Forwards them additively -- incomplete_plans and incomplete_count keep their
name, type and semantics byte-for-byte -- and adds the same three empty
defaults to the roadmap-only fallback so the shape is consistent in both
branches. Covered by a new test that drives the real CLI end to end rather
than the locator function, since the locator already worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2830): fail closed on dependency cycles and stop the templates inviting the defect

Three review findings, all fixed:

- BLOCKER (isolated adversarial). Cycle participants never reach indegree 0 in
  the Kahn pass, so they were excluded from the topological order, never visited
  by the forward pass, and vanished from blocked_by entirely -- i.e. reported as
  runnable. The wave-grouping reader hard-fails on a cycle so it never hit this,
  but the phase-location reader does not, so init execute-phase offered a plan
  depending directly on a halted plan. Reproduced, then fixed in the shared
  engine so every consumer is safe regardless of pre-checks: a node absent from
  the order is now blocked with a deterministic, non-empty named cause. A plan
  silently missing from both blocked_by and runnable is the exact disappearance
  this issue exists to prevent.

- MAJOR (isolated adversarial). All four summary templates showed the field as
  an inline comment on the value line. Frontmatter parsing does not strip
  trailing comments, so an executor copying the templates' own presentation
  wrote a halt that parsed as a non-halted string, silently reproducing the
  original bug. Guidance moved off the value line, and the halt predicate now
  tolerates an unquoted trailing comment.

- HARD standards violation. A test regex-matched child-process stderr prose for
  /cycle/i, which CONTRIBUTING bans. Replaced with the structured failure signal
  plus a differential assertion (same fixture without the cycle edge must
  succeed), so it stays cycle-specific without matching prose.

Also folds the duplicated read-summary-and-check-halted wrapper out of both
readers into the shared module -- centralizing only the predicate left the exact
two-copies-that-drift pattern the module exists to prevent -- and commits the
artifact-types documentation for the new status value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2830): stop the property generator hanging the whole suite

The remote runner did not fail -- it hung. Two containers sat in this file for
31+ minutes, and an earlier attempt ran 9 hours before I killed it. The runner
passes --test-timeout=0, so nothing ever reaps it: this would have hung CI
indefinitely, not reported a failure.

Root cause: the DAG generator built edges by rejection --

  from: fc.integer({ min: 0, max: n - 1 })
  to:   fc.integer({ min: 0, max: n - 1 })
  .filter(({ from, to }) => from < to)

With n === 1 both integers are forced to 0, so the predicate is unsatisfiable
and fast-check retries value generation forever. n is drawn from 1..12 and
fast-check biases toward boundary values, so n === 1 is reached almost at once.

This also explains why the failing-first run completed normally while the fixed
run hung: before the fix the graph module did not exist, so the property test
threw on import and never reached generation. It only starts hanging once the
code under test works.

Generates the DAG by construction instead -- `to` is drawn strictly above
`from`, with the degenerate single-node case short-circuited to an empty edge
list -- so no rejection sampling is involved. Switches the import to the shared
fast-check setup so the seed and run count are pinned per CONTRIBUTING, and adds
a bounded regression guard that samples the arbitrary directly, so a future
reintroduction fails loudly instead of hanging.

Verified: the file now completes in 2 seconds, 29 tests started and 29 finished,
zero failures, against an indefinite hang before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2830): restore the depends_on display contract and acknowledge the workflow growth

Full-suite run surfaced two things the focused harnesses could not.

1. Regression of a pinned pre-existing contract (#3785). A refactor routed the
   EMITTED depends_on field through the new dependency resolver, which also
   consults the canonical-prefix map. The original consulted the plan map only,
   so a short canonical prefix passed through verbatim -- '24-01' stayed
   '24-01' rather than becoming '24-01-auth-hardening'. The emitted field is a
   DISPLAY mapping, not the DAG resolution, and #3785 pins that. Reverted with
   a comment recording why it must not use the resolver; full resolution is
   still used for the wave DAG and halt propagation, which is what needs it.

2. The workflow file grew 518 bytes without an acknowledgment, from the
   halt-aware skip rule and the widened parse contract. Acknowledged.

Note on where the acknowledgment landed: the guidance is to add a NEW fragment,
but execute-phase.md is already named by an existing fragment and the linter
hard-fails when two ack sources name the same path. Appending to the owning
fragment, following its own established multi-PR pattern, was the only
lint-clean option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2830): backfill changeset pr number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:47:46 -04:00

765 lines
34 KiB
JavaScript

/**
* 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);
});
});