feat(#1956): flag cross-artifact fact drift in the plan drift guard (#3259)

* test(#1956): failing-first contract for cross-artifact fact-drift pass

* feat(#1956): flag cross-artifact fact drift in the plan drift guard

* fix(#1956): correct config-key assertion and bidirectional lifecycle-lag exemption

* docs(#1956): document the cross-artifact axis in the architecture reference

* feat(#1956): decide the phase-status drift axis deterministically

* fix(#1956): scope the progress-table lookup, abstain without a position section, rank deferred

* docs(#1956): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 14:23:35 -04:00
committed by GitHub
parent 9f57fa43ed
commit b7431a9259
14 changed files with 1116 additions and 11 deletions

View File

@@ -25,6 +25,7 @@ const {
AUTHORITY_RUNGS,
getEffectiveAuthority,
classifyDriftSeverity,
comparePhaseStatus,
} = require('../gsd-core/bin/lib/plan-drift-guard.cjs');
// ── 1. AUTHORITY_RUNGS sanity ────────────────────────────────────────────────
@@ -326,3 +327,277 @@ describe('plan-review-convergence.md uses gsd_run drift-guard seam', () => {
);
});
});
// ── 6. comparePhaseStatus unit tests (#1956) ────────────────────────────────
describe('comparePhaseStatus', () => {
test('equal ranks (STATE vocabulary vs ROADMAP vocabulary) → consistent', () => {
const result = comparePhaseStatus({ stateStatus: 'In progress', roadmapStatus: 'In Progress' });
assert.equal(result.verdict, 'consistent');
assert.equal(result.stateRank, result.roadmapRank);
});
test('Phase complete vs In Progress → drifted, NOT lag (the issue\'s canonical case)', () => {
const result = comparePhaseStatus({ stateStatus: 'Phase complete', roadmapStatus: 'In Progress' });
assert.equal(result.verdict, 'drifted');
assert.notEqual(result.verdict, 'lag', 'a completion disagreement must never be classified as lag, even though the ranks are only 1 apart');
});
test('Phase complete vs Not started → drifted', () => {
const result = comparePhaseStatus({ stateStatus: 'Phase complete', roadmapStatus: 'Not started' });
assert.equal(result.verdict, 'drifted');
});
test('Ready to plan vs In Progress → lag', () => {
const result = comparePhaseStatus({ stateStatus: 'Ready to plan', roadmapStatus: 'In Progress' });
assert.equal(result.verdict, 'lag');
});
test('unknown status on either side → uncheckable, and the other side\'s rank still resolves', () => {
const stateUnknown = comparePhaseStatus({ stateStatus: 'Frobnicating', roadmapStatus: 'In Progress' });
assert.equal(stateUnknown.verdict, 'uncheckable');
assert.equal(stateUnknown.stateRank, null);
assert.equal(stateUnknown.roadmapRank, 1, 'the resolvable side must still be diagnosable even when the other is unknown');
const roadmapUnknown = comparePhaseStatus({ stateStatus: 'Phase complete', roadmapStatus: 'Frobnicating' });
assert.equal(roadmapUnknown.verdict, 'uncheckable');
assert.equal(roadmapUnknown.roadmapRank, null);
assert.equal(roadmapUnknown.stateRank, 2, 'the resolvable side must still be diagnosable even when the other is unknown');
});
test('null / undefined / empty-string on either side → uncheckable', () => {
assert.equal(comparePhaseStatus({ stateStatus: null, roadmapStatus: 'In Progress' }).verdict, 'uncheckable');
assert.equal(comparePhaseStatus({ stateStatus: undefined, roadmapStatus: 'In Progress' }).verdict, 'uncheckable');
assert.equal(comparePhaseStatus({ stateStatus: '', roadmapStatus: 'In Progress' }).verdict, 'uncheckable');
assert.equal(comparePhaseStatus({ stateStatus: 'In Progress', roadmapStatus: null }).verdict, 'uncheckable');
assert.equal(comparePhaseStatus({ stateStatus: 'In Progress', roadmapStatus: undefined }).verdict, 'uncheckable');
assert.equal(comparePhaseStatus({ stateStatus: 'In Progress', roadmapStatus: '' }).verdict, 'uncheckable');
});
test('case and surrounding whitespace are ignored', () => {
const result = comparePhaseStatus({ stateStatus: ' PHASE COMPLETE ', roadmapStatus: 'Phase complete' });
assert.equal(result.verdict, 'consistent');
assert.equal(result.stateRank, 2);
assert.equal(result.roadmapRank, 2);
});
test('does not throw for unrecognized input (unlike classifyDriftSeverity)', () => {
assert.doesNotThrow(() => comparePhaseStatus({ stateStatus: 'garbage', roadmapStatus: 'nonsense' }));
assert.doesNotThrow(() => comparePhaseStatus({ stateStatus: undefined, roadmapStatus: undefined }));
});
// #1956 review fix: 'Deferred' was missing from PHASE_STATUS_RANKS despite
// gsd-core/templates/roadmap.md:133 declaring it as part of the full
// ROADMAP Status vocabulary (`Not started | In progress | Complete |
// Deferred`) — it silently always resolved 'uncheckable', losing real drift.
test('Deferred vs Not started → consistent (both agree no work has happened)', () => {
const result = comparePhaseStatus({ stateStatus: 'Not started', roadmapStatus: 'Deferred' });
assert.equal(result.verdict, 'consistent');
});
test('Deferred vs In progress → drifted (declared stopped vs declared happening)', () => {
const result = comparePhaseStatus({ stateStatus: 'In progress', roadmapStatus: 'Deferred' });
assert.equal(result.verdict, 'drifted');
});
test('Deferred vs Phase complete → drifted (declared stopped vs declared done)', () => {
const result = comparePhaseStatus({ stateStatus: 'Phase complete', roadmapStatus: 'Deferred' });
assert.equal(result.verdict, 'drifted');
});
test('deferred resolves a real (non-null) rank on either side', () => {
const result = comparePhaseStatus({ stateStatus: 'Not started', roadmapStatus: 'Deferred' });
assert.notEqual(result.roadmapRank, null, "'deferred' must not be uncheckable — it is a declared vocabulary value");
assert.equal(result.roadmapRank, 0);
});
});
// Shared ROADMAP.md "Progress" table fixture builder (#1956/#2012). Used by
// BOTH the CLI acceptance tests below (via writeRoadmap, which writes it to
// disk) and the findRoadmapProgressTable/deriveProgressFromRoadmap parity
// test (section 8), so the CLI-level decoy fixture and the parity fixture
// can never independently drift into slightly different shapes.
//
// `opts.decoy`, when true, prepends an earlier `## Archive Notes` section
// carrying a table with the EXACT SAME four column headers
// (`Phase | Plans Complete | Status | Completed`) as the real `## Progress`
// table, with the same phase row reporting a DIFFERENT status ('Not
// started') — the #2012 decoy shape a column-name-only lookup would pick up
// first.
function buildRoadmapProgressContent(phase, phaseName, roadmapStatus, opts = {}) {
const decoySection = opts.decoy
? `## Archive Notes
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| ${phase}. ${phaseName} | 0/1 | Not started | - |
`
: '';
return `# Roadmap: Test Project
${decoySection}## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| ${phase}. ${phaseName} | 0/1 | ${roadmapStatus} | - |
`;
}
// ── 7. #1956 acceptance — drifted phase status across STATE/ROADMAP, via the real CLI ──
describe('#1956 acceptance — a drifted phase status across STATE/ROADMAP yields a finding', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1956-acceptance-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
const PHASE = 3;
const PHASE_NAME = 'Convergence';
// Writes .planning/STATE.md carrying frontmatter + a `## Current Position`
// section, matching gsd-core/templates/state.md.
function writeState(stateStatus) {
const content = `---
gsd_state_version: '1.0'
status: planning
---
# Project State
## Current Position
Phase: ${PHASE} of 8 (${PHASE_NAME})
Plan: 1 of 1 in current phase
Status: ${stateStatus}
Last activity: 2026-08-09 — test fixture
Progress: [░░░░░░░░░░] 0%
`;
fs.writeFileSync(path.join(planningDir, 'STATE.md'), content);
}
// Writes .planning/ROADMAP.md carrying a `## Progress` section with the
// table shape gsd-core/templates/roadmap.md declares. `opts.decoy` prepends
// a same-headers decoy table under a different heading — see
// buildRoadmapProgressContent above.
function writeRoadmap(roadmapStatus, opts = {}) {
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
buildRoadmapProgressContent(PHASE, PHASE_NAME, roadmapStatus, opts),
);
}
test('an intentionally-drifted phase status yields a finding', () => {
writeState('Phase complete');
writeRoadmap('In Progress');
const res = runGsdTools(['drift-guard', 'phase-status', '--phase', String(PHASE)], tmpDir);
assert.ok(res.success, `Expected success, got: ${res.error}`);
const result = JSON.parse(res.output);
assert.equal(result.verdict, 'drifted');
assert.equal(result.authority, 'STATE.md', 'the finding must name STATE.md as authority so the reviewer knows which side to keep');
});
test('consistent artifacts do not', () => {
writeState('Phase complete');
writeRoadmap('Complete');
const res = runGsdTools(['drift-guard', 'phase-status', '--phase', String(PHASE)], tmpDir);
assert.ok(res.success, `Expected success, got: ${res.error}`);
const result = JSON.parse(res.output);
assert.equal(result.verdict, 'consistent');
});
test('a missing ROADMAP.md is uncheckable, not consistent', () => {
writeState('Phase complete');
const res = runGsdTools(['drift-guard', 'phase-status', '--phase', String(PHASE)], tmpDir);
assert.ok(res.success, `Expected success, got: ${res.error}`);
const result = JSON.parse(res.output);
assert.equal(result.verdict, 'uncheckable');
assert.ok(result.reason && result.reason.length > 0, 'a skipped axis must be observable via a non-empty reason');
});
// #1956 review Fix 1: the Progress-table lookup used to scan the WHOLE
// ROADMAP for the first table matching the column headers, so an earlier
// decoy table sharing those headers (e.g. an "Archive Notes" table) was
// picked up instead of the real `## Progress` table (#2012 decoy
// avoidance). The decoy's phase-3 row says 'Not started'; the real
// `## Progress` table's phase-3 row says 'Complete', agreeing with STATE.md's
// 'Phase complete' — the CLI must read the real table.
test('a decoy table with the same headers is not mistaken for ## Progress', () => {
writeState('Phase complete');
writeRoadmap('Complete', { decoy: true });
const res = runGsdTools(['drift-guard', 'phase-status', '--phase', String(PHASE)], tmpDir);
assert.ok(res.success, `Expected success, got: ${res.error}`);
const result = JSON.parse(res.output);
assert.equal(result.verdict, 'consistent');
assert.equal(result.roadmapStatus, 'Complete', 'must read the real ## Progress table\'s status, not the decoy\'s "Not started"');
});
// #1956 review Fix 2: `stateCurrentPositionSlice(stateBody) ?? stateBody`
// fell back to the whole STATE.md body when no `## Current Position`
// heading was found, reintroducing the #2956 archive-shadowing bug — a
// stray historical `Status:` line elsewhere in the body would be read as
// if it were the real current status, fabricating a drift finding. A
// STATE.md with NO Current Position heading, plus an earlier stray
// `Status: Ready to plan` line, must abstain instead of guessing.
test('a STATE.md with no ## Current Position heading is uncheckable, never a fabricated finding', () => {
const stateContent = `---
gsd_state_version: '1.0'
status: planning
---
# Project State
Status: Ready to plan
## Session
Some unrelated notes with no Current Position section.
`;
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent);
writeRoadmap('Complete');
const res = runGsdTools(['drift-guard', 'phase-status', '--phase', String(PHASE)], tmpDir);
assert.ok(res.success, `Expected success, got: ${res.error}`);
const result = JSON.parse(res.output);
assert.equal(result.verdict, 'uncheckable');
assert.equal(result.reason, 'no_current_position');
assert.notEqual(result.verdict, 'drifted', 'a shadowed/absent Current Position must never fabricate a drift finding');
});
});
// ── 8. #1956/#2012 parity — findRoadmapProgressTable vs deriveProgressFromRoadmap ──
//
// Two SEPARATE implementations locate "the" ROADMAP Progress table:
// roadmap-parser.cts's findRoadmapProgressTable (collectSection-based, used
// by the drift-guard CLI) and phase-lifecycle.cts's deriveProgressFromRoadmap
// (its own regex-based `## Progress` scope, used by the phase-lifecycle SDK
// handler — deliberately NOT refactored onto the shared owner; its blast
// radius is large). The repo requires a parity assertion whenever a parser is
// expressed twice, so this test feeds both the SAME decoy-bearing ROADMAP
// fixture from section 7 and asserts they agree about which table is real.
describe('#1956/#2012 parity — findRoadmapProgressTable vs deriveProgressFromRoadmap agree', () => {
const { findRoadmapProgressTable } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
test('both locators pick the real ## Progress table, not the decoy', () => {
const content = buildRoadmapProgressContent(3, 'Convergence', 'Complete', { decoy: true });
const table = findRoadmapProgressTable(content);
assert.ok(table, 'findRoadmapProgressTable must find the real Progress table');
const row = table.rows.find((r) => r.Phase.startsWith('3.'));
assert.ok(row, 'expected a phase 3 row in the located table');
assert.equal(row.Status, 'Complete', 'must read the real ## Progress table\'s phase-3 status, not the decoy\'s "Not started"');
const progress = deriveProgressFromRoadmap(content);
assert.equal(progress.completedPhases, 1, 'deriveProgressFromRoadmap must count the real ## Progress table\'s Complete row, not be fooled by the decoy');
});
});

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"plan-review-convergence.md": "#1956: the plan drift guard gains a second axis — a cross-artifact fact-drift pass sitting immediately after the existing source-grounding pass, under the SAME `plan_review.source_grounding` gate. Growth is the new section only (26195 -> 30672 bytes, +4477); no existing text was rewritten and no new config key was introduced. The addition is deliberately inline rather than extracted: ADR-1610 Decision 4 names eager `@`-import relocation as proxy-gaming (it shrinks the measured file while leaving loaded context unchanged or larger), legitimate extraction is Read-at-step lazy, and this pass has no lazy-read seam — it is prose the orchestrator must already hold when it runs the guard. The file remains far inside its DEFAULT tier hard cap of 40960 bytes (tests/workflow-size-budget.test.cjs), with ~11.4 KB of headroom. Content justification: source-grounding proves a plan's cited SYMBOLS exist in source; nothing proved that the same FACT stated in two planning artifacts still agrees. Because each phase runs in a fresh context an agent typically reads one artifact and trusts it, so a stale duplicate — a phase marked complete in STATE.md but in progress in ROADMAP.md, a success criterion the plan restates with a different outcome, a term used against its CONTEXT.md definition — silently steers it wrong. The pass keys on contradicting knowledge rather than similar-looking text (the false-positive mode the issue's own research comment names), gates on a three-way conjunction, defers the axes gsd-plan-checker already owns (Dimensions 1, 7b, 9) so nothing is reported twice, and is advisory only: it never sets hardBlock and contributes to neither HIGH_COUNT nor ACTIONABLE_COUNT, so a project carrying pre-existing drift can still converge."
}
}

