fix(#3162): resolve active state phase before drift scan (#3208)

* test(02-01): reproduce template state validation drift

- derive command fixtures from the shipped STATE template
- pair passed-verification drift with a clean opposite-result control

* test(02-01): cover state phase resolution boundaries

- exercise precedence conflicts fallbacks and fail-closed directory handling
- prove canonical equality and reject outside-root verification evidence

* docs: add changeset for PR #3208

* Address review feedback

* fix(#3162): preserve phase validation after state refactor

* test(#3162): align validation scope cases
This commit is contained in:
Dennis Kim
2026-08-11 17:10:33 -04:00
committed by GitHub
parent 2076d450d7
commit 5e951540af
3 changed files with 367 additions and 49 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3208
---
**State validation properly detects drift** — Resolved an issue where state validation would silently fail to detect drift because it skipped scanning entirely when the shipped template lacked a specific field.

View File

@@ -1464,6 +1464,34 @@ function parseProsePhaseField(value: string | null): { phase: string | null; nam
return parsePhaseFromProse(value);
}
function resolveStatePhase(fm: Record<string, unknown>, body: string): {
phase: string | null;
name: string | null;
sources: {
frontmatter: string | null;
legacy_current_phase: string | null;
current_position_phase: string | null;
};
} {
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
const frontmatterRaw = stateFieldValue(fm, body, 'current_phase', null).value;
const legacyRaw = stateFieldValue(fm, currentPositionScope, null, 'Current Phase').value;
const currentPositionRaw = stateFieldValue(fm, currentPositionScope, null, 'Phase').value;
const sources = {
frontmatter: parseProsePhaseField(frontmatterRaw).phase,
legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
};
const prosePhase = parseProsePhaseField(currentPositionRaw);
return {
phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
name: stateFieldValue(fm, body, 'current_phase_name', null).value
?? stateFieldValue(fm, currentPositionScope, null, 'Current Phase Name').value
?? prosePhase.name,
sources,
};
}
function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } {
if (!value) return { date: null, description: null };
const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:\s+[—-]{1,2}\s+(.+))?$/);
@@ -1503,10 +1531,9 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
// full-body search only when no ## Current Position section exists, so files
// with no section heading keep their current behaviour.
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
const prosePhase = parseProsePhaseField(stateFieldValue(fm, currentPositionScope, null, 'Phase').value);
const currentPhase = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase.phase;
const currentPhaseName = stateFieldValue(fm, body, 'current_phase_name', 'Current Phase Name').value ?? prosePhase.name;
const resolvedPhase = resolveStatePhase(fm, body);
const currentPhase = resolvedPhase.phase;
const currentPhaseName = resolvedPhase.name;
const totalPhasesRaw = stateFieldValue(fm, body, 'total_phases', 'Total Phases').value;
const currentPlan = stateFieldValue(fm, body, 'current_plan', 'Current Plan').value;
const totalPlansRaw = stateFieldValue(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
@@ -3214,31 +3241,56 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
// site sees. Pass statePath so a truncated STATE.md is named in the #1882
// diagnostic rather than reported under a content digest.
const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
let scope: planningScopeMod.Scope = initialScope;
const scope: planningScopeMod.Scope = initialScope;
const status = stateFieldValue(fm, body, 'status', 'Status').value || '';
const currentPhase = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value;
const resolvedPhase = resolveStatePhase(fm, body);
const currentPhase = resolvedPhase.phase;
const totalPlansRaw = stateFieldValue(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const phasesDir = planningPaths(cwd).phases;
if (currentPhase === null) {
// #3162: nothing to scope the disk lookup to — the derivation cannot run
// at all. Distinct from "ran, found nothing to warn about" (scope stays
// COMPLETE elsewhere in this function).
if (scope === SCOPE.COMPLETE) scope = SCOPE.UNSCOPED;
} else if (fs.existsSync(phasesDir)) {
const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
if (phaseDir) {
const phaseDirPath = path.join(phasesDir, phaseDir.name);
const { planCount: diskPlans, summaryCount: diskSummaries, scope: planScanScope } = scanPhasePlans(phaseDirPath);
if (planScanScope !== SCOPE.COMPLETE && scope === SCOPE.COMPLETE) {
scope = planScanScope;
}
warnings.push('Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value');
drift['phase_reference'] = { reason: 'unresolved', selected: null, sources: resolvedPhase.sources };
output({ valid: false, warnings, drift, scope }, raw, undefined);
return;
}
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
warnings.push(`Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`);
drift['phase_reference'] = { reason: 'conflict', selected: currentPhase, sources: resolvedPhase.sources };
}
if (!fs.existsSync(phasesDir)) {
warnings.push(`Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'missing_root', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
return;
}
let phaseDirPath: string;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
if (!phaseDir) {
warnings.push(`Cannot validate phase drift: no phase directory matches phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'not_found', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
return;
}
phaseDirPath = path.join(phasesDir, phaseDir.name);
} catch {
warnings.push(`Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
return;
}
try {
const scan = scanPhasePlans(phaseDirPath);
if (scan.scope !== SCOPE.COMPLETE) {
throw new Error('phase plan scan is incomplete');
}
const { planCount: diskPlans, summaryCount: diskSummaries } = scan;
// Check plan count mismatch
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
@@ -3270,20 +3322,10 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
}
}
}
// else: phase resolved, but no matching directory on disk — a real
// answer (row 22), not a look failure. scope stays COMPLETE.
} catch {
// #3162/#2245: previously a silent best-effort swallow. The disk-scan
// failure (readdirSync/scanPhasePlans) means drift detection for this
// phase could not run this pass — that degrade is kept (validate still
// does not crash), but is now visible via `scope` instead of being
// indistinguishable from a clean pass.
scope = SCOPE.UNREADABLE;
}
} catch {
warnings.push(`Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase };
}
// else: phasesDir itself does not exist — a real answer (no phases on disk
// yet), not a look failure. scope stays COMPLETE.
const valid = warnings.length === 0;
output({ valid, warnings, drift, scope }, raw, undefined);

View File

@@ -59,6 +59,19 @@ function captureStdout(fn) {
return chunks.join('');
}
function readShippedStateTemplateBody(replacements) {
const templatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'state.md');
const template = fs.readFileSync(templatePath, 'utf-8');
const fencedDocument = template.match(/```markdown\r?\n([\s\S]*?)```/);
assert.ok(fencedDocument, 'gsd-core/templates/state.md must contain a fenced markdown document');
let body = fencedDocument[1];
for (const [target, replacement] of replacements) {
assert.ok(body.includes(target), `shipped state template must contain replacement target: ${target}`);
body = body.replace(target, replacement);
}
return body;
}
describe('state-snapshot command', () => {
let tmpDir;
@@ -3192,6 +3205,233 @@ describe('state validate command', () => {
cleanup(tmpDir);
});
test('template frontmatter phase reaches passed-verification drift on disk', () => {
const stateContent = readShippedStateTemplateBody([
['status: planning', ['current_phase: 2', 'status: executing'].join('\n')],
['Phase: [X] of [Y] ([Phase name])', 'Phase: 2 of 2 (State Validation Drift Diagnostics)'],
['Status: [Ready to plan / Planning / Ready to execute / In progress / Phase complete]', 'Status: Executing Phase 2'],
]);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-state-validation-drift-diagnostics');
fs.mkdirSync(phaseDir, { recursive: true });
writePassedVerification(tmpDir, '02-state-validation-drift-diagnostics', '02');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'passed verification must invalidate executing state');
assert.ok(output.warnings.length > 0, 'passed verification drift must emit a warning');
assert.deepStrictEqual(
output.drift.verification_status,
{ state_status: 'executing', verification: 'passed' },
'template frontmatter phase must reach the existing disk-backed verification drift check',
);
});
test('template-equivalent phase identities remain clean without disk drift', () => {
const stateContent = readShippedStateTemplateBody([
['status: planning', ['current_phase: 2', 'status: planning'].join('\n')],
['Phase: [X] of [Y] ([Phase name])', 'Phase: 02 of 2 (State Validation Drift Diagnostics)'],
['Status: [Ready to plan / Planning / Ready to execute / In progress / Phase complete]', 'Status: Planning'],
]);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
fs.mkdirSync(
path.join(tmpDir, '.planning', 'phases', '02-state-validation-drift-diagnostics'),
{ recursive: true },
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, true, 'equivalent phase identities without disk drift must stay valid');
assert.deepStrictEqual(output.warnings, [], 'clean control must not emit warnings');
assert.deepStrictEqual(output.drift, {}, 'clean control must not report drift');
});
test('legacy Current Phase fallback reaches passed-verification drift on disk', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 2',
'**Status:** Executing Phase 2',
'',
].join('\n'),
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-legacy'), { recursive: true });
writePassedVerification(tmpDir, '02-legacy', '02');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'legacy phase fallback must expose verification drift');
assert.deepStrictEqual(
output.drift.verification_status,
{ state_status: 'Executing Phase 2', verification: 'passed' },
);
});
test('Current Position Phase fallback reaches passed-verification drift on disk', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'## Current Position',
'',
'Phase: 2 of 2 (State Validation Drift Diagnostics)',
'Status: Executing Phase 2',
'',
].join('\n'),
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-canonical'), { recursive: true });
writePassedVerification(tmpDir, '02-canonical', '02');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'canonical phase fallback must expose verification drift');
assert.deepStrictEqual(
output.drift.verification_status,
{ state_status: 'Executing Phase 2', verification: 'passed' },
);
});
test('frontmatter phase wins conflicts and scans its selected directory', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'current_phase: 2',
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 of 2 (Foundation)',
'Status: Executing Phase 2',
'',
].join('\n'),
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-state-validation'), { recursive: true });
writePassedVerification(tmpDir, '02-state-validation', '02');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'conflicting sources must invalidate the result');
assert.strictEqual(output.drift.phase_reference.reason, 'conflict');
assert.strictEqual(output.drift.phase_reference.selected, '2');
assert.strictEqual(output.drift.phase_reference.sources.frontmatter, '2');
assert.strictEqual(output.drift.phase_reference.sources.current_position_phase, '1');
assert.deepStrictEqual(
output.drift.verification_status,
{ state_status: 'executing', verification: 'passed' },
'disk evidence must come from the authoritative frontmatter phase',
);
});
test('missing phase sources fail closed with phase-reference drift', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['# Project State', '', 'Status: Planning', ''].join('\n'),
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'missing phase source must not validate cleanly');
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
assert.strictEqual(output.drift.phase_reference.selected, null);
assert.ok(output.warnings.some(warning => /phase/i.test(warning)), 'warning must identify phase resolution');
});
test('non-scalar frontmatter phase fails closed without a body fallback', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'current_phase:',
' nested: 2',
'status: planning',
'---',
'',
'# Project State',
'',
].join('\n'),
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'non-scalar phase source must not validate cleanly');
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
assert.strictEqual(output.drift.phase_reference.sources.frontmatter, null);
});
test('missing phases root fails closed with phase-directory drift', () => {
cleanup(tmpDir);
tmpDir = createFixture({ planning: false, projectDoc: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['---', 'current_phase: 2', 'status: planning', '---', '', '# Project State', ''].join('\n'),
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'missing phases root must not validate cleanly');
assert.strictEqual(output.drift.phase_directory.reason, 'missing_root');
assert.ok(output.warnings.some(warning => /director/i.test(warning)), 'warning must identify the missing directory');
});
test('missing canonical phase-directory match fails closed', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['---', 'current_phase: 2', 'status: planning', '---', '', '# Project State', ''].join('\n'),
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'missing phase-directory match must not validate cleanly');
assert.strictEqual(output.drift.phase_directory.reason, 'not_found');
assert.strictEqual(output.drift.phase_directory.selected, '2');
});
test('crafted path-like phase cannot scan verification evidence outside phases root', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'current_phase: ../outside',
'status: executing',
'---',
'',
'# Project State',
'',
].join('\n'),
);
const outsideDir = path.join(tmpDir, '.planning', 'outside');
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(
path.join(outsideDir, '02-VERIFICATION.md'),
['---', 'status: passed', '---', '', '# Outside verification', ''].join('\n'),
);
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'crafted phase reference must fail closed');
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
assert.ok(!output.drift.verification_status, 'outside-root verification evidence must not be scanned');
});
test('STATE says executing + VERIFICATION.md shows passed emits warning', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
@@ -3253,6 +3493,22 @@ describe('state validate command', () => {
assert.strictEqual(output.warnings.length, 0, 'Should have no warnings');
});
test('archived "Current Phase:" line does not trigger false-positive conflict when frontmatter is correct', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\ncurrent_phase: 2\n---\n# Project State\n\n## Current Position\n**Phase:** 2\n\n## Archive\n**Current Phase:** 1 (completed last week)\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, true, 'Should be valid, legacy extractor should not read the archive');
assert.strictEqual(output.warnings.length, 0, 'Should have no phase_reference conflict warnings');
});
test('missing STATE.md returns graceful error', () => {
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, 'Should not crash');
@@ -3373,11 +3629,8 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
);
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
// ⛔ Rejected #2: a non-COMPLETE scope must NEVER be routed to valid:false.
assert.strictEqual(output.valid, true);
assert.strictEqual(output.warnings.length, 0);
assert.notStrictEqual(output.scope, SCOPE.COMPLETE, 'scope must be distinguishable from a real clean pass (B2)');
assert.strictEqual(output.scope, SCOPE.UNSCOPED);
assert.strictEqual(output.valid, false);
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
});
test('B5: missing phase dir differs from could-not-look (distinguishable from B4)', () => {
@@ -3395,11 +3648,8 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
// phases/ exists (createFixture) but has no matching 99-* directory.
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, true);
assert.strictEqual(output.warnings.length, 0);
// Resolvable phase + legitimately-absent directory is a real answer —
// COMPLETE — not the same non-answer as B4's totally unresolvable phase.
assert.strictEqual(output.scope, SCOPE.COMPLETE);
assert.strictEqual(output.valid, false);
assert.strictEqual(output.drift.phase_directory.reason, 'not_found');
});
test('B6: unreadable phases dir is surfaced, not swallowed', (t) => {
@@ -3432,8 +3682,28 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
const raw = captureStdout(() => stateLib.cmdStateValidate(tmpDir, false));
const output = JSON.parse(raw);
assert.strictEqual(output.valid, true, 'the previous silent degrade must not crash or fabricate a warning');
assert.strictEqual(output.scope, SCOPE.UNREADABLE);
assert.strictEqual(output.valid, false, 'an unreadable directory must not validate cleanly');
assert.strictEqual(output.drift.phase_directory.reason, 'unreadable');
});
test('B6b: unreadable selected phase directory fails closed', (t) => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['# Project State', '', '**Status:** Executing Phase 1', '**Current Phase:** 1', ''].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
const originalReaddirSync = fs.readdirSync;
mock.method(fs, 'readdirSync', (p, ...rest) => {
if (p === phaseDir) throw new Error('EACCES: permission denied, scandir');
return originalReaddirSync.call(fs, p, ...rest);
});
t.after(() => mock.restoreAll());
const output = JSON.parse(captureStdout(() => stateLib.cmdStateValidate(tmpDir, false)));
assert.strictEqual(output.valid, false, 'an unreadable selected phase must not validate cleanly');
assert.strictEqual(output.drift.phase_directory.reason, 'unreadable');
});
test('B7: one unreadable verification file does not abort the scan', (t) => {
@@ -3694,9 +3964,10 @@ describe('#3187 chain-owner identity — every consumer agrees with stateFieldVa
fs.writeFileSync(path.join(phase2Dir, '02-01-PLAN.md'), '# Plan\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, true, 'validate must have scanned phase 2 (the owner answer), which matches disk');
assert.strictEqual(output.warnings.length, 0);
assert.strictEqual(output.scope, SCOPE.COMPLETE);
assert.strictEqual(output.valid, false, 'conflicting phase sources must not validate cleanly');
assert.strictEqual(output.drift.phase_reference.reason, 'conflict');
assert.strictEqual(output.drift.phase_reference.selected, '2');
assert.ok(!output.drift.plan_count, 'validate must scan phase 2, not the shadowed phase 1');
});
test('C3: prune resolves the same phase as the owner', () => {