Files
msd-core/src/phase-locator.cts
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

323 lines
13 KiB
TypeScript

/**
* Phase Locator — Phase-directory search and location
*
* ADR-857 rollout phase 2d: extracted from core.cts (issue #881).
* Owns active-phase discovery against the `.planning/phases/` tree
* (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
* enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
* the filesystem. Behaviour is preserved byte-for-behaviour from the prior
* location; only the module boundary moved. The core.cjs re-export spine
* was retired in epic #1267; callers import phase-locator helpers directly.
*
* Dependencies (leaf modules only — no loadConfig):
* - node:fs / node:path (stdlib)
* - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
* - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
* - ./planning-workspace.cjs (planningDir)
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsModule = require('./core-utils.cjs');
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatterModule = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatterModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planDependencyGraphModule = require('./plan-dependency-graph.cjs');
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphModule;
// ─── Phase search types ───────────────────────────────────────────────────────
interface PhaseSearchResult {
found: boolean;
directory: string;
phase_number: string;
phase_name: string | null;
phase_slug: string | null;
plans: string[];
summaries: string[];
incomplete_plans: string[];
has_research: boolean;
has_context: boolean;
has_verification: boolean;
has_reviews: boolean;
archived?: string;
ambiguous_matches?: string[];
/**
* #2830: plan filenames (from `plans`) whose own SUMMARY declares
* `status: halted` — a designed stop, not an ordinary completion.
*/
halted_plans: string[];
/**
* #2830: plan filename -> the halted plan id(s) (canonical, e.g. "01-02")
* transitively blocking it, for every entry in `incomplete_plans` that is
* blocked by an upstream halt. A plan filename absent from this map is not
* blocked (either not incomplete, or incomplete with no halted upstream).
*/
blocked_by: Record<string, string[]>;
/**
* #2830: the runnable-only view — `incomplete_plans` filtered to exclude
* anything present as a key in `blocked_by`. `incomplete_plans` itself
* keeps its pre-#2830 meaning ("no matching SUMMARY yet") unchanged.
*/
runnable_plans: string[];
}
/**
* #2830: parse a plan file's `depends_on` frontmatter. Returns [] — never
* throws — on a missing/unreadable/malformed plan or absent field, matching
* this primitive's existing fail-safe posture (a plan directory this
* primitive can otherwise read must never throw here).
*/
function parsePlanDependsOn(phaseDir: string, planFile: string): string[] {
try {
const planPath = path.join(phaseDir, planFile);
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content, planPath);
const fmDeps = fm['depends_on'];
if (Array.isArray(fmDeps)) return fmDeps.map(String);
if (typeof fmDeps === 'string' && fmDeps.trim() !== '') return [fmDeps];
return [];
} catch {
return [];
}
}
interface ArchivedPhaseDir {
name: string;
milestone: string;
basePath: string;
fullPath: string;
}
interface ArchiveVersionDir {
version: string;
archivePath: string;
}
// ─── Phase search helpers ─────────────────────────────────────────────────────
/**
* #2855: single source of truth for resolving and enumerating a project's
* (or, when a workstream is active, that workstream's OWN) archived-milestone
* directories — `<planningDir(cwd)>/milestones/vX.Y-phases/`. Both
* `findPhaseInternal`'s archive fallback and `getArchivedPhaseDirs` used to
* carry independent copies of this resolve-then-enumerate logic, which is
* exactly the shape that let the original #2855 bug (hardcoded root path)
* exist in one copy and not the other. Sharing this seam means a future
* change to how the archive tree is located only needs to happen once.
* Most-recent-milestone-first order (reverse-sorted directory names).
* Never throws: an absent/unreadable milestones/ dir yields [].
*/
function listArchiveVersionDirs(cwd: string): ArchiveVersionDir[] {
const milestonesDir = path.join(planningDir(cwd), 'milestones');
if (!fs.existsSync(milestonesDir)) return [];
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
return milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse()
.map(archiveName => ({
version: archiveName.match(/^(v[\d.]+)-phases$/)![1],
archivePath: path.join(milestonesDir, archiveName),
}));
} catch {
return [];
}
}
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
const matches = dirs.filter(d => phaseTokenMatches(d, normalized));
if (matches.length === 0) return null;
// #2237: fail loud when multiple directories match the same bare phase
// number — this happens when unrelated projects share a .planning/phases/
// tree. Silently taking the first match risks cross-project file writes.
if (matches.length > 1) {
return {
found: false,
directory: '',
phase_number: normalized,
phase_name: null,
phase_slug: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
has_reviews: false,
ambiguous_matches: matches,
halted_plans: [],
blocked_by: {},
runnable_plans: [],
};
}
const match = matches[0];
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
// its actual summary filename. Shared builder (also used by phase.cts's
// cmdPhasePlanIndex) so the two can never disagree about which summary
// belongs to which plan.
const summaryFileByPlanId = buildSummaryFileIndex(summaries, extractCanonicalPlanId);
// #2830: this primitive previously never parsed depends_on at all — see
// src/plan-dependency-graph.cts's file header. Build the same
// PlanHaltNode[] shape phase.cts's cmdPhasePlanIndex builds (id resolution
// mirrors its planMap/canonicalToId pattern) and hand it to the ONE
// shared halt-propagation traversal so this reader and the wave-grouping
// reader can never diverge on the halt rule again.
const planIds = plans.map(p => p.replace('-PLAN.md', '').replace('PLAN.md', ''));
const planIdByLower = new Map(planIds.map(id => [id.toLowerCase(), id]));
const canonicalToPlanId = new Map(
plans.map((p, i) => [extractCanonicalPlanId(p).toLowerCase(), planIds[i]]),
);
const haltNodes = plans.map((p, i) => {
const planId = planIds[i];
const canonical = extractCanonicalPlanId(p);
const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(canonical);
const halted = summaryFile !== undefined && isSummaryFileHalted(path.join(phaseDir, summaryFile));
const resolvedDependsOn = parsePlanDependsOn(phaseDir, p)
.map((dep) => {
const lower = dep.toLowerCase();
return planIdByLower.get(lower) ?? canonicalToPlanId.get(lower) ?? null;
})
.filter((id): id is string => id !== null);
return { id: planId, resolvedDependsOn, halted };
});
const { blockedBy } = computeHaltPropagation(haltNodes);
const haltedPlans = plans.filter((_, i) => haltNodes[i].halted);
const incompletePlanSet = new Set(incompletePlans);
const blockedByFiles: Record<string, string[]> = {};
const runnablePlans: string[] = [];
for (let i = 0; i < plans.length; i++) {
const p = plans[i];
if (!incompletePlanSet.has(p)) continue;
const causes = blockedBy.get(planIds[i]) ?? [];
if (causes.length > 0) {
blockedByFiles[p] = causes;
} else {
runnablePlans.push(p);
}
}
return {
found: true,
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
has_reviews: hasReviews,
halted_plans: haltedPlans,
blocked_by: blockedByFiles,
runnable_plans: runnablePlans,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
// #2855: scope the archived-milestone fallback to the SAME workstream as the
// active-phase search above (planningDir(cwd) resolves GSD_WORKSTREAM/GSD_PROJECT
// the identical way both places), not the hardcoded project-root tree. Archived
// phases genuinely live under a workstream's own `.planning/workstreams/<ws>/
// milestones/` — that is where archivePhaseDirectories (milestone.cts) writes
// them via the same planningDir(cwd) resolution. Hardcoding root here let a
// pending workstream phase resolve to an unrelated workstream's (or a flat-mode
// project's) archived phase that merely shares a phase number. Shared with
// getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment).
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
const relBase = toPosixPath(path.relative(cwd, archivePath));
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
return null;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
// #2855: same workstream-scoped resolution as findPhaseInternal above, via
// the shared listArchiveVersionDirs helper. `phase.list --include-archived`
// (the primary non-init consumer) must not leak a different workstream's
// archive either.
const results: ArchivedPhaseDir[] = [];
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: toPosixPath(path.relative(cwd, archivePath)),
fullPath: path.join(archivePath, dir),
});
}
}
return results;
}
export = {
searchPhaseInDir,
findPhaseInternal,
getArchivedPhaseDirs,
};