fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline (#4318)

* test(#4093): regression matrix for advance-plan zero-labeled-fields decline

* fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline

* refactor(#4093): collapse IIFE to a plain block (review finding)

* docs(#4093): document the advance-plan recovery decline + changeset

* chore(#4093): backfill PR number in changeset

* fix(#4093): budget lint-compiled-artifact-sync's tsc compile as a compile, not a probe

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 10:46:37 -04:00
committed by GitHub
parent d9d16551b7
commit 2e1ede6d99
6 changed files with 385 additions and 9 deletions

View File

@@ -183,8 +183,20 @@ describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457
});
test('lint-compiled-artifact-sync exits 0 with nothing left to check', () => {
// #4093 CI: this spawn is NOT the PROBE class the shared default below
// describes. The script's "nothing left to check" path still runs a FULL
// `tsc -p tsconfig.build.json` compile to a throwaway outDir whenever any
// compiled artifact remains tracked (ten are, deliberately — ADR-457's
// staged end state), and under CI shard load that compile can exceed the
// 15s probe budget, dying to a SIGTERM with empty piped stdout (observed
// twice on ubuntu shard 1/3). Per helpers/timeouts.cjs's own rule, a call
// site that genuinely differs from its class — "a real `tsc` compile" —
// keeps its own local constant with its own justifying comment; see
// tests/ensure-runtime-build.test.cjs's BUILD_TIMEOUT_MS for the other
// instance. 60s is that same class, sized for the cold-cache CI case.
const TSC_COMPILE_TIMEOUT_MS = 60000;
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
const result = run(process.execPath, args);
const result = run(process.execPath, args, { timeoutMs: TSC_COMPILE_TIMEOUT_MS });
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
});
});

View File

@@ -2096,6 +2096,156 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => {
assert.ok(updated.includes('Plan: 2 of 3'), 'Plan counter should advance to 2 of 3');
});
});
describe('cmdStateAdvancePlan #4093 zero-labeled-fields recovery decline', () => {
// The reporter's exact shape: ## Current Position has drifted to pure
// narrative prose — zero matches for Phase:/Plan:/Current Plan:/Total
// Plans in Phase: anywhere in the file, bold or plain — while frontmatter
// still carries current_phase and the phase directory still holds the
// plan/summary set that IS the position. advance-plan must not strand the
// caller at a bare "cannot parse" error: the decline carries a
// machine-readable reason plus the disk-derived facts needed to repair.
const narrativeFixture = [
'---',
'status: Executing',
'current_phase: 01',
'current_phase_name: Implementation',
'last_activity: 2026-09-01',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'**Phase 1 plan 2** (auth flow): EXECUTED — token round-trip verified end to end.',
"Follow-ups captured in plan 3's tasks.",
'',
'## Session',
'',
'Session ID: abc',
'',
].join('\n');
const seedPhaseDir = (dir, planCount, summaryCount) => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', dir);
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= planCount; i++) {
fs.writeFileSync(path.join(phaseDir, `01-0${i}-PLAN.md`), `# plan ${i}\n`);
}
for (let i = 1; i <= summaryCount; i++) {
fs.writeFileSync(path.join(phaseDir, `01-0${i}-SUMMARY.md`), `# summary ${i}\n`);
}
return phaseDir;
};
test('#4093 declines with disk-derived repair guidance when Current Position has zero labeled fields', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), narrativeFixture);
seedPhaseDir('01-impl', 2, 1);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(typeof out.error === 'string' && /cannot read the plan position/i.test(out.error),
`error should still name the unreadable plan position; got: ${JSON.stringify(out)}`);
assert.strictEqual(out.reason, 'plan_position_unreadable',
`reason should be plan_position_unreadable; got: ${JSON.stringify(out)}`);
assert.strictEqual(out.phase_dir, '01-impl',
`phase_dir should name the disk phase directory; got: ${JSON.stringify(out)}`);
assert.strictEqual(out.disk.plan_count, 2,
`disk.plan_count should count the 2 plan files; got: ${JSON.stringify(out.disk)}`);
assert.strictEqual(out.disk.summarized_count, 1,
`disk.summarized_count should count the 1 summary; got: ${JSON.stringify(out.disk)}`);
assert.strictEqual(out.suggested.current_plan, 2,
`next plan after 1 summarized of 2 is 2; got: ${JSON.stringify(out.suggested)}`);
assert.strictEqual(out.suggested.total_plans, 2,
`suggested total is the on-disk plan count; got: ${JSON.stringify(out.suggested)}`);
assert.ok(out.suggested.lines.includes('Current Plan: 2') && out.suggested.lines.includes('Total Plans in Phase: 2'),
`suggested lines should name the legacy pair to re-insert; got: ${JSON.stringify(out.suggested)}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(after, narrativeFixture,
'a recovery decline must leave STATE.md byte-identical');
});
test('#4093 resolves the position phase from the Phase line when frontmatter has none', () => {
const noFm = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 2 — Build out',
'',
'All narrative from here; the labeled plan lines were displaced by executor notes.',
'',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), noFm);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-second');
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= 3; i++) fs.writeFileSync(path.join(phaseDir, `02-0${i}-PLAN.md`), `# plan ${i}\n`);
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# summary 1\n');
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.reason, 'plan_position_unreadable', `got: ${JSON.stringify(out)}`);
assert.strictEqual(out.phase_dir, '02-second',
`phase should resolve from the Phase: prose line; got: ${JSON.stringify(out)}`);
assert.strictEqual(out.suggested.current_plan, 2, `got: ${JSON.stringify(out.suggested)}`);
assert.strictEqual(out.suggested.total_plans, 3, `got: ${JSON.stringify(out.suggested)}`);
});
test('#4093 names the reason even when no phase can be resolved from disk or frontmatter', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPure narrative, no labeled lines anywhere.\n',
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.reason, 'plan_position_unreadable', `got: ${JSON.stringify(out)}`);
assert.ok(/cannot read the plan position/i.test(out.error),
`the accepted-shape sentence must survive; got: ${out.error}`);
assert.strictEqual(out.phase_dir, undefined,
`no resolvable phase means no phase_dir; got: ${JSON.stringify(out)}`);
assert.strictEqual(out.disk, undefined, `no resolvable phase means no disk block`);
});
test('#4093 omits suggested values when the phase directory has no plan files', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), narrativeFixture);
seedPhaseDir('01-impl', 0, 0);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.reason, 'plan_position_unreadable', `got: ${JSON.stringify(out)}`);
assert.strictEqual(out.phase_dir, '01-impl', `got: ${JSON.stringify(out)}`);
assert.strictEqual(out.disk.plan_count, 0, `got: ${JSON.stringify(out.disk)}`);
assert.strictEqual(out.suggested, undefined,
`zero plans on disk means nothing to suggest; got: ${JSON.stringify(out)}`);
});
test('#4093 gives the same recovery decline for a present-but-unreadable plan field', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
narrativeFixture.replace("Follow-ups captured in plan 3's tasks.", 'Plan: TBD'),
);
seedPhaseDir('01-impl', 2, 1);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.reason, 'plan_position_unreadable', `got: ${JSON.stringify(out)}`);
assert.strictEqual(out.phase_dir, '01-impl', `got: ${JSON.stringify(out)}`);
assert.strictEqual(out.suggested.current_plan, 2, `got: ${JSON.stringify(out.suggested)}`);
assert.strictEqual(out.advanced, undefined, 'an unreadable position must never be advanced');
});
});
});
describe('cmdStateRecordMetric (state record-metric)', () => {