fix(#4764): dep_phases extracts only Phase-prefixed references from Depends-on prose (#4876)

* test(#4764): failing-first — dep_phases must extract only Phase-prefixed references, never dates/shas/ledger ids/self

* fix(#4764): dep_phases anchors phase references to their 'Phase' prose context and never emits the row's own number

* fix(#4764): review fold-ins — hoist the anchored dep-reference grammar to phase-id, cover Oxford lists and hyphen ranges, repair the property test

Adversarial review found: Oxford-comma lists under-extracted ('Phases 1, 2,
and 3' dropped the tail member — a silent real-blocker clear, the dangerous
direction); hyphen ranges ('Phases 1-3') kept only the first endpoint; the
property test called fc.hexaString (absent in fast-check 4.8, threw every
run) and passed the junk arbitrary unspread (vacuous guard) with no
completeness assertion; planning-inspect's extractDependencyTokens carried
the same whole-field scrape (generative-fix divergence). The anchored
grammar now lives beside PHASE_NUMBER_TOKEN_SOURCE in phase-id.cts and both
readers interpolate it.

* chore(#4764): backfill changeset PR number (4876)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-19 13:07:03 -04:00
committed by GitHub
parent 969456c46d
commit a87b83d485
5 changed files with 258 additions and 2 deletions

View File

@@ -105,6 +105,7 @@ const {
matchPhaseDirs,
stripProjectCodePrefix,
PHASE_NUMBER_TOKEN_SOURCE,
PHASE_DEP_REF_SOURCE,
isForeignPrefixedPhaseQuery,
isSentinelPhaseId,
extractPhaseToken,
@@ -3105,6 +3106,23 @@ function cmdInitManager(cwd: string, raw: boolean): void {
return reaches(numA, numB) || reaches(numB, numA);
}
// #4764: a phase reference in depends_on prose is a PHASE-SHAPED token in
// context — directly following "Phase"/"Phases" — never a bare digit run.
// The previous whole-field scrape matched the token grammar against every
// digit run, so calendar dates ("2026-09-14" → 2026, 09, 14), git shas
// ("8bf403100d" → 8b, 403100d, …), bracketed ledger ids (WINDOWS #1843) and
// the row's OWN number all became "dependencies", and deps_satisfied came
// back false for phases whose prose declares none (50 of 92 phases in the
// reporter's milestone). The anchored grammar (owned by phase-id.cts as
// PHASE_DEP_REF_SOURCE, shared with planning-inspect's dependencies) keeps
// lists fully extracted ("Phases 601 and 602", "Phase 601, 602, and 603",
// "Phase 1-3") — silently dropping a REAL dependency would clear
// deps_satisfied prematurely, the dangerous direction. Negation prose
// ("dropped the dependency on Phase 654") is NOT detected: the issue's own
// minimum keeps such tokens.
const depPhaseRefRe = new RegExp(`${PHASE_DEP_REF_SOURCE}`, 'gi');
const depTokenRe = new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi');
for (const phase of phases) {
if (
!phase['depends_on'] ||
@@ -3112,7 +3130,23 @@ function cmdInitManager(cwd: string, raw: boolean): void {
) {
phase['deps_satisfied'] = true;
} else {
const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || [];
const prose = phase['depends_on'] as string;
const ownNumber = normalizePhaseNumber(phase['number'] as string);
const depNums: string[] = [];
const seen = new Set<string>();
let refMatch: RegExpExecArray | null;
depPhaseRefRe.lastIndex = 0;
while ((refMatch = depPhaseRefRe.exec(prose)) !== null) {
let tok: RegExpExecArray | null;
depTokenRe.lastIndex = 0;
while ((tok = depTokenRe.exec(refMatch[1])) !== null) {
const normalized = normalizePhaseNumber(tok[0]);
if (normalized === ownNumber) continue; // #4764: never the row's own phase
if (seen.has(normalized)) continue;
seen.add(normalized);
depNums.push(tok[0]);
}
}
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
phase['dep_phases'] = depNums;
}

View File

@@ -64,6 +64,22 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
// introduced outside this module without a `// phase-id-owner:` justification.
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
// #4764: a phase REFERENCE in depends-on PROSE — the token in context, directly
// following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
// "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
// anchored on, because two readers consume Depends-on prose (init.manager's
// dep_phases and planning-inspect's dependencies) and re-deriving the anchored
// form at each would drift exactly the way PHASE_NUMBER_TOKEN_SOURCE's own
// anti-divergence rule exists to prevent. A bare digit run is the right shape
// test for a phase id and the WRONG test for a reference: the whole-field
// scrape this replaces pulled calendar dates ("2026-09-14" → 2026/09/14), git
// shas ("8bf403100d" → 8b/403100d/…) and ledger ids (WINDOWS #1843) in as
// dependencies. The `-` separator deliberately extracts range ENDPOINTS only
// ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
// out (a range's middle is not written as a reference).
const PHASE_DEP_REF_SOURCE =
`\\bphases?\\s+(${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*)`;
// #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
// sites that scan directory names (where a project code or a variant suffix may
// legitimately be lowercase) and therefore cannot use a case-sensitive class.
@@ -1632,6 +1648,7 @@ export = {
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
PHASE_DEP_REF_SOURCE,
CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
PHASE_CONTINUATION_SEGMENT_SOURCE,

View File

@@ -1059,7 +1059,23 @@ function extractGoalProse(sectionBody: string): string | null {
function extractDependencyTokens(sectionBody: string): string[] {
const m = DEPENDS_ON_LINE_RE.exec(sectionBody);
if (!m) return [];
return sortedUnique([...m[1].matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]));
// #4764: phase REFERENCES, not digit runs — the same prose-anchored grammar
// init.manager's dep_phases extraction uses (owner: phase-id.cts's
// PHASE_DEP_REF_SOURCE). The whole-field token scrape this replaces pulled
// calendar dates, git shas and ledger ids in as dependencies. The grammar's
// capture group 1 already excludes the "Phase(s)" anchor word, so no
// prefix-strip literal is needed here. Self-exclusion (init.manager drops
// the row's own number) is deliberately NOT applied: this reader has no row
// context at the extraction site and reports informationally, it does not
// gate.
const refRe = new RegExp(phaseIdMod.PHASE_DEP_REF_SOURCE, 'gi');
const tokenRe = new RegExp(phaseIdMod.PHASE_NUMBER_TOKEN_SOURCE, 'g');
const tokens: string[] = [];
let refMatch: RegExpExecArray | null;
while ((refMatch = refRe.exec(m[1])) !== null) {
for (const t of refMatch[1].matchAll(tokenRe)) tokens.push(t[0]);
}
return sortedUnique(tokens);
}
/**