fix(verification): keep passed uat results across verifier re-runs

A phase whose human UAT passed could loop forever between execute-phase
and verify-work. A gap-closure plan, or a later phase editing a covered
file, made the report stale; verify-work only recorded a passed UAT
against human_needed; the re-run verifier could not see the UAT file and
re-emitted human_needed; and execute-phase's human_needed branch then
rewrote *-UAT.md with every row back to [pending].

- verification.uat-evidence: recorded UAT rows plus the covered
  implementation files changed since the UAT. The verifier (Step 8b)
  treats a row that passed on unchanged code as verified and re-lists
  only affected rows, each with a retest_reason.
- verification.seed-uat: merges the report's human items into *-UAT.md.
  Existing rows are kept byte-for-byte; a pass is reset only with a
  recorded reason.
- verification.canonicalize-uat: the single human_needed -> passed flip,
  gated on the uat-only row predicate; refuses a stale report.
- verification.status reports stale_reason; init.progress lists every
  executed-but-stale phase in reverify_phases.
- verify-work and progress re-run the verifier themselves for a stale
  report instead of routing to execute-phase; execute-phase records a
  gap-closure checkpoint's UAT answers before dispatching the verifier.

Fail-closed properties are unchanged: a malformed fingerprint and a real
change to covered code still read stale.

Emitted-Drift-Ack-Growth: execute-phase.md — verify_phase_goal gains the gap-closure uat-record pointer and the merge-not-overwrite human_needed branch; the uat template it replaced moved into verification.seed-uat
Emitted-Drift-Ack-Growth: msd-verifier.md — new step 8b pointer to the lazily loaded verifier-uat-evidence reference plus the human_verified frontmatter key
Emitted-Drift-Ack-Growth: progress.md — route v.stale now re-verifies every stale executed phase in place instead of naming one command
This commit is contained in:
Jakub Zych
2026-10-11 01:44:30 +02:00
parent d1a3a31731
commit aa03fc7fa9
26 changed files with 1617 additions and 85 deletions

View File

@@ -366,6 +366,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -383,6 +384,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -485,6 +487,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -272,6 +272,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -289,6 +290,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -391,6 +393,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -336,6 +336,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -353,6 +354,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -455,6 +457,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -365,6 +365,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -382,6 +383,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -484,6 +486,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -339,6 +339,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -356,6 +357,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -458,6 +460,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -437,6 +437,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -454,6 +455,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -556,6 +558,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

View File

@@ -397,6 +397,7 @@
"msd-core/references/sketch-variant-patterns.md",
"msd-core/references/specless-probe-fallback.md",
"msd-core/references/spidr-splitting.md",
"msd-core/references/stale-reverification.md",
"msd-core/references/tdd.md",
"msd-core/references/thinking-models-debug.md",
"msd-core/references/thinking-models-execution.md",
@@ -414,6 +415,7 @@
"msd-core/references/verification-patterns.md",
"msd-core/references/verifier-evidence-gate.md",
"msd-core/references/verifier-phase-gates.md",
"msd-core/references/verifier-uat-evidence.md",
"msd-core/references/verifier-wiring-patterns.md",
"msd-core/references/verify-command-path-resolvability.md",
"msd-core/references/verify-mvp-mode.md",
@@ -516,6 +518,7 @@
"msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"msd-core/workflows/execute-phase/steps/executor-progress-policy.md",
"msd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"msd-core/workflows/execute-phase/steps/gap-closure-uat-record.md",
"msd-core/workflows/execute-phase/steps/partial-wave.md",
"msd-core/workflows/execute-phase/steps/per-plan-executor-routing.md",
"msd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",

500
tests/uat-evidence.test.cjs Normal file
View File

