* 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:
@@ -148,3 +148,146 @@ export function classifyDriftSeverity({
|
||||
return { severity: 'INFO', hardBlock: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── #1956 cross-artifact phase-status drift ────────────────────────────────
|
||||
|
||||
/** Verdict from comparing one phase's status across STATE.md and ROADMAP.md. */
|
||||
export type PhaseStatusVerdict = 'consistent' | 'lag' | 'drifted' | 'uncheckable';
|
||||
|
||||
/** Result of comparePhaseStatus. */
|
||||
export interface PhaseStatusResult {
|
||||
verdict: PhaseStatusVerdict;
|
||||
stateRank: number | null;
|
||||
roadmapRank: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen map from lowercased phase-status text to a shared ordinal rank,
|
||||
* covering the union of the STATE.md "Current Position" vocabulary
|
||||
* (gsd-core/templates/state.md) and the FULL ROADMAP.md "## Progress" table
|
||||
* Status column vocabulary declared by gsd-core/templates/roadmap.md:133 —
|
||||
* `Not started | In progress | Complete | Deferred`.
|
||||
*
|
||||
* The two vocabularies overlap on 'in progress', which is rank 1 in both —
|
||||
* no conflict. 'deferred' is rank 0 (no work done) — see comparePhaseStatus's
|
||||
* doc comment for how a deferred/non-rank-0 mismatch is classified; it is NOT
|
||||
* simply numeric distance from rank 0 like an ordinary lag.
|
||||
*/
|
||||
const PHASE_STATUS_RANKS: Readonly<Record<string, number>> = Object.freeze({
|
||||
// STATE.md "Current Position" vocabulary
|
||||
'ready to plan': 0,
|
||||
'planning': 0,
|
||||
'ready to execute': 1,
|
||||
'in progress': 1,
|
||||
'phase complete': 2,
|
||||
// ROADMAP.md "## Progress" table Status column vocabulary
|
||||
'not started': 0,
|
||||
'complete': 2,
|
||||
'deferred': 0,
|
||||
} as const);
|
||||
|
||||
/** Rank at which a status asserts work is DONE (terminal, not comparative). */
|
||||
const TERMINAL_RANK = 2;
|
||||
|
||||
/**
|
||||
* Normalize a raw phase-status string for lookup/comparison: trims
|
||||
* surrounding whitespace and lowercases. Returns null for missing/empty
|
||||
* values. Single owner of this normalization so `resolvePhaseStatusRank` and
|
||||
* the 'deferred' declared-intent check in `comparePhaseStatus` cannot drift
|
||||
* apart on what counts as "empty".
|
||||
*/
|
||||
function normalizePhaseStatusText(value: string | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '' ? null : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw phase-status string to its shared ordinal rank, or null when
|
||||
* the value is missing/empty/unrecognized. Case-insensitive, trims
|
||||
* surrounding whitespace.
|
||||
*
|
||||
* @param value - raw status text from STATE.md or ROADMAP.md
|
||||
* @returns the resolved rank, or null if unresolvable
|
||||
*/
|
||||
function resolvePhaseStatusRank(value: string | null | undefined): number | null {
|
||||
const normalized = normalizePhaseStatusText(value);
|
||||
if (normalized === null) return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(PHASE_STATUS_RANKS, normalized)) return null;
|
||||
return PHASE_STATUS_RANKS[normalized];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a phase's status as reported by STATE.md against the same phase's
|
||||
* status as reported by ROADMAP.md's "## Progress" table, and classify the
|
||||
* result.
|
||||
*
|
||||
* Unlike classifyDriftSeverity, this never throws for an unrecognized
|
||||
* status: the inputs are user document text (prose a human or agent typed
|
||||
* into STATE.md/ROADMAP.md), not config, so an unrecognized value is data —
|
||||
* surfaced as 'uncheckable' — not a programming error.
|
||||
*
|
||||
* Rank 2 ('phase complete' / 'Complete') is TERMINAL: it asserts the work is
|
||||
* DONE. If exactly one side reports rank 2 and the other does not, that is
|
||||
* always 'drifted', regardless of numeric distance — a document claiming
|
||||
* "done" while another claims "still going" is a direct contradiction, not
|
||||
* mere lag. This is the issue's canonical example: complete in STATE.md but
|
||||
* in progress in ROADMAP.md.
|
||||
*
|
||||
* 'Deferred' is a second declared-intent rank-0 status (gsd-core/templates/
|
||||
* roadmap.md:133's full vocabulary: `Not started | In progress | Complete |
|
||||
* Deferred`) that is NOT ordinary lag from rank 0: it is an explicit
|
||||
* decision to STOP work, not merely "hasn't started yet". If exactly one
|
||||
* side declares 'deferred' and the other side's rank is >= 1 (work is
|
||||
* reported as in progress or complete), that is always 'drifted' — a phase
|
||||
* declared deferred while the other document says work is happening is a
|
||||
* direct contradiction, checked here (like the terminal-completeness rule
|
||||
* above) BEFORE the numeric distance comparison. 'Deferred' against a
|
||||
* rank-0 status on the other side (e.g. 'Not started') stays 'consistent' —
|
||||
* both agree no work has happened.
|
||||
*
|
||||
* Otherwise, ranks are compared numerically: equal → 'consistent';
|
||||
* off-by-one → 'lag'; off-by-two-or-more → 'drifted'.
|
||||
*
|
||||
* @param opts.stateStatus - raw Status value from STATE.md's Current Position
|
||||
* @param opts.roadmapStatus - raw Status cell from ROADMAP.md's Progress table
|
||||
* @returns { verdict, stateRank, roadmapRank }
|
||||
*/
|
||||
export function comparePhaseStatus({
|
||||
stateStatus,
|
||||
roadmapStatus,
|
||||
}: {
|
||||
stateStatus: string | null | undefined;
|
||||
roadmapStatus: string | null | undefined;
|
||||
}): PhaseStatusResult {
|
||||
const stateRank = resolvePhaseStatusRank(stateStatus);
|
||||
const roadmapRank = resolvePhaseStatusRank(roadmapStatus);
|
||||
|
||||
if (stateRank === null || roadmapRank === null) {
|
||||
return { verdict: 'uncheckable', stateRank, roadmapRank };
|
||||
}
|
||||
|
||||
const stateIsTerminal = stateRank === TERMINAL_RANK;
|
||||
const roadmapIsTerminal = roadmapRank === TERMINAL_RANK;
|
||||
if (stateIsTerminal !== roadmapIsTerminal) {
|
||||
return { verdict: 'drifted', stateRank, roadmapRank };
|
||||
}
|
||||
|
||||
const stateIsDeferred = normalizePhaseStatusText(stateStatus) === 'deferred';
|
||||
const roadmapIsDeferred = normalizePhaseStatusText(roadmapStatus) === 'deferred';
|
||||
if (stateIsDeferred !== roadmapIsDeferred) {
|
||||
const otherRank = stateIsDeferred ? roadmapRank : stateRank;
|
||||
if (otherRank >= 1) {
|
||||
return { verdict: 'drifted', stateRank, roadmapRank };
|
||||
}
|
||||
}
|
||||
|
||||
const diff = Math.abs(stateRank - roadmapRank);
|
||||
if (diff === 0) {
|
||||
return { verdict: 'consistent', stateRank, roadmapRank };
|
||||
}
|
||||
if (diff === 1) {
|
||||
return { verdict: 'lag', stateRank, roadmapRank };
|
||||
}
|
||||
return { verdict: 'drifted', stateRank, roadmapRank };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
import { splitTableRow } from './markdown-table.cjs';
|
||||
import { clampPercentFromFraction } from './phase-lifecycle.cjs';
|
||||
import { collectSection } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
|
||||
// Internal helpers
|
||||
function escapeRegex(str: string): string {
|
||||
@@ -232,6 +234,38 @@ export function stateExtractField(content: string, fieldName: string): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the "Current Position" section body from a STATE.md body. #2956: this
|
||||
* is the Phase analogue of state.cts's matchSessionSection. `Phase` canonically
|
||||
* lives under `## Current Position` (gsd-core/templates/state.md), so — like
|
||||
* Stopped At / Paused At under `## Session` — it must be extracted from THAT
|
||||
* section, not from the first `Phase:` / `**Phase:**` line anywhere in the
|
||||
* body. Without the scope, a historical `Phase:` line in an archive section
|
||||
* silently shadows the real one on every read/write, and because callers use
|
||||
* this for routing (state.cts's current_phase) and for drift detection
|
||||
* (gsd-tools.cjs's `drift-guard phase-status` CLI seam), a stale match either
|
||||
* routes work to the wrong phase or fabricates a drift finding.
|
||||
*
|
||||
* Level-flexible: the canonical template uses an h2 `## Current Position`, the
|
||||
* bootstrap template an h3 `### Current Position` (templates/state.md). Both
|
||||
* must match — mirroring how matchSessionSection recognises `## Session` and
|
||||
* `## Session Continuity`. Exact 'current position' text match (case-
|
||||
* insensitive) excludes unrelated headings. Built on the `collectSection`
|
||||
* seam, so it inherits that seam's CRLF tolerance (#2444 fix).
|
||||
*
|
||||
* This is the single owner of the scope — state.cts's private
|
||||
* `matchCurrentPositionSection` delegates here rather than duplicating the
|
||||
* logic, so the two consumers cannot drift apart.
|
||||
*
|
||||
* Returns the section body, or null (caller falls back to full-body search).
|
||||
*/
|
||||
export function stateCurrentPositionSlice(body: string): string | null {
|
||||
const isCurrentPosition = (h: HeadingToken): boolean =>
|
||||
(h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
|
||||
const section = collectSection(body, isCurrentPosition, { levelBounded: true });
|
||||
return section ? section.body : null;
|
||||
}
|
||||
|
||||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Bold inline format: **FieldName:** value
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
stateReplaceField,
|
||||
KNOWN_TEMPLATE_DEFAULTS,
|
||||
stateReplaceFieldIfTemplate,
|
||||
stateCurrentPositionSlice,
|
||||
} from './state-document.cjs';
|
||||
import { tokenizeHeadings, collectSection, replaceSection } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
@@ -1367,12 +1368,14 @@ function matchSessionSection(body: string): string | null {
|
||||
* excludes unrelated headings. Built on the same `collectSection` seam as
|
||||
* matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
|
||||
* Returns the section body, or null (caller falls back to full-body search).
|
||||
*
|
||||
* The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice`
|
||||
* (the module that owns STATE.md field extraction) — this is a thin alias kept
|
||||
* for call-site stability. Two copies of this scope would be exactly the kind
|
||||
* of generative-fix divergence the repo's parity rule exists to prevent.
|
||||
*/
|
||||
function matchCurrentPositionSection(body: string): string | null {
|
||||
const isCurrentPosition = (h: HeadingToken): boolean =>
|
||||
(h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
|
||||
const section = collectSection(body, isCurrentPosition, { levelBounded: true });
|
||||
return section ? section.body : null;
|
||||
return stateCurrentPositionSlice(body);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user