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

@@ -1646,3 +1646,187 @@ describe('bug-3584: validate health uses formatter for codex runtime too', () =>
});
});
}
// ─── #4764: dep_phases extracts only in-context phase references ────────────
describe('#4764 dep_phases extracts only Phase-prefixed references', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// The issue's measured phase-663 prose: dates, a round name, ledger ids and
// the row's own number, plus one genuine "Phase 654" mention.
const ISSUE_663_PROSE =
'Nothing. Round 663-DISPOSITION Q7 (3/3, 2026-09-14, ' +
'`.planning/decisions/663-CROSSAI-stream-consumer-failure-disposition.md`) moved the ' +
'"for retry" javadoc correction into this phase and dropped the dependency on Phase 654; ' +
'the defect is already recorded (WINDOWS #1843, #1977-#1985, #1992).';
test("#4764: dep_phases extracts only Phase-prefixed references from the issue's measured prose", () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '663', name: 'Disposition phase', depends_on: ISSUE_663_PROSE },
{ number: '654', name: 'Prior phase' },
]);
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '663');
assert.deepEqual(row.dep_phases, ['654']);
for (const junk of ['663', '2026', '09', '14', '7', '3', '1843', '1977', '1985', '1992']) {
assert.ok(!row.dep_phases.includes(junk), `must not scrape ${junk} as a dependency`);
}
assert.strictEqual(row.deps_satisfied, false, '654 is incomplete — the only real reference');
});
test('#4764: a date-only depends_on prose yields no dependencies and a satisfied row', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '674', name: 'Ops current', depends_on: 'Nothing. Opened by the owner on 2026-09-15 after asking whether the operations app was current with the backend.' },
]);
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '674');
assert.deepEqual(row.dep_phases, []);
assert.strictEqual(row.deps_satisfied, true);
assert.strictEqual(row.deps_display, '—');
});
test('#4764: sha-bearing prose contributes no dependency tokens', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '607', name: 'Sha phase', depends_on: 'Landed in 8bf403100d and 9445745c08; follow-up 76aea5c36f tracked separately.' },
]);
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '607');
assert.deepEqual(row.dep_phases, []);
assert.strictEqual(row.deps_satisfied, true);
});
test('#4764: Phase lists stay fully extracted (and/comma separators)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'A', complete: true },
{ number: '2', name: 'B', complete: true },
{ number: '3', name: 'C', depends_on: 'Phases 1 and 2' },
{ number: '4', name: 'D', depends_on: 'Phase 1, Phase 2' },
{ number: '5', name: 'E', depends_on: 'Phase 1 & 2' },
{ number: '6', name: 'F', depends_on: 'Phase 1 to 2' },
]);
// Completion is DISK-strict (ADR-3180): a ticked checkbox does not feed
// completedNums — the dep targets need real dirs + passed verifications.
for (const n of [1, 2]) {
writePassedVerification(scaffoldPhase(tmpDir, n, { plans: 1, summaries: 1 }), `0${n}`);
}
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
assert.deepEqual(output.phases.find((p) => p.number === '3').dep_phases, ['1', '2']);
assert.deepEqual(output.phases.find((p) => p.number === '4').dep_phases, ['1', '2']);
assert.deepEqual(output.phases.find((p) => p.number === '5').dep_phases, ['1', '2'], '& separator');
assert.deepEqual(output.phases.find((p) => p.number === '6').dep_phases, ['1', '2'], 'to-range separator');
assert.strictEqual(output.phases.find((p) => p.number === '3').deps_satisfied, true);
assert.strictEqual(output.phases.find((p) => p.number === '5').deps_satisfied, true);
assert.strictEqual(output.phases.find((p) => p.number === '6').deps_satisfied, true);
});
test('#4764: Oxford-comma lists stay fully extracted, and a missing member blocks', () => {
// Adversarial-review fold-in: "Phases 1, 2, and 3" is the most common
// English enumeration; the first grammar cut dropped 603-style tail
// members, silently clearing a real blocker (the dangerous direction).
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'A', complete: true },
{ number: '2', name: 'B', complete: true },
{ number: '3', name: 'C', depends_on: 'Phases 1, 2, and 3' },
]);
for (const n of [1, 2]) {
writePassedVerification(scaffoldPhase(tmpDir, n, { plans: 1, summaries: 1 }), `0${n}`);
}
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '3');
assert.deepEqual(row.dep_phases, ['1', '2']);
assert.strictEqual(row.deps_satisfied, true);
});
test('#4764: hyphen ranges extract both endpoints and a missing tail blocks', () => {
// House style writes ranges ("Phases 1-9"); endpoints are the written
// references (the pre-#4764 scrape also kept only endpoints).
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'A', complete: true },
{ number: '3', name: 'C' },
{ number: '4', name: 'D', depends_on: 'Phases 1-3' },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { plans: 1, summaries: 1 }), '01');
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '4');
assert.deepEqual(row.dep_phases, ['1', '3']);
assert.strictEqual(row.deps_satisfied, false, '3 is incomplete — a real blocker must not be dropped');
});
test('#4764 property: extraction pulls exactly the Phase-prefixed references out of arbitrary prose', () => {
// House fast-check config (seed 42); per-call numRuns caps the cost — each
// run spawns the real CLI (runGsdTools), unlike the pure-function properties.
const fc = require('./helpers/fast-check-setup.cjs');
// Junk fragments whose digit runs the old whole-field scrape pulled in as
// "dependencies": calendar dates, git shas, ledger ids, counts, round names.
// (fc v4 has no hexaString — stringMatching is the legal sha generator.)
const junkFragments = [
fc.nat({ max: 28 }).map((d) => `2026-09-${String(d).padStart(2, '0')}`),
fc.stringMatching(/[0-9a-f]{8,10}/),
fc.nat({ max: 99999 }).map((n) => `WINDOWS #${n}`),
fc.nat({ max: 999 }).map((n) => `#${n}-#${n + 1}`),
fc.nat({ max: 9 }).map((n) => `round ${n}-DISPOSITION (${n}/3`),
];
const refFragment = fc.nat({ max: 8 }).map((n) => `Phase ${n + 1}`);
const listFragment = fc.nat({ max: 7 }).map((n) => `Phases ${n + 1}, ${n + 2}, and ${n + 3}`);
const selfFragment = fc.constant('Phase 9');
// The phase under test is 9: any "Phase 9" mention in its own prose must be
// dropped as a self-reference.
fc.assert(
fc.property(
fc.array(fc.oneof(...junkFragments, refFragment, listFragment, selfFragment), { maxLength: 12 }),
(fragments) => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '9', name: 'Property phase', depends_on: fragments.join('; ') + '.' },
{ number: '1', name: 'Ref target', complete: true },
]);
const output = JSON.parse(runGsdTools('init manager', tmpDir).output);
const row = output.phases.find((p) => p.number === '9');
const extracted = row.dep_phases.map((d) => String(d));
// Independent spec mirror: the expected set is every phase token a
// fragment references ("Phase N" or inside a "Phases …" list),
// minus the row's own number — junk never contributes.
const expected = new Set();
for (const f of fragments) {
const refMatch = /^phases?\s+(.*)$/i.exec(f);
if (!refMatch) continue;
for (const tok of refMatch[1].match(/\d+(?:\.\d+)*/g) || []) {
if (tok !== '9') expected.add(tok);
}
}
assert.deepEqual(
[...extracted].sort(),
[...expected].sort(),
`extraction must pull exactly the Phase-prefixed references from: ${fragments.join('; ')}`,
);
},
),
{ numRuns: 20 },
);
});
});