View File

@@ -1502,3 +1502,412 @@ describe('bug-936 — plan-review-convergence runs plan-phase inline, not inside
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// #1956 — Cross-artifact fact-drift pass (second axis of the plan drift guard)
//
// The source-grounding pass answers "does this symbol exist in the source?".
// This pass answers "does the project state the same FACT in two planning
// artifacts, and do the two disagree?" — the DRY hazard the issue names, where
// one copy is updated and the other silently steers a fresh-context agent wrong.
//
// ## What this suite locks
//
// The deployed contract, plus the two Hyrum contracts that are invisible from
// the new section itself and would be silently broken by a plausible edit:
// 1. the pass is ORCHESTRATOR-side, not inside the Agent(prompt=…) string —
// the review agent's return message must end with its two "## " sections
// and carry no others, because the workflow awk-parses them;
// 2. the pass can never reach the convergence gate. Convergence is
// HIGH_COUNT + ACTIONABLE_COUNT == 0, and the workflow's own ACTIONABLE
// definition would otherwise swallow a fact-drift finding — which would
// turn an advisory check into an infinite replan loop for any project that
// already carries drift.
//
// ## What it cannot prove
//
// That the model acts on the text. The subject is an LLM prompt; no test in
// this repo proves behavior for the source-grounding pass either. Stated so the
// coverage claim is honest rather than implied.
//
// The file is read through readFileNormalized, the same LF-normalizing seam the
// rest of this suite uses, so a CRLF checkout cannot skew the offsets.
// ─────────────────────────────────────────────────────────────────────────────
describe('plan-review-convergence: cross-artifact fact-drift pass (#1956)', () => {
const WORKFLOW = readFileNormalized(WORKFLOW_PATH);
const DRIFT_HEADING = /^### Cross-artifact fact-drift pass/m;
const GROUNDING_HEADING = /^### Source-grounding pass/m;
const AFTER_AGENT_LINE = /^After agent returns, verify REVIEWS\.md exists/m;
function offsetOf(content, pattern) {
const m = content.match(pattern);
return m && typeof m.index === 'number' ? m.index : -1;
}
function driftSpan() {
const start = offsetOf(WORKFLOW, DRIFT_HEADING);
const end = offsetOf(WORKFLOW, AFTER_AGENT_LINE);
assert.ok(start >= 0, 'workflow must define a "### Cross-artifact fact-drift pass" heading');
assert.ok(end >= 0, 'workflow must retain the "After agent returns…" line that bounds the pass');
assert.ok(end > start, 'the fact-drift pass must precede the "After agent returns…" line');
return WORKFLOW.slice(start, end);
}
/**
* Top-level ordered-list items in a span — the trigger gate's arity, i.e. the
* "flag only when ALL N hold" conjunction. Widening it from 3 to 2 is exactly
* what turns a precise heuristic into a noise generator, so the COUNT is
* asserted rather than the prose.
*/
function countOrderedItems(span) {
const matches = span.match(/^\d+\. /gm);
return matches ? matches.length : 0;
}
/**
* Conjunction clauses — the indented sub-list under step 3's "FLAG only when
* ALL THREE hold". Counted separately from the STEPS above because they are
* different things: `countOrderedItems` measures the pass's procedure, this
* measures the trigger gate's arity. Asserting one while claiming the other
* is how a weakened gate slips through a green suite.
*/
function countConjunctionItems(span) {
const matches = span.match(/^ {3}\d+\. /gm);
return matches ? matches.length : 0;
}
describe('the pass exists and extends the drift guard in place', () => {
test('defines a cross-artifact fact-drift pass', () => {
const heading = WORKFLOW.match(/^### Cross-artifact fact-drift pass(.*)$/m);
assert.ok(heading, 'workflow must define a "### Cross-artifact fact-drift pass" heading');
});
test('the pass extends the drift guard, in place', () => {
const grounding = offsetOf(WORKFLOW, GROUNDING_HEADING);
const drift = offsetOf(WORKFLOW, DRIFT_HEADING);
const after = offsetOf(WORKFLOW, AFTER_AGENT_LINE);
assert.ok(grounding >= 0 && drift >= 0 && after >= 0, 'all three anchors must be present');
assert.ok(grounding < drift, 'the fact-drift pass must follow the source-grounding pass — it extends it');
assert.ok(drift < after, 'the fact-drift pass must sit before the post-agent REVIEWS.md check');
});
test('the pass region is outside the review-agent prompt', () => {
// Anchor on the review-agent return contract itself — the sentence inside
// the Agent(prompt=…) string that this test exists to protect — rather than
// on a generic mode-argument literal that a later Agent() block could reuse
// and thereby relocate the anchor past this section.
const RETURN_CONTRACT = 'These two sections MUST be the final content of your response';
const contractAt = WORKFLOW.indexOf(RETURN_CONTRACT);
assert.ok(contractAt >= 0, 'the review agent prompt must still carry its return-message contract');
assert.strictEqual(
WORKFLOW.indexOf(RETURN_CONTRACT, contractAt + 1),
-1,
'the return-message contract must appear exactly once — a second copy makes this anchor ambiguous'
);
assert.ok(
offsetOf(WORKFLOW, GROUNDING_HEADING) > contractAt,
'source-grounding pass must remain orchestrator-side (after the Agent prompt)'
);
assert.ok(
offsetOf(WORKFLOW, DRIFT_HEADING) > contractAt,
'the fact-drift pass must be orchestrator-side — inside the Agent prompt its findings ' +
'would break the "no additional ## headings" return contract the workflow parses'
);
});
});
describe('it adds no config surface', () => {
test('the pass is gated on the existing drift-guard key', () => {
assert.match(
driftSpan(),
/plan_review\.source_grounding/,
'the fact-drift pass must name plan_review.source_grounding as its gate — issue #1956 ' +
'scopes it as an extension of the existing guard, gated by the existing config'
);
});
test('the pass introduces no new config key', () => {
// The issue's pre-submission checklist asserts the change adds no new
// concept, and its breaking-change mitigation reads "gated behind the
// EXISTING plan_review config". A third key would also force a 25th
// setting into gsd-core/workflows/settings.md's six-section UX.
//
// Two halves, both required. The gate must still be NAMED — otherwise a
// deleted or empty section would satisfy a bare "no novel keys" check
// vacuously — and nothing beyond the two keys the drift guard already owns
// may appear. (`source_grounding_authority` is resolved through
// `gsd_run drift-guard authority` and is not spelled out in this file
// today; it stays on the allowed list so naming it later is not a failure.)
const keys = new Set([...WORKFLOW.matchAll(/plan_review\.([a-z_]+)/g)].map((m) => m[1]));
assert.ok(
keys.has('source_grounding'),
'the workflow must still name plan_review.source_grounding as the drift-guard gate'
);
const novel = [...keys]
.filter((k) => k !== 'source_grounding' && k !== 'source_grounding_authority')
.sort();
assert.deepStrictEqual(
novel,
[],
`plan-review-convergence must introduce no new plan_review key, found: ${novel.join(', ')}`
);
});
});
describe('the trigger gate is a three-way conjunction', () => {
test('the pass runs four procedure steps', () => {
assert.strictEqual(
countOrderedItems(driftSpan()),
4,
'the fact-drift pass must run four steps (deterministic phase-status, pair up judgment ' +
'facts, judge them, record). This counts the PROCEDURE; the trigger gate arity is ' +
'counted separately.'
);
});
test('the trigger gate enumerates exactly three conditions', () => {
assert.strictEqual(
countConjunctionItems(driftSpan()),
3,
'the fact-drift pass must gate on exactly three AND-ed conditions (same fact named on ' +
'both sides, the two representations contradict, and the pair is one of the declared ' +
'authority pairs). Dropping one widens it into a noise generator; adding one silently ' +
'narrows what it can catch.'
);
});
test('condition counter fires at 2 / 3 / 4', () => {
// The assertion above can only ever observe the real document's arity, so
// its inequality branch never executes. Exercise the counter at
// limit-1 / limit / limit+1 through the SAME function the guard uses, in
// both LF and CRLF form, so a future edit cannot neuter it.
const item = (n) => `${n}. condition ${n}`;
const indentedItem = (n) => ` ${n}. condition ${n}`;
for (const eol of ['\n', '\r\n']) {
const spanOf = (count) =>
['### Cross-artifact fact-drift pass', ...Array.from({ length: count }, (_, i) => item(i + 1))]
.join(eol);
const indentedSpanOf = (count) =>
['### Cross-artifact fact-drift pass', ...Array.from({ length: count }, (_, i) => indentedItem(i + 1))]
.join(eol);
assert.strictEqual(countOrderedItems(spanOf(2)), 2, `2 items must count as 2 (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countOrderedItems(spanOf(3)), 3, `3 items must count as 3 (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countOrderedItems(spanOf(4)), 4, `4 items must count as 4 (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countConjunctionItems(indentedSpanOf(2)), 2, `2 indented items must count as 2 (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countConjunctionItems(indentedSpanOf(3)), 3, `3 indented items must count as 3 (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countConjunctionItems(indentedSpanOf(4)), 4, `4 indented items must count as 4 (eol=${JSON.stringify(eol)})`);
// The two counters must not see each other's items — a column-0-only
// span has no conjunction items, and an indented-only span has no
// procedure items.
assert.strictEqual(countOrderedItems(indentedSpanOf(3)), 0, `indented-only span must count 0 procedure items (eol=${JSON.stringify(eol)})`);
assert.strictEqual(countConjunctionItems(spanOf(3)), 0, `column-0-only span must count 0 conjunction items (eol=${JSON.stringify(eol)})`);
}
});
});
describe('severity is advisory, and can never gate convergence', () => {
test('the finding is advisory and never a blocker', () => {
assert.match(
driftSpan(),
/never\s+a\s+blocker/i,
'the fact-drift pass must state that its finding is never a blocker — issue #1956 asks ' +
'for an advisory finding, and blocking would strand every project carrying prior drift'
);
});
test('the pass can never gate convergence', () => {
// Convergence is HIGH_COUNT + ACTIONABLE_COUNT == 0, and the workflow's
// own ACTIONABLE definition ("a non-HIGH finding invisible to
// execute-phase unless incorporated into PLAN.md") would otherwise
// swallow a fact-drift finding — making pre-existing drift an infinite
// replan loop. This has to be WRITTEN DOWN, not merely true today.
const span = driftSpan();
assert.match(span, /HIGH_COUNT/, 'the pass must name HIGH_COUNT when disclaiming the convergence gate');
assert.match(span, /ACTIONABLE_COUNT/, 'the pass must name ACTIONABLE_COUNT when disclaiming the convergence gate');
assert.match(
span,
/never sets\s+`?hardBlock/,
'the pass must state that it never sets hardBlock — the source-grounding pass uses ' +
'hardBlock to stop the review cycle, and this pass must not inherit that'
);
});
test('findings land in REVIEWS.md', () => {
assert.match(
driftSpan(),
/REVIEWS\.md/,
'the fact-drift pass must write to REVIEWS.md, matching the source-grounding pass — ' +
'not to the review agent\'s return message'
);
});
test('the phase-status axis is delegated to the deterministic seam', () => {
const span = driftSpan();
assert.match(
span,
/gsd_run drift-guard phase-status/,
'the phase-status axis must be decided by the drift-guard seam, not by model judgment — ' +
'issue #1956 requires a drifted STATE/ROADMAP pair to yield a finding deterministically'
);
for (const verdict of [/\bdrifted\b/, /\blag\b/, /\buncheckable\b/]) {
assert.match(span, verdict, `the pass must say how it treats the ${verdict} verdict`);
}
});
});
describe('negative space is enumerated', () => {
test('the pass keys on knowledge, not similar text', () => {
// The maintainer's own research comment on #1956: "The check must key on
// 'same knowledge, drifting representations,' not 'similar-looking text,'
// or it will produce false positives."
const span = driftSpan();
assert.match(span, /knowledge/i, 'the pass must frame the check in terms of knowledge');
assert.match(
span,
/contradict/i,
'the pass must require a CONTRADICTION, not a resemblance — this is the rule that ' +
'keeps it from firing on every restatement'
);
});
test('the pass enumerates its non-triggering cases', () => {
assert.match(driftSpan(), /Do NOT flag/, 'the fact-drift pass must carry an explicit non-triggering list');
});
test('non-triggering list covers every exclusion class', () => {
const span = driftSpan();
// Each token is a distinct exclusion class from the design's negative
// space. Their absence is what produces the false positives the issue's
// research comment warns about.
for (const [token, why] of [
[/wording/i, 'a wording-only difference asserting the same thing is not drift'],
[/single-source/i, 'a fact held in one artifact only is the TARGET state, not a finding'],
[/\bADDS\b/, 'a PLAN may ADD truths beyond the roadmap SCs — only subtraction/contradiction counts'],
[/lifecycle/i, 'STATE trailing ROADMAP by one lifecycle step is lag, not drift'],
[/Deferred Ideas/, 'CONTEXT.md non-authoritative sections must not be compared'],
]) {
assert.match(span, token, `the non-triggering list must cover: ${why}`);
}
});
test('the pass defers overlapping axes to the plan checker', () => {
// Report once, not twice. plan-checker already owns requirement coverage
// (D1), scope reduction (D7b) and cross-plan data contracts (D9).
const span = driftSpan();
for (const dimension of [/Dimension 1\b/, /Dimension 7b\b/, /Dimension 9\b/]) {
assert.match(span, dimension, `the pass must defer the overlapping axis to ${dimension}`);
}
});
test('a completion disagreement is never exempted as lag', () => {
// The issue's canonical example is "complete in STATE.md but in progress
// in ROADMAP.md" — one lifecycle step apart, and exactly the case it wants
// FLAGGED. An exemption phrased purely as "one step apart" would exempt it.
const span = driftSpan();
assert.match(
span,
/completion[^.]*never lag|never lag|Completeness is terminal/i,
'the pass must state that a disagreement about completion is never lag'
);
});
});
describe('authority and coverage', () => {
test('the pass names an authority for every artifact pair', () => {
const span = driftSpan();
for (const artifact of ['ROADMAP.md', 'PLAN.md', 'STATE.md', 'CONTEXT.md']) {
assert.ok(
span.includes(artifact),
`the fact-drift pass must name ${artifact} — issue #1956 spans all four planning artifacts`
);
}
assert.match(
span,
/Authority/i,
'the pass must declare which side of each pair is the source of truth — a finding that ' +
'names a divergence without naming the authority cannot be acted on'
);
});
test('a skipped axis is recorded, never silent', () => {
const span = driftSpan();
assert.match(span, /skip(ped)?/i, 'the pass must describe what happens when an artifact is absent');
assert.match(
span,
/coverage/i,
'a skipped axis must be recorded in the Verification coverage block — a clean pass must ' +
'never silently mean "nothing was compared"'
);
});
});
describe('independence — the surrounding contracts are unchanged', () => {
test('the source-grounding pass keeps its hard block', () => {
const start = offsetOf(WORKFLOW, GROUNDING_HEADING);
const end = offsetOf(WORKFLOW, DRIFT_HEADING);
assert.ok(start >= 0 && end > start, 'source-grounding pass must still precede the fact-drift pass');
const groundingSpan = WORKFLOW.slice(start, end);
assert.match(
groundingSpan,
/hardBlock: true/,
'the source-grounding pass must keep its hardBlock gating — #1956 is additive and must ' +
'not downgrade the existing guard'
);
});
test('the review-agent return contract is unchanged', () => {
assert.match(
WORKFLOW,
/no additional "## " headings after them/,
'the review agent\'s return-message contract must survive — the workflow awk-parses ' +
'those sections and a stray "## " heading breaks escalation-detail extraction'
);
});
});
describe('docs parity', () => {
test('CONFIGURATION.md documents both drift-guard axes', () => {
const configDoc = readFileNormalized(CONFIG_DOC_PATH);
const row = configDoc
.split('\n')
.find((l) => l.startsWith('|') && l.includes('`plan_review.source_grounding`'));
assert.ok(row, 'docs/CONFIGURATION.md must carry a table row for plan_review.source_grounding');
assert.match(
row,
/cross-artifact|fact drift/i,
'the plan_review.source_grounding row must document the second (fact-drift) axis — the ' +
'key now gates two checks, and a reader disabling it must know what else goes dark'
);
});
test('USER-GUIDE.md documents the second axis', () => {
const guide = readFileNormalized(path.join(__dirname, '..', 'docs', 'USER-GUIDE.md'));
const start = guide.indexOf('plan_review.source_grounding: true');
assert.ok(start >= 0, 'docs/USER-GUIDE.md must retain its Drift Guard section');
const section = guide.slice(start, start + 4000);
assert.match(
section,
/cross-artifact|fact drift/i,
'the USER-GUIDE Drift Guard section must describe the cross-artifact fact-drift axis'
);
});
test('ARCHITECTURE.md documents the second axis', () => {
// The issue's Scope of changes names ARCHITECTURE.md explicitly, and its
// existing drift-guard paragraph is the one place the architecture doc
// describes this guard at all — leaving it single-axis would state, in the
// architecture reference, that the guard does less than it does.
const arch = readFileNormalized(path.join(__dirname, '..', 'docs', 'ARCHITECTURE.md'));
const start = arch.indexOf('The plan drift guard (`plan_review.source_grounding`)');
assert.ok(start >= 0, 'docs/ARCHITECTURE.md must retain its plan drift guard paragraph');
const section = arch.slice(start, start + 2000);
assert.match(
section,
/cross-artifact|fact-drift/i,
'the ARCHITECTURE.md drift-guard paragraph must describe the cross-artifact fact-drift axis'
);
});
});
});