@@ -0,0 +1,500 @@
'use strict';
/**
* Tests for the UAT-evidence seam (src/uat-evidence.cts) and the stale-reason
* split on readVerificationStatus.
*
* The bug: a phase whose human UAT passed could not reach `passed`. A
* gap-closure plan (or a later phase editing a covered file) made the report
* `stale`; verify-work only recorded a passed UAT against `human_needed`; the
* re-run verifier could not see the UAT file and re-emitted `human_needed`;
* and execute-phase's human_needed branch rewrote `*-UAT.md` with every row
* `[pending]`. Each acceptance scenario below simulates the verifier re-run by
* writing the report a verifier would write, then drives the real verbs.
*
* All assertions are on structured values (typed results, `--json` CLI output,
* parsed UAT rows) — never on rendered prose.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runMsdTools } = require('./helpers.cjs');
const {
readVerificationStatus,
computeCoveredDigest,
STALE_REASONS,
} = require('../msd-core/bin/lib/verification.cjs');
const {
CANONICALIZE_REASON,
canonicalizeHumanNeeded,
readUatEvidence,
seedUatFromVerification,
scanUatRows,
} = require('../msd-core/bin/lib/uat-evidence.cjs');
const PHASE = '03-demo';
const ITEMS = [
{ test: 'Login form renders', expected: 'Two fields and a submit button' },
{ test: 'Logout clears the session', expected: 'Redirected to /login' },
];
let root;
let phaseDir;
function rel(...segments) {
return ['.planning', 'phases', PHASE, ...segments].join('/');
}
function writePlan(n) {
fs.writeFileSync(path.join(phaseDir, `03-${n}-PLAN.md`), `plan ${n}\n`);
fs.writeFileSync(path.join(phaseDir, `03-${n}-SUMMARY.md`), `summary ${n}\n`);
}
function artifacts(...plans) {
return plans.flatMap((n) => [rel(`03-${n}-PLAN.md`), rel(`03-${n}-SUMMARY.md`)]);
}
/** Write the report a verifier run would write over `covered`, digest computed for real. */
function writeReport({ status, covered, items = [] }) {
const digest = computeCoveredDigest(root, covered, undefined, { phaseDir });
assert.ok(digest, 'fixture covered files must fingerprint');
const lines = [
'---',
`phase: ${PHASE}`,
`status: ${status}`,
`covered_files: [${covered.join(', ')}]`,
`covered_digest: "${digest}"`,
];
if (items.length > 0) {
lines.push('human_verification:');
for (const item of items) {
lines.push(` - test: "${item.test}"`, ` expected: "${item.expected}"`, ' why_human: "visual"');
if (item.retest_reason) lines.push(` retest_reason: "${item.retest_reason}"`);
}
}
lines.push('---', '', '# Verification', '');
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), lines.join('\n'));
}
function writePassedUat() {
fs.writeFileSync(
path.join(phaseDir, '03-UAT.md'),
[
'---',
'status: complete',
`phase: ${PHASE}`,
'source: [03-VERIFICATION.md]',
'started: 2026-10-01T10:00:00.000Z',
'updated: 2026-10-01T11:00:00.000Z',
'---',
'',
'## Current Test',
'',
'[testing complete]',
'',
'## Tests',
'',
'### 1. Login form renders',
'expected: Two fields and a submit button',
'result: pass',
'reported: "both fields there, button works"',
'',
'### 2. Logout clears the session',
'expected: Redirected to /login',
'result: pass',
'reported: "landed on /login"',
'',
'## Summary',
'',
'total: 2',
'passed: 2',
'issues: 0',
'pending: 0',
'skipped: 0',
'blocked: 0',
'',
'## Gaps',
'',
'- truth: "Logout clears the session"',
' status: resolved',
' test: 2',
'',
].join('\n'),
);
}
function uatBytes() {
return fs.readFileSync(path.join(phaseDir, '03-UAT.md'), 'utf-8');
}
function uatRows() {
return scanUatRows(uatBytes().split('\n'));
}
/** Pin a file's mtime (the fixture is not a git repo, so mtime is the clock). */
function setMtime(file, isoTime) {
const t = new Date(isoTime);
fs.utimesSync(path.join(root, file), t, t);
}
beforeEach(() => {
root = createTempProject('msd-uat-evidence-');
phaseDir = path.join(root, '.planning', 'phases', PHASE);
fs.mkdirSync(phaseDir, { recursive: true });
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'auth.js'), 'export const login = 1;\n');
fs.writeFileSync(path.join(root, 'src', 'billing.js'), 'export const bill = 1;\n');
writePlan('01');
writePlan('02');
});
afterEach(() => {
cleanup(root);
});
describe('readVerificationStatus — stale_reason', () => {
test('a fresh report carries no stale_reason', () => {
writeReport({ status: 'passed', covered: [...artifacts('01', '02'), 'src/auth.js'] });
const result = readVerificationStatus(phaseDir);
assert.equal(result.status, 'passed');
assert.equal('stale_reason' in result, false);
assert.equal('raw_status' in result, false);
});
test('a PLAN/SUMMARY the report never declared reads uncovered_artifacts', () => {
writeReport({ status: 'human_needed', covered: [...artifacts('01', '02'), 'src/auth.js'], items: ITEMS });
writePlan('03');
const result = readVerificationStatus(phaseDir);
assert.equal(result.status, 'stale');
assert.equal(result.stale_reason, 'uncovered_artifacts');
assert.equal(result.raw_status, 'human_needed');
});
test('an edited covered file reads covered_changed', () => {
writeReport({ status: 'passed', covered: [...artifacts('01', '02'), 'src/auth.js'] });
fs.writeFileSync(path.join(root, 'src', 'auth.js'), 'export const login = 2;\n');
const result = readVerificationStatus(phaseDir);
assert.equal(result.status, 'stale');
assert.equal(result.stale_reason, 'covered_changed');
assert.equal(result.raw_status, 'passed');
});
test('a malformed fingerprint still fails closed, as fingerprint_malformed', () => {
fs.writeFileSync(
path.join(phaseDir, '03-VERIFICATION.md'),
['---', 'status: passed', 'covered_files: [src/auth.js]', '---', ''].join('\n'),
);
const result = readVerificationStatus(phaseDir);
assert.equal(result.status, 'stale');
assert.equal(result.stale_reason, 'fingerprint_malformed');
});
test('every emitted reason belongs to the frozen STALE_REASONS set', () => {
assert.deepEqual([...STALE_REASONS].sort(), [
'covered_changed',
'fingerprint_malformed',
'summary_newer',
'uncovered_artifacts',
]);
});
});
describe('verification.seed-uat — merge, never overwrite', () => {
test('creates the UAT file with every item pending when none exists', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
const seed = seedUatFromVerification(phaseDir);
assert.equal(seed.created, true);
assert.equal(seed.pending, 2);
assert.deepEqual(seed.appended, [
{ test: 1, name: 'Login form renders' },
{ test: 2, name: 'Logout clears the session' },
]);
assert.deepEqual(uatRows().map((r) => r.result), ['pending', 'pending']);
});
test('no-clobber: passed rows and their reported evidence are left byte-identical', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
writePassedUat();
const before = uatBytes();
const seed = seedUatFromVerification(phaseDir);
assert.equal(seed.changed, false);
assert.equal(seed.created, false);
assert.equal(seed.pending, 0);
assert.equal(seed.all_passing, true);
assert.equal(uatBytes(), before);
});
test('an unseen item is appended as pending; existing results are kept', () => {
writePassedUat();
writeReport({
status: 'human_needed',
covered: artifacts('01', '02'),
items: [...ITEMS, { test: 'Password reset email arrives', expected: 'Email within a minute' }],
});
const seed = seedUatFromVerification(phaseDir, { now: new Date('2026-10-10T00:00:00.000Z') });
assert.deepEqual(seed.appended, [{ test: 3, name: 'Password reset email arrives' }]);
assert.deepEqual(seed.kept.map((r) => r.result), ['pass', 'pass']);
assert.equal(seed.pending, 1);
assert.deepEqual(uatRows().map((r) => [r.test, r.result]), [[1, 'pass'], [2, 'pass'], [3, 'pending']]);
const fm = runMsdTools(['frontmatter', 'get', rel('03-UAT.md')], root);
const parsed = JSON.parse(fm.output);
assert.equal(parsed.status, 'testing');
assert.equal(parsed.updated, '2026-10-10T00:00:00.000Z');
});
test('item matching ignores case and punctuation', () => {
writePassedUat();
writeReport({
status: 'human_needed',
covered: artifacts('01', '02'),
items: [{ test: 'login form renders.', expected: 'x' }],
});
const seed = seedUatFromVerification(phaseDir);
assert.equal(seed.changed, false);
assert.deepEqual(seed.appended, []);
});
test('a passing row is reset only with a recorded retest_reason, written into the row', () => {
writePassedUat();
writeReport({
status: 'human_needed',
covered: artifacts('01', '02'),
items: [
{ ...ITEMS[0], retest_reason: 'src/auth.js changed after the UAT pass' },
ITEMS[1],
],
});
const seed = seedUatFromVerification(phaseDir);
assert.deepEqual(seed.retested, [
{ test: 1, name: 'Login form renders', previous_result: 'pass', reason: 'src/auth.js changed after the UAT pass' },
]);
assert.equal(seed.pending, 1);
const rows = uatRows();
assert.equal(rows[0].result, 'pending');
assert.equal(rows[0].retest_reason, 'src/auth.js changed after the UAT pass');
assert.equal(rows[1].result, 'pass');
});
test('a row inside a fenced block is not a row', () => {
const rows = scanUatRows(
['## Tests', '', '```', '### 9. Fake', 'result: pass', '```', '', '### 1. Real', 'result: [pending]', ''],
);
assert.deepEqual(rows.map((r) => [r.test, r.result]), [[1, 'pending']]);
});
test('returns null when the phase has no verification report', () => {
assert.equal(seedUatFromVerification(phaseDir), null);
});
});
describe('verification.canonicalize-uat — the single flip seam', () => {
test('flips human_needed to passed when every UAT row passed', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
writePassedUat();
const result = canonicalizeHumanNeeded(phaseDir);
assert.deepEqual(result, {
canonicalized: true,
status: 'passed',
reason: CANONICALIZE_REASON.UAT_PASSED,
blockers: [],
});
assert.equal(readVerificationStatus(phaseDir).status, 'passed');
});
test('refuses while a UAT row is pending, and names the blocking rows (#4663)', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
seedUatFromVerification(phaseDir);
const result = canonicalizeHumanNeeded(phaseDir);
assert.equal(result.canonicalized, false);
assert.equal(result.reason, CANONICALIZE_REASON.UAT_NOT_PASSED);
assert.equal(result.blockers.length, 2);
assert.equal(readVerificationStatus(phaseDir).status, 'human_needed');
});
test('refuses when there is no UAT evidence at all (zero issues is not a pass)', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
const result = canonicalizeHumanNeeded(phaseDir);
assert.equal(result.canonicalized, false);
assert.equal(result.reason, CANONICALIZE_REASON.UAT_NOT_PASSED);
});
test('never flips a stale report, even with a fully passed UAT', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
writePassedUat();
writePlan('03');
const result = canonicalizeHumanNeeded(phaseDir);
assert.equal(result.canonicalized, false);
assert.equal(result.status, 'stale');
assert.equal(result.reason, CANONICALIZE_REASON.NOT_HUMAN_NEEDED);
});
test('CLI: verification canonicalize-uat emits the typed result', () => {
writeReport({ status: 'human_needed', covered: artifacts('01', '02'), items: ITEMS });
writePassedUat();
const run = runMsdTools(['verification', 'canonicalize-uat', rel()], root);
assert.equal(run.success, true);
assert.equal(JSON.parse(run.output).reason, 'uat_passed');
});
});
describe('acceptance — a passed UAT is neither discarded nor re-requested', () => {
test('gap-closure fixture: gap plan + passed UAT reaches passed without re-testing', () => {
writeReport({ status: 'human_needed', covered: [...artifacts('01', '02'), 'src/auth.js'], items: ITEMS });
writePlan('03'); // the gap-closure plan and its summary
writePassedUat(); // the human re-ran UAT at the gap plan's checkpoint
assert.equal(readVerificationStatus(phaseDir).stale_reason, 'uncovered_artifacts');
// Worst-case verifier re-run: it covers plan 03 but re-lists both human
// items without consulting the UAT file (no retest_reason).
writeReport({ status: 'human_needed', covered: [...artifacts('01', '02', '03'), 'src/auth.js'], items: ITEMS });
const before = uatBytes();
const seed = seedUatFromVerification(phaseDir);
assert.equal(seed.pending, 0);
assert.equal(uatBytes(), before);
assert.equal(canonicalizeHumanNeeded(phaseDir).canonicalized, true);
const status = readVerificationStatus(phaseDir);
assert.equal(status.status, 'passed');
assert.equal(status.next_command, '');
assert.deepEqual(uatRows().map((r) => r.result), ['pass', 'pass']);
});
test('real change still stales, and the affected row is marked for re-test', () => {
const covered = [...artifacts('01', '02'), 'src/auth.js', 'src/billing.js'];
writeReport({ status: 'human_needed', covered, items: ITEMS });
writePassedUat();
assert.equal(canonicalizeHumanNeeded(phaseDir).canonicalized, true);
setMtime(rel('03-UAT.md'), '2026-10-01T11:00:00.000Z');
setMtime('src/billing.js', '2026-09-30T00:00:00.000Z');
fs.writeFileSync(path.join(root, 'src', 'auth.js'), 'export const login = 2;\n');
setMtime('src/auth.js', '2026-10-05T00:00:00.000Z');
const status = readVerificationStatus(phaseDir);
assert.equal(status.status, 'stale');
assert.equal(status.stale_reason, 'covered_changed');
const evidence = readUatEvidence(phaseDir);
assert.deepEqual(evidence.changed_since_uat, [{ file: 'src/auth.js', reason: 'modified' }]);
assert.equal(evidence.code_unchanged_since_uat, false);
assert.deepEqual(evidence.checked_files, ['src/auth.js', 'src/billing.js']);
assert.deepEqual(evidence.rows.map((r) => r.passing), [true, true]);
// The verifier re-lists only the row the changed file bears on.
writeReport({
status: 'human_needed',
covered,
items: [{ ...ITEMS[0], retest_reason: 'src/auth.js changed after the UAT pass' }],
});
const seed = seedUatFromVerification(phaseDir);
assert.deepEqual(seed.retested.map((r) => r.test), [1]);
assert.deepEqual(uatRows().map((r) => r.result), ['pending', 'pass']);
const refused = canonicalizeHumanNeeded(phaseDir);
assert.equal(refused.canonicalized, false);
assert.equal(refused.reason, CANONICALIZE_REASON.UAT_NOT_PASSED);
assert.equal(readVerificationStatus(phaseDir).status, 'human_needed');
});
test('a deleted covered file counts as changed since the UAT', () => {
writePassedUat();
const evidence = readUatEvidence(phaseDir, ['src/auth.js', 'src/gone.js', rel('03-01-SUMMARY.md')]);
assert.deepEqual(evidence.checked_files, ['src/auth.js', 'src/gone.js']);
assert.deepEqual(
evidence.changed_since_uat.filter((c) => c.reason === 'missing'),
[{ file: 'src/gone.js', reason: 'missing' }],
);
});
test('no UAT file is no evidence', () => {
const evidence = readUatEvidence(phaseDir, ['src/auth.js']);
assert.equal(evidence.uat_file, '');
assert.equal(evidence.code_unchanged_since_uat, false);
assert.deepEqual(evidence.rows, []);
});
test('loop guard: after UAT passes, progress does not route the phase anywhere', () => {
writeReport({ status: 'human_needed', covered: [...artifacts('01', '02'), 'src/auth.js'], items: ITEMS });
writePassedUat();
canonicalizeHumanNeeded(phaseDir);
const run = runMsdTools(['init', 'progress'], root);
assert.equal(run.success, true);
const progress = JSON.parse(run.output);
const phase = progress.phases.find((p) => p.number === '03');
assert.equal(phase.verification_status, 'passed');
assert.equal(phase.phase_complete, true);
assert.equal(phase.verification_next_command, '');
assert.equal(phase.verification_stale_reason, '');
assert.deepEqual(progress.reverify_phases, []);
});
test('cross-phase drift: finished phases surface as ONE re-verify action and keep their UAT', () => {
// Phase 03 passed with UAT; a second finished phase shares src/auth.js.
const covered03 = [...artifacts('01', '02'), 'src/auth.js', 'src/billing.js'];
writeReport({ status: 'human_needed', covered: covered03, items: ITEMS });
writePassedUat();
canonicalizeHumanNeeded(phaseDir);
setMtime(rel('03-UAT.md'), '2026-10-01T11:00:00.000Z');
setMtime('src/auth.js', '2026-09-30T00:00:00.000Z');
const otherDir = path.join(root, '.planning', 'phases', '04-other');
fs.mkdirSync(otherDir, { recursive: true });
fs.writeFileSync(path.join(otherDir, '04-01-PLAN.md'), 'plan\n');
fs.writeFileSync(path.join(otherDir, '04-01-SUMMARY.md'), 'summary\n');
const covered04 = ['.planning/phases/04-other/04-01-PLAN.md', '.planning/phases/04-other/04-01-SUMMARY.md', 'src/billing.js'];
fs.writeFileSync(
path.join(otherDir, '04-VERIFICATION.md'),
[
'---',
'status: passed',
`covered_files: [${covered04.join(', ')}]`,
`covered_digest: "${computeCoveredDigest(root, covered04, undefined, { phaseDir: otherDir })}"`,
'---',
'',
].join('\n'),
);
// A later phase edits the shared framework file.
fs.writeFileSync(path.join(root, 'src', 'billing.js'), 'export const bill = 2;\n');
setMtime('src/billing.js', '2026-10-08T00:00:00.000Z');
const progress = JSON.parse(runMsdTools(['init', 'progress'], root).output);
assert.deepEqual(
progress.reverify_phases.map((p) => [p.number, p.stale_reason]),
[['03', 'covered_changed'], ['04', 'covered_changed']],
);
// Evidence names only the drifted file; both UAT rows still stand.
const evidence = readUatEvidence(phaseDir);
assert.deepEqual(evidence.changed_since_uat, [{ file: 'src/billing.js', reason: 'modified' }]);
assert.deepEqual(evidence.rows.map((r) => r.passing), [true, true]);
// The verifier finds neither row exercises billing: it re-verifies to
// `passed` with no human items. The UAT file is untouched and nothing is
// left to re-verify for phase 03.
const before = uatBytes();
writeReport({ status: 'passed', covered: covered03 });
assert.equal(seedUatFromVerification(phaseDir).changed, false);
assert.equal(uatBytes(), before);
const after = JSON.parse(runMsdTools(['init', 'progress'], root).output);
assert.deepEqual(after.reverify_phases.map((p) => p.number), ['04']);
});
test('CLI: verification uat-evidence and seed-uat emit JSON; unknown flags are usage errors', () => {
writeReport({ status: 'human_needed', covered: [...artifacts('01', '02'), 'src/auth.js'], items: ITEMS });
const seed = runMsdTools(['verification', 'seed-uat', rel()], root);
assert.equal(seed.success, true);
assert.equal(JSON.parse(seed.output).created, true);
const evidence = runMsdTools(['verification', 'uat-evidence', rel(), '--files', 'src/auth.js,src/billing.js'], root);
assert.equal(evidence.success, true);
assert.deepEqual(JSON.parse(evidence.output).checked_files, ['src/auth.js', 'src/billing.js']);
const bad = runMsdTools(['verification', 'uat-evidence', rel(), '--nope'], root);
assert.equal(bad.success, false);
});
});