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

@@ -13,7 +13,8 @@
* - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
* - ./planning-workspace.cjs (planningDir)
* - ./shell-command-projection.cjs (platformReadSync)
* - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
* - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection, collectSection)
* - ./markdown-table.cjs (findTableWithColumns)
*/
import fs from 'node:fs';
@@ -38,8 +39,10 @@ import { platformReadSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputMod = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode } from './markdown-sectionizer.cjs';
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode, collectSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
import { findTableWithColumns } from './markdown-table.cjs';
import type { MarkdownTable } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
@@ -882,6 +885,42 @@ function reportUnreadableRoadmap(err: unknown, roadmapPath: string): void {
warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
}
// ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
/**
* Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
* decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
*
* Scopes to the `## Progress` heading first (level-2, exact case-insensitive
* text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
* same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
* to scope STATE.md's `## Current Position` — so a differently-headed table
* that happens to share the same column names (e.g. an "Archive Notes"
* table) is never picked up instead of the real one (#2012). Falls back to
* scanning the WHOLE document when no `## Progress` heading exists, so a
* headingless milestone slice (#1445) still resolves rather than going
* uncheckable — the same fallback `deriveProgressFromRoadmap`
* (phase-lifecycle.cts) deliberately preserves.
*
* `deriveProgressFromRoadmap` independently expresses this same "scope to
* `## Progress`, else whole document" rule via its own regex-based scope
* (kept there deliberately rather than refactored onto this function — its
* blast radius is large). The two locators are therefore separate
* implementations of the same scoping rule and must agree about WHICH table
* is the Progress table; a parity test in
* tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
* generative-fix-divergence guard.
*
* Returns the same shape `findTableWithColumns` returns (or `null`).
*/
function findRoadmapProgressTable(roadmapContent: string): MarkdownTable | null {
const isProgressHeading = (h: HeadingToken): boolean =>
h.level === 2 && h.text.trim().toLowerCase() === 'progress';
const section = collectSection(roadmapContent, isProgressHeading, { levelBounded: true });
const scoped = section ? section.body : roadmapContent;
return findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
}
// ─── Milestone info lookup ────────────────────────────────────────────────────
interface MilestoneInfo {
@@ -1465,4 +1504,7 @@ export = {
sliceMilestoneWindow,
hasVersionedMilestones,
hasMilestoneSectioning,
// #1956: sole owner of the #2012 decoy-avoidance scope for the
// `drift-guard phase-status` CLI seam.
findRoadmapProgressTable,
};