fix(#4130): parse phase-prefixed decision IDs (D4-01) (#4357)

* test(#4130): failing-first regression for phase-prefixed decision IDs

Add the #4130 matrix: D4-01/D12-01 across all three bullet forms, tags,
discretion, wrapped lead-ins, gate-level plan/verify end-to-end rows, and
parity properties (well-formed digit-prefixed ids parse to their exact id;
a non-digit injected into the prefix fails loud). Update the #2347
non-D-prefix fixture from D5-NN (now a legal grammar) to DEC-NN, and
graduate the representative d5-prefix corpus fixture from could-not-parse
to parsed-but-uncovered.

All new rows are RED against origin/next; they go green with the parser
fix in the next commit.

* fix(#4130): parse phase-prefixed decision IDs (D4-01)

The three declaration grammars, the parse-miss guard, the #3939 join
regexes, and the token evidence all anchored on the literal 'D-' (or
'**D-'), so an ID carrying a digit-run phase prefix between the leading
letter and the hyphen matched nothing — while the #2347 shape detector
correctly called those bullets decision-shaped, collapsing the whole
CONTEXT.md to could-not-parse with 0 extracted instead of a coverage
verdict.

Derive the extractor ID grammar from one shared DECISION_ID_SOURCE
('D[0-9]*-' + the existing alnum tail, full id captured), widen the
guard/join anchors to ID_ATTEMPT_SOURCE (bare 'D-' or a digit-initial
prefix run, so a typo'd 'D4x-01' fails loud while letter-initial prose
like 'Deferred-until' stays none-present), and align the bare-token
evidence. Both gates and the gap-checker share the parser, so all three
surfaces read phase-prefixed decisions now; the gate messages name the
accepted forms including the phase-prefixed one.

* docs(#4130): document the phase-prefixed decision identifier form

The canonical CONTEXT.md reference said decisions carry 'a sequential
D-NN identifier' with no mention of the optional phase-number prefix the
parser now accepts (D4-01) or the alphanumeric tail it always accepted
(D-INFRA-01). Name both in the Decision identifier format section, EN
and ja-JP.

* chore(#4130): changeset

* chore(#4130): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 21:05:19 -04:00
committed by GitHub
parent 48789fe9a9
commit 06eba5fdb0
9 changed files with 480 additions and 67 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4357
---
**The decision-coverage gate now reads phase-prefixed decision IDs** — a CONTEXT.md whose decisions use D4-01-style IDs (a digit-run phase prefix) no longer reports could-not-parse for the whole file; its decisions are counted and coverage-checked like any other, and a typo'd prefix (D4x-01) still fails loud. (#4130)

View File

@@ -51,7 +51,7 @@
## 意思決定識別子フォーマット
`<decisions>` 内のすべての意思決定は連番の `D-NN` 識別子を持ちます:
`<decisions>` 内のすべての意思決定は連番の `D-NN` 識別子を持ちます。`D` とハイフンの間にフェーズ番号の接頭辞(`D4-01`)を置くこともできます。複数フェーズのプロジェクトで裸の `D-01` がフェーズ間で衝突する場合に有用で、数字は識別子の一部として扱われます(#4130):
```markdown
### Layout style
@@ -59,7 +59,7 @@
- **D-02:** Each card shows: author avatar, name, timestamp, full post content, reaction counts
```
識別子はフェーズにスコープされます。フェーズ3の `D-01` はフェーズ7の `D-01` とは無関係です。プランチェッカー(ディメンション7)は、すべての `D-NN` が生成されたプランの少なくとも1つのタスクアクションによって対処されていることを検証します。
英数字の末尾(`D-INFRA-01`)も受け付けられます。識別子はフェーズにスコープされます。フェーズ3の `D-01` はフェーズ7の `D-01` とは無関係です。プランチェッカー(ディメンション7)は、すべての意思決定識別子が生成されたプランの少なくとも1つのタスクアクションによって対処されていることを検証します。
---

View File

@@ -51,15 +51,24 @@ The body is divided into named XML-style blocks. The blocks appear in a fixed or
## Decision identifier format
Every decision in `<decisions>` carries a sequential `D-NN` identifier:
Every decision in `<decisions>` carries a sequential `D-NN` identifier. An optional
phase-number prefix may sit between the `D` and the hyphen (`D4-01`) — useful on
multi-phase projects where bare `D-01` collides across phases; the digits are read
as part of the identifier (#4130):
```markdown
### Layout style
- **D-01:** Card-based layout, not timeline or list
- **D-02:** Each card shows: author avatar, name, timestamp, full post content, reaction counts
### Phase-4 decisions
- **D4-01:** Phase-scoped identifier, distinct from any other phase's D-01
```
Identifiers are scoped to the phase. `D-01` in Phase 3 is unrelated to `D-01` in Phase 7. The plan-checker (Dimension 7) verifies that every `D-NN` is addressed by at least one task action in the generated plans.
Alphanumeric tails (`D-INFRA-01`) are also accepted. Identifiers are scoped to the
phase. `D-01` in Phase 3 is unrelated to `D-01` in Phase 7. The plan-checker
(Dimension 7) verifies that every decision identifier is addressed by at least one
task action in the generated plans.
---

View File

@@ -329,12 +329,15 @@ function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolea
uncovered: [],
message: partialParse
? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
'`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator). ' +
'Fix the bullet format so all D-NN decisions can be read before re-running the gate.'
'`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator, or a phase ' +
'prefix that is not a digit run, e.g. `D4x-01`). Fix the bullet format so all decisions ' +
'can be read before re-running the gate.'
: 'Decision coverage gate: could not parse decisions — possible format mismatch. ' +
'The CONTEXT.md appears to be decision-shaped (has a <decisions> block, a decisions heading, ' +
'or D- tokens) but no D-NN bullets could be extracted. Check the formatting of the decisions ' +
'block and ensure bullets follow the `- **D-NN:** text` or `- **D-NN — title** body` form.',
'or D- tokens) but no decision bullets could be extracted. Check the formatting of the decisions ' +
'block and ensure bullets follow the `- **D-NN:** text`, `- **D4-NN:** text` (phase-prefixed), ' +
'or `- **D-NN — title** body` form. An ID grammar the parser does not support (e.g. `DEC-01`) ' +
'also lands here.',
}, raw, undefined);
return;
}
@@ -433,9 +436,11 @@ function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: bool
not_honored: [],
message: partialParse
? 'Decision coverage verify (warning): decisions could not be fully parsed — one or more ' +
'`- **D-NN ...**` bullets appear malformed. Fix the bullet format in the CONTEXT.md decisions block.'
'`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator, or a phase ' +
'prefix that is not a digit run). Fix the bullet format in the CONTEXT.md decisions block.'
: 'Decision coverage verify (warning): could not parse decisions — possible format mismatch. ' +
'Check the formatting of the CONTEXT.md decisions block.',
'Check the formatting of the CONTEXT.md decisions block (accepted forms: `- **D-NN:** text`, ' +
'`- **D4-NN:** text` (phase-prefixed), `- **D-NN — title** body`).',
}, raw, undefined);
return;
}

View File

@@ -4,7 +4,9 @@
* truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
* .cjs; only types are added.
*
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
* Accepts numeric (D-42), alphanumeric (D-INFRA-01), and phase-prefixed
* (D4-01 — an optional digit-run between the leading letter and the hyphen,
* #4130) IDs.
* Returns {id, text, category, tags, trackable} per decision.
* CJS callers that only use {id, text} safely ignore the extra fields.
*
@@ -57,23 +59,56 @@ const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
// ─── Bullet parsers (decisions-specific grammar) ─────────────────────────────
/**
* Colon form: `- **D-NN[ [tags]]:** text`
* (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
* #4130: the ID grammar every extractor regex below shares, as ONE source.
* `D`, an OPTIONAL digit-run phase prefix, a hyphen, then the pre-existing
* alphanumeric tail — so `D-01` (bare), `D4-01`/`D12-01` (phase-prefixed,
* the reporter's multi-phase convention where bare D-01 collides across
* phases), and `D-INFRA-01` (alnum tail) are all the same grammar now.
* #2347 had already taught the shape DETECTOR to call `D4-01` decision-shaped
* while the EXTRACTOR still anchored on the literal `**D-` — the disagreement
* that made a whole phase-prefixed CONTEXT.md report could-not-parse. Deriving
* the three grammars (and the token evidence below) from this one constant is
* the parity pin: the extractor's ID universe cannot drift from the declared
* grammar again without editing this line, which the #4130 property tests
* watch from the other side.
*/
const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
const DECISION_ID_SOURCE = 'D[0-9]*-[A-Za-z0-9][A-Za-z0-9_-]*';
/**
* Em-dash form: `- **D-NN[ [tags]] — title** body`
* #4130: the bold lead-in that ATTEMPTS the ID grammar above — used by the
* parse-miss guard and the #3939 join regexes, where recognising MORE shapes
* is the conservative direction (an over-broad match can only make a
* malformed bullet fail loud). The prefix run is either empty (bare `D-`) or
* DIGIT-INITIAL (`4`, `4x` — a phase prefix with a typo still counts as an
* attempted ID, so `D4x-01` reaches the guard and fails loud instead of
* vanishing), but never letter-initial: `D` + letters + `-` (`Deferred-until`)
* is a prose word, and prose must stay `none-present` (#2347's law).
*/
const ID_ATTEMPT_SOURCE = 'D(?:[0-9][A-Za-z0-9]*)?-';
/**
* Colon form: `- **D[phase]-NN[ [tags]]:** text`
* (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
* Group 1 captures the FULL id including any phase prefix (#4130).
*/
const bulletColonRe = new RegExp(
`^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`,
);
/**
* Em-dash form: `- **D[phase]-NN[ [tags]] — title** body`
* The em-dash (U+2014) or its lookalike separates the ID+tags group from a title
* that lives inside the bold markers; the body (which may be empty) follows
* outside the closing `**`. This form was not handled pre-T1 (bug #1364).
*
* Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
*/
const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
const bulletEmDashRe = new RegExp(
`^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^*]*[—–][^*]*\\*\\*\\s*(.*)$`,
);
/**
* Titled-colon form: `- **D-NN[ [tags]]: Title.** body`
* Titled-colon form: `- **D[phase]-NN[ [tags]]: Title.** body`
* A title sits between the colon and the closing `**` (so the `:**` anchor of
* bulletColonRe fails, and there is no em-dash for bulletEmDashRe). This is a strict
* superset of the colon-immediate form, so it MUST be checked AFTER bulletColonRe and
@@ -83,16 +118,35 @@ const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+
* guard — matching bulletColonRe's `[^:*]*` discipline that the separator colon is the
* only colon permitted before `**`. (#1639)
*/
const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/;
const bulletTitledColonRe = new RegExp(
`^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`,
);
/**
* #4130: the parse-miss guard's probe — a line whose bold lead-in ATTEMPTS the
* ID grammar (see `ID_ATTEMPT_SOURCE`) but failed all three bullet patterns
* above. Bare `D-` attempts behave exactly as before #4130; a digit-initial
* prefix run (`D4-`… including a typo'd `D4x-`) is new evidence of an attempt,
* so the malformed-prefixed bullet fails loud instead of silently vanishing.
*/
const parseMissGuardRe = new RegExp(`^\\s*-\\s+\\*\\*${ID_ATTEMPT_SOURCE}`);
/**
* #4130: bare-token evidence of decision-shaped content — a `D…-<alnum>` token
* in running text. `D-01` matched before; the digit-run phase prefix (`D4-01`)
* is added so token evidence agrees with the extractor's ID grammar
* (DECISION_ID_SOURCE) instead of silently ignoring prefixed mentions.
*/
const decisionTokenRe = new RegExp(`\\bD[0-9]*-[A-Za-z0-9]`, 'm');
/**
* #2347: format-agnostic evidence that a block/section holds real decision
* ENTRIES the parser could not read — a bullet whose bold lead-in is an
* ID-SHAPED token (uppercase prefix, optional digits, hyphen, alnum), whatever
* the exact ID grammar. The three parser grammars above all require a `D-`
* prefix; #1365's fail-loud guard reused that same `\bD-` test as its "is this
* decision-shaped?" evidence, so any other prefix (e.g. `D5-01`) was invisible
* to BOTH parser and guard, collapsing `could-not-parse` into a clean
* the exact ID grammar. #1365's fail-loud guard originally reused the parser's
* own `\bD-` test as its "is this decision-shaped?" evidence, so any prefix the
* parser could not read (e.g. `D5-01` then, `DEC-01` now) was invisible to
* BOTH parser and guard, collapsing `could-not-parse` into a clean
* `none-present` pass.
*
* The ID-shape requirement (not "any bold bullet") is deliberate: a decisions
@@ -100,26 +154,36 @@ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([
* bullets with bold labels (`- **Scope:** …`, `- **Why:** …`, `- **Note:** …`).
* Those are NOT decision entries and must stay `none-present` — a false
* `could-not-parse` hard-blocks the plan gate. `[A-Z]+[0-9]*-[A-Za-z0-9]` matches
* `D-01` / `D5-01` / `DEC-01` but not `Scope:` / `Why:` / `Follow-up:` (mixed
* `D-01` / `D4-01` / `DEC-01` but not `Scope:` / `Why:` / `Follow-up:` (mixed
* case) / `TODO:` (no `-<alnum>` id) — mirroring the parser's own `D-<alnum>`
* shape without hardcoding the `D`.
*
* #4130 parity note: for the D-prefixed universe this detector's grammar
* (`D` + digit-run + `-` + alnum) is exactly `DECISION_ID_SOURCE` above, so a
* well-formed bullet the detector calls decision-shaped is now always one the
* extractor can read. The detector stays WIDER on purpose (`DEC-01` is still
* evidence): an ID grammar outside the parser's universe must keep failing
* loud, never silently passing. The #4130 property tests pin both directions.
*/
const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
/**
* #3939: a decision bullet's DECLARATION line — the `- **D-NN … **` bold lead-in
* the three grammars above anchor on — may wrap across a line break. Physical
* line breaks inside a bullet are markdown-insignificant, and GSD's own
* #3939: a decision bullet's DECLARATION line — the `- **D[phase]-NN … **` bold
* lead-in the three grammars above anchor on — may wrap across a line break.
* Physical line breaks inside a bullet are markdown-insignificant, and GSD's own
* discuss-phase writer emits the wrapped shape whenever a decision title runs
* past the wrap column. All three grammars require the closing `**` in the same
* string as the `- **D-` anchor, so a wrapped declaration matched none of them
* string as the `- **D…-` anchor, so a wrapped declaration matched none of them
* and fell to the #1365 parse-miss guard, forcing `could-not-parse` (which
* hard-blocks `check.decision-coverage-plan`) on a well-formed CONTEXT.md.
*
* The repair is confined to how the LOGICAL bullet is assembled — the grammars
* themselves are untouched, so every single-line form parses exactly as before.
* #4130: the anchor uses `ID_ATTEMPT_SOURCE` (digit-run phase prefixes join
* like bare ones; recognising more start shapes only reassembles the logical
* bullet, which then parses or fails loud as itself).
*/
const decisionBulletStartRe = /^\s*-\s+\*\*D-/;
const decisionBulletStartRe = new RegExp(`^\\s*-\\s+\\*\\*${ID_ATTEMPT_SOURCE}`);
/**
* A line that opens a new BLOCK-LEVEL construct, and therefore terminates the
@@ -169,7 +233,8 @@ const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
* to watch for a splice.
*
* The id character class is deliberately looser than the grammars' (it admits
* an empty id, so a bare `- **D-` still counts as unsettled). This regex only
* an empty id, so a bare `- **D-` still counts as unsettled, and — #4130 — a
* digit-run phase prefix between the `D` and the first hyphen). This regex only
* answers "may an id-adjacent bracket still open here?", where recognising MORE
* shapes is the conservative direction: an over-broad match can only make a
* malformed bullet fail loud, while a missed one silently re-classifies.
@@ -178,7 +243,7 @@ const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
* into `tags` (and therefore into `trackable`). A `[` further along the title is
* ordinary text and does not restrict the join.
*/
const tagRegionRe = /^\s*-\s+\*\*D-[A-Za-z0-9_-]*\s*(?:\[([^\]]*))?$/;
const tagRegionRe = new RegExp(`^\\s*-\\s+\\*\\*D[0-9]*-[A-Za-z0-9_-]*\\s*(?:\\[([^\\]]*))?$`);
/**
* #3939 (review): would folding `next` onto a lead-in whose `[tags]` bracket is
@@ -391,11 +456,11 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
continue;
}
// Colon form: `- **D-NN[ [tags]]:** text`
// Colon form: `- **D[phase]-NN[ [tags]]:** text`
const colonMatch = line.match(bulletColonRe);
if (colonMatch) {
flush();
const id = `D-${colonMatch[1]}`;
const id = colonMatch[1];
const tags = colonMatch[2]
? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
@@ -405,11 +470,11 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
continue;
}
// Em-dash form: `- **D-NN[ [tags]] — title** body`
// Em-dash form: `- **D[phase]-NN[ [tags]] — title** body`
const emDashMatch = line.match(bulletEmDashRe);
if (emDashMatch) {
flush();
const id = `D-${emDashMatch[1]}`;
const id = emDashMatch[1];
const tags = emDashMatch[2]
? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
@@ -422,14 +487,14 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
continue;
}
// Titled-colon form: `- **D-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
// Titled-colon form: `- **D[phase]-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
// a strict superset of bulletColonRe, so it only catches bullets the colon-immediate
// and em-dash forms missed (minimal blast radius). id + [tags] trackability honored;
// the body after the closing bold run is reported as text.
const titledColonMatch = line.match(bulletTitledColonRe);
if (titledColonMatch) {
flush();
const id = `D-${titledColonMatch[1]}`;
const id = titledColonMatch[1];
const tags = titledColonMatch[2]
? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
@@ -439,10 +504,14 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
continue;
}
// Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
// bullet but failed both patterns — flush, warn, and record the miss.
// Parse-miss guard (FIX B + #1343, grammar widened #4130): a line whose bold
// lead-in ATTEMPTS the ID grammar but failed all three patterns — flush,
// warn, and record the miss. `ID_ATTEMPT_SOURCE` accepts the bare `D-` form
// (as before) plus a digit-initial prefix run, so a typo'd phase prefix
// (`D4x-01`) fails loud instead of silently vanishing, while a letter-initial
// run (`Deferred-until`) stays prose and stays invisible.
// parseMisses > 0 forces could-not-parse even when other decisions parsed.
if (/^\s*-\s+\*\*D-/.test(line)) {
if (parseMissGuardRe.test(line)) {
flush();
parseMisses += 1;
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
@@ -502,10 +571,10 @@ export function extractDecisions(content: unknown): DecisionExtraction {
// FIX A: Block present but 0 extracted and no parse-misses.
// Only report could-not-parse when there is genuine evidence of real decisions
// that failed to parse: a bold-lead-in bullet (`- **…**`, any ID grammar — #2347),
// a \bD- token in the block text, or an unterminated fence. An empty scaffold
// a bare `D[phase]-<alnum>` token (#4130) in the block text, or an unterminated fence. An empty scaffold
// (<decisions></decisions>) or an all-prose block has no such evidence — treat
// as none-present so the gate passes cleanly.
const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
const hasDecisionTokenInBlock = decisionTokenRe.test(combined);
const hasBoldLeadInBullet = boldLeadInBulletRe.test(combined);
if (hasDecisionTokenInBlock || hasBoldLeadInBullet || unterminatedFence) {
return { decisions: [], outcome: 'could-not-parse' };
@@ -534,10 +603,10 @@ export function extractDecisions(content: unknown): DecisionExtraction {
}
// FIX A: Heading found but 0 extracted and no parse-misses.
// Report could-not-parse when the section body holds a decision-entry-shaped
// bold-lead-in bullet (`- **…**`, any ID grammar — #2347) or a D- token. A
// bold-lead-in bullet (`- **…**`, any ID grammar — #2347) or a `D[phase]-<alnum>` token (#4130). A
// heading with only prose, sub-headings, or all-discretion content (no such
// evidence) is a legitimate empty/discretion section → none-present.
const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
const hasDecisionTokenInSection = decisionTokenRe.test(section.body);
const hasBoldLeadInBulletInSection = boldLeadInBulletRe.test(section.body);
if (hasDecisionTokenInSection || hasBoldLeadInBulletInSection) {
return { decisions: [], outcome: 'could-not-parse' };
@@ -547,8 +616,8 @@ export function extractDecisions(content: unknown): DecisionExtraction {
// ── Path 3: no blocks, no heading ────────────────────────────────────────────
// Apply shape heuristics to distinguish none-present from could-not-parse.
// We re-use the already-computed unterminatedFence and check for D- tokens.
const hasDecisionToken = /\bD-[A-Za-z0-9]/m.test(stripped);
// We re-use the already-computed unterminatedFence and check for decision tokens.
const hasDecisionToken = decisionTokenRe.test(stripped);
if (unterminatedFence || hasDecisionToken) {
return { decisions: [], outcome: 'could-not-parse' };
}

View File

@@ -234,14 +234,20 @@ describe('extractDecisions — typed outcome (#1364 + #1365)', () => {
// literal "D-" token, so they isolate the bold-bullet evidence from the old
// D--token path.
describe('extractDecisions — format-agnostic evidence test (#2347)', () => {
// #4130 note: this fixture originally used the D5-NN phase-prefixed shape
// from #2347's own reproduction. #4130 made the digit-run phase prefix LEGAL
// (see the #4130 blocks below — D5-01 now parses), so the "prefix the parser
// cannot read" fixture here uses a DEC-NN multi-letter prefix instead: still
// an ID-shaped bold lead-in, still outside the parser's D-prefixed universe,
// so the fail-loud contract this describe-block pins is unchanged.
test('populated <decisions> block with a non-D- ID prefix is could-not-parse, not none-present', () => {
const md = '<decisions>\n'
+ '- **D5-01:** choose the primary datastore\n'
+ '- **D5-02:** pick the queue technology\n'
+ '- **D5-03:** settle on the auth model\n'
+ '- **DEC-01:** choose the primary datastore\n'
+ '- **DEC-02:** pick the queue technology\n'
+ '- **DEC-03:** settle on the auth model\n'
+ '</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.decisions.length, 0, 'parser cannot read the D5- prefix (0 extracted)');
assert.strictEqual(r.decisions.length, 0, 'parser cannot read the DEC- prefix (0 extracted)');
assert.strictEqual(r.outcome, 'could-not-parse',
'a populated block the parser cannot read must FAIL LOUD, not pass as none-present');
});
@@ -2216,3 +2222,303 @@ describe('check.decision-coverage-plan — wrapped bold lead-in does not hard-bl
`Both wrapped decisions must be seen as covered by the plan. Got: ${JSON.stringify(parsed)}`);
});
});
// ─── #4130: phase-prefixed decision IDs (D4-01) must parse ───────────────────
//
// The three declaration grammars all anchored on the literal `**D-`, so an ID
// carrying a phase-number prefix between the leading letter and the hyphen
// (D4-01, D12-01 — the reporter's D3-NN/D4-NN/D5-NN multi-phase convention,
// where bare D-01 collides across 18 phases) matched none of them — and none
// of the parse-miss guard or the #3939 join regexes either, so such a bullet
// was INVISIBLE to the extractor while the #2347 evidence detector correctly
// called the file decision-shaped. Net effect: the whole CONTEXT.md collapsed
// to could-not-parse with 0 extracted and the gate reported a format problem
// instead of a coverage result. The extractor now accepts an optional
// digit-run phase prefix on the same grammar the detector already recognized.
describe('parseDecisions — phase-prefixed IDs parse in every form (#4130)', () => {
test('FAIL-FIRST: - **D4-01:** single-line colon form with a phase prefix parses', () => {
// Before the fix: outcome could-not-parse, 0 extracted (the issue's own
// b-phase-prefix fixture — the only variable vs the parsing baseline is
// the `4` in the ID).
const md = '<decisions>\n- **D4-01:** a short single-line decision.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed',
`A phase-prefixed colon bullet must parse. Got: ${JSON.stringify(r)}`);
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D4-01'],
`The full prefixed id must be reported. Got: ${JSON.stringify(r.decisions)}`);
assert.strictEqual(r.decisions[0].text, 'a short single-line decision.');
});
test('- **D12-01:** two-digit phase prefix parses', () => {
const md = '<decisions>\n- **D12-01:** two-digit phase.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed');
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D12-01']);
});
test('em-dash form - **D4-01 — title** body parses with a phase prefix', () => {
const md = '<decisions>\n- **D4-01 — the chosen datastore** use Postgres.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed');
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D4-01']);
});
test('titled-colon form - **D4-01: Title.** body parses with a phase prefix', () => {
const md = '<decisions>\n- **D4-01: The chosen datastore.** use Postgres.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed');
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D4-01']);
});
test('D5-NN (the #2347 reproduction shape) now parses — the multi-phase convention is legal', () => {
const md = '<decisions>\n'
+ '- **D5-01:** choose the primary datastore\n'
+ '- **D5-02:** pick the queue technology\n'
+ '- **D5-03:** settle on the auth model\n'
+ '</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed',
`The exact #2347 fixture grammar must now be readable. Got: ${JSON.stringify(r)}`);
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D5-01', 'D5-02', 'D5-03']);
});
test('phase prefix + [tags] honors tags and trackable:false', () => {
const md = '<decisions>\n- **D4-01 [informational]:** reference only.\n</decisions>\n';
const ds = parseDecisions(md);
assert.deepStrictEqual(ds.map((d) => d.id), ['D4-01']);
assert.ok(ds[0].tags.includes('informational'),
`Tags must survive the prefixed grammar. Got: ${JSON.stringify(ds[0])}`);
assert.strictEqual(ds[0].trackable, false);
});
test("phase-prefixed decision under ### Claude's Discretion is non-trackable", () => {
const md = "<decisions>\n### Claude's Discretion\n- **D4-01:** internal choice.\n</decisions>\n";
const ds = parseDecisions(md);
assert.deepStrictEqual(ds.map((d) => d.id), ['D4-01']);
assert.strictEqual(ds[0].trackable, false);
});
test('phase-prefixed bullet with a wrapped bold lead-in parses like the one-line form (#4130 x #3939)', () => {
const oneLine = extractDecisions(inBlock('- **D4-01: Persist the raw delivery headers.** JSON arrays preserve order.'));
const wrapped = extractDecisions(inBlock(
'- **D4-01: Persist the raw delivery\n headers.** JSON arrays preserve order.'));
assert.strictEqual(oneLine.outcome, 'parsed');
assert.strictEqual(wrapped.outcome, 'parsed',
`A wrapped prefixed declaration must join and parse. Got: ${JSON.stringify(wrapped)}`);
assert.deepStrictEqual(wrapped.decisions.map((d) => d.id), ['D4-01']);
assert.deepStrictEqual(wrapped.decisions[0].text, oneLine.decisions[0].text,
'Wrapping must stay markdown-insignificant for prefixed ids too.');
});
});
describe('parseDecisions — phase-prefix failure modes stay loud, prose stays prose (#4130)', () => {
test('- **D4x-01:** (non-digit inside the prefix) is a genuine parse-miss → could-not-parse', () => {
const md = '<decisions>\n- **D4x-01:** a typo in the phase prefix.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'could-not-parse',
`A malformed phase prefix must fail loud, not silently extract or vanish. Got: ${JSON.stringify(r)}`);
assert.deepStrictEqual(r.decisions, [],
'A malformed prefix must never be extracted as a decision.');
});
test('a malformed phase-prefixed bullet poisons a file that also has valid decisions (FIX B parity)', () => {
// Before the fix this file was outcome:parsed with D-01 only — D4x-02 was
// silently invisible to the extractor AND the guard (the exact silent-drop
// class #1365 FIX B exists to prevent, surviving for prefixed ids).
const md = '<decisions>\n- **D-01:** valid.\n- **D4x-02:** malformed prefix.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'could-not-parse',
`A parse-miss on a prefixed bullet must block, not silently drop. Got: ${JSON.stringify(r)}`);
});
test('D-initial hyphenated prose labels stay none-present (the widened guard must not reach prose)', () => {
// `Deferred-until-later` is `D` + letters + `-`: the letter-initial run is
// a prose word, not a digit-run phase prefix, so it must stay invisible to
// the parse-miss guard exactly as before #4130.
const md = '<decisions>\n'
+ '- **Deferred-until-later:** we revisit the queue choice next phase.\n'
+ '- **Note:** nothing else was decided here.\n'
+ '</decisions>\n';
assert.strictEqual(extractDecisions(md).outcome, 'none-present',
'D-initial hyphenated prose labels must not become parse-misses.');
});
test('bare D-01 baseline and D-INFRA-01 alnum tail parse unchanged', () => {
const md = '<decisions>\n- **D-01:** bare.\n- **D-INFRA-01:** alnum tail.\n</decisions>\n';
const r = extractDecisions(md);
assert.strictEqual(r.outcome, 'parsed');
assert.deepStrictEqual(r.decisions.map((d) => d.id), ['D-01', 'D-INFRA-01']);
});
});
// ─── #4130 properties: detector/extractor ID-grammar parity ─────────────────
//
// The bug was a grammar DISAGREEMENT: the #2347 evidence detector accepted
// `D4-01` as decision-shaped while the extractor's `**D-` anchor rejected it,
// so the file failed loud as a whole instead of being read. These properties
// pin the parity invariant in both directions for the D-prefixed universe:
// every well-formed digit-prefixed id (the shape the detector already calls
// decision-shaped) must PARSE to its exact id in every bullet form, and every
// malformed variant of that shape (a non-digit inside the digit-run prefix)
// must FAIL LOUD — never a silent none-present, never a silent extraction.
// If either grammar drifts from the other again, one of these fires.
describe('#4130 properties: digit-prefixed ids parse or fail loud, never vanish', () => {
const phasePrefixedIdArb = fc
.tuple(fc.integer({ min: 1, max: 99 }), fc.integer({ min: 1, max: 99 }))
.map(([phase, seq]) => `D${phase}-${String(seq).padStart(2, '0')}`);
test('property: every well-formed phase-prefixed bullet parses to its exact id, in every form', () => {
fc.assert(
fc.property(
fc.constantFrom(...Object.keys(BULLET_FORMS)),
phasePrefixedIdArb,
tagsArb,
proseArb(2, 8),
proseArb(1, 6),
fc.boolean(),
(form, id, tags, title, body, withCategory) => {
const heading = withCategory ? '### Implementation\n' : '';
const line = renderBullet(form, id, tags, title, body);
const r = extractDecisions(inBlock(heading + line));
assert.strictEqual(r.outcome, 'parsed',
`A well-formed phase-prefixed declaration must parse. form=${form} line=${JSON.stringify(line)} → ${JSON.stringify(r)}`);
assert.deepStrictEqual(r.decisions.map((d) => d.id), [id],
`The prefixed id must round-trip exactly. form=${form} id=${id} → ${JSON.stringify(r.decisions)}`);
return true;
},
),
);
});
test('property: a non-digit injected into the phase prefix fails loud, never silently', (t) => {
const originalWarn = console.warn;
console.warn = () => {};
t.after(() => { console.warn = originalWarn; });
fc.assert(
fc.property(
fc.constantFrom(...Object.keys(BULLET_FORMS)),
phasePrefixedIdArb,
fc.constantFrom('x', 'X', 'z'),
(form, id, junk) => {
const badId = id.replace(/^(D\d)/, `$1${junk}`);
const line = renderBullet(form, badId, '', 'title', 'body');
const r = extractDecisions(inBlock(line));
assert.strictEqual(r.outcome, 'could-not-parse',
`A malformed phase prefix must fail loud. form=${form} line=${JSON.stringify(line)} → ${JSON.stringify(r)}`);
assert.deepStrictEqual(r.decisions, [],
`A malformed phase prefix must never be extracted. form=${form} line=${JSON.stringify(line)}`);
return true;
},
),
);
});
});
// ─── #4130 gate-level: the gates read phase-prefixed decisions end-to-end ────
describe('check.decision-coverage-plan — phase-prefixed decisions are readable (#4130)', () => {
let tmpDir;
let planningDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-4130-');
planningDir = path.join(tmpDir, '.planning');
phaseDir = path.join(planningDir, 'phases', '01-init');
fs.mkdirSync(phaseDir, { recursive: true });
});
afterEach(() => cleanup(tmpDir));
test('FAIL-FIRST: CONTEXT.md with D4-01 covered by the plan → passed:true, total:1, covered:1', () => {
// Before the fix: reason could-not-parse, total 0 — the gate reported a
// format problem for the whole file instead of a coverage result.
writeContextFile(phaseDir, [
'# Phase 4 Context',
'',
'<decisions>',
'### Implementation',
'- **D4-01:** use the phase-scoped datastore',
'</decisions>',
].join('\n'));
writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- D4-01: provision the datastore\n');
const contextPath = path.join(phaseDir, 'CONTEXT.md');
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
const parsed = JSON.parse(result.output || '{}');
assert.strictEqual(parsed.passed, true,
`A plan covering the prefixed decision must pass. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.total, 1,
`The prefixed decision must be counted. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.covered, 1,
`The prefixed decision must be seen as covered. Got: ${JSON.stringify(parsed)}`);
});
test('D4-01 not covered → passed:false with the uncovered id, NOT could-not-parse', () => {
writeContextFile(phaseDir, [
'# Phase 4 Context',
'',
'<decisions>',
'- **D4-01:** use the phase-scoped datastore',
'</decisions>',
].join('\n'));
writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- Something unrelated.\n');
const contextPath = path.join(phaseDir, 'CONTEXT.md');
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
const parsed = JSON.parse(result.output || '{}');
assert.strictEqual(parsed.passed, false,
`An uncovered prefixed decision must fail on coverage. Got: ${JSON.stringify(parsed)}`);
assert.notStrictEqual(parsed.reason, 'could-not-parse',
`The gate must report a coverage result, not a format problem. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.total, 1);
assert.strictEqual(parsed.covered, 0);
assert.deepStrictEqual(
(parsed.uncovered || []).map((u) => u.id),
['D4-01'],
`The uncovered row must carry the prefixed id. Got: ${JSON.stringify(parsed.uncovered)}`,
);
});
});
describe('check.decision-coverage-verify — phase-prefixed decisions are readable (#4130)', () => {
let tmpDir;
let planningDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-4130v-');
planningDir = path.join(tmpDir, '.planning');
phaseDir = path.join(planningDir, 'phases', '01-init');
fs.mkdirSync(phaseDir, { recursive: true });
});
afterEach(() => cleanup(tmpDir));
test('verify reads D4-01 and honors it when the plan mentions it (no could-not-parse)', () => {
writeContextFile(phaseDir, [
'# Phase 4 Context',
'',
'<decisions>',
'- **D4-01:** use the phase-scoped datastore',
'</decisions>',
].join('\n'));
writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- D4-01: provision the datastore\n');
const contextPath = path.join(phaseDir, 'CONTEXT.md');
const result = runGsdTools(
['query', 'check.decision-coverage-verify', phaseDir, contextPath],
tmpDir,
);
const parsed = JSON.parse(result.output || '{}');
assert.notStrictEqual(parsed.reason, 'could-not-parse',
`Verify must read prefixed decisions, not report a format mismatch. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.total, 1,
`The prefixed decision must be counted. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.honored, 1,
`A plan mentioning D4-01 honors it. Got: ${JSON.stringify(parsed)}`);
});
});

View File

@@ -4,9 +4,10 @@
"fixtures": [
{
"file": "d5-prefix-context.md",
"expectedReason": "could-not-parse",
"expectedPassed": false,
"note": "A <decisions> block using the D5-01 ID-prefix shape from #2347's own reproduction ('- **D5-01:** some decision'), repeated twice so 'populated but 0 extracted' is unambiguous. The original report used 23 real decisions under a project-specific D5- prefix convention; this fixture preserves the exact grammar mismatch, not the count. FIXED by #2347: the guard's evidence test no longer reuses the parser's D- grammar — a bold ID-shaped lead-in bullet (any prefix) is now evidence, so this populated block correctly fails loud (could-not-parse). currentBuggyOutput removed and the assertion graduated to expected* per this corpus's contract."
"expectedTotal": 2,
"expectedCovered": 0,
"note": "A <decisions> block using the D5-01 ID-prefix shape from #2347's own reproduction ('- **D5-01:** some decision'), repeated twice so 'populated' is unambiguous. The original report used 23 real decisions under a project-specific D5- prefix convention; this fixture preserves the exact grammar, not the count. History: #2347 made the guard's evidence test format-agnostic so this populated block failed loud (could-not-parse) instead of silently passing. #4130 then made the digit-run phase prefix LEGAL — the parser now reads D5-01/D5-02 — so the gate reports a real coverage verdict for this shape: both decisions parsed (total 2) and uncovered (covered 0, passed false, no reason field) in this bare project. The corpus's graduation contract: the assertion was updated as part of the #4130 fix that changed the observable verdict."
}
]
}

View File

@@ -1,4 +1,4 @@
# Decision-coverage guard fixture (#2347)
# Decision-coverage guard fixture (#2347, graduated by #4130)
`d5-prefix-context.md` is the verbatim reproduction shape from #2347 — the
`- **D5-01:** some decision` bullet given in the issue's own "Steps to
@@ -7,18 +7,23 @@ reproduce" — used as a CONTEXT.md `<decisions>` block and driven through
CLI gate; see `tests/decisions.test.cjs` for the established pattern this
follows), not `extractDecisions()` called in isolation.
The #1365 fail-loud guard's "is this decision-shaped?" evidence test
(`/\bD-[A-Za-z0-9]/`) reuses the same `D-` grammar as the parser it guards.
For any ID prefix the parser cannot read — `D5-01` here — the guard sees
no evidence either, so the two failure modes the guard exists to
distinguish (`none-present` vs `could-not-parse`) collapse into
`none-present`, and a populated, genuinely decision-shaped CONTEXT.md
passes silently.
History of the expected verdict for this exact shape:
Expected once fixed: `reason: 'could-not-parse'`, `passed: false` (see
`MANIFEST.json`'s `expectedReason`/`expectedPassed`). Today's gate instead
reports `passed: true, skipped: true, reason: 'no trackable decisions'` —
pinned in `MANIFEST.json`'s `currentBuggyOutput` and asserted directly in
`tests/representative-corpus.test.cjs` (a characterization of today's
known-broken behavior, not a `todo` — see
`tests/fixtures/representative/README.md` for why) until #2347 lands.
1. **#2347 (original):** the #1365 fail-loud guard's "is this
decision-shaped?" evidence test (`/\bD-[A-Za-z0-9]/`) reused the same
`D-` grammar as the parser it guards, so `D5-01` was invisible to both
and a populated CONTEXT.md passed silently. #2347 made the evidence test
format-agnostic, and the fixture's expectation graduated from the
characterized buggy green-skip to `could-not-parse`.
2. **#4130 (current):** the digit-run phase prefix itself became a LEGAL
ID grammar — the parser now reads `D5-01`/`D5-02` — so this shape no
longer fails loud at all. The gate reports a real coverage verdict:
`total: 2`, `covered: 0`, `passed: false`, and NO `reason` field (the
coverage path emits none). Pinned in `MANIFEST.json`'s
`expectedTotal`/`expectedCovered`/`expectedPassed` and asserted in
`tests/representative-corpus.test.cjs`.
The fail-loud contract itself is unchanged and still covered by the
`DEC-`-prefix (non-`D` universe) tests in `tests/decisions.test.cjs`:
an ID grammar the parser genuinely cannot read still yields
`could-not-parse`.

View File

@@ -207,6 +207,8 @@ describe('representative corpus — decision-coverage guard (#2347)', () => {
for (const fx of manifest.fixtures) {
const label = fx.currentBuggyOutput
? `${fx.file} → currently passed:true, skipped:true (#2347)`
: typeof fx.expectedTotal === 'number'
? `${fx.file} → parsed but uncovered, passed:false (#4130)`
: `${fx.file} → outcome could-not-parse, passed:false`;
test(label, () => {
tmpDir = makeProject();
@@ -225,6 +227,17 @@ describe('representative corpus — decision-coverage guard (#2347)', () => {
assert.strictEqual(j.skipped, fx.currentBuggyOutput.skipped, `${fx.file}: skipped`);
assert.strictEqual(j.reason, fx.currentBuggyOutput.reason, `${fx.file}: reason`);
assert.strictEqual(j.total, fx.currentBuggyOutput.total, `${fx.file}: total`);
} else if (typeof fx.expectedTotal === 'number') {
// #4130: the fixture's D5-NN phase-prefixed ids are now READ by the
// parser, so the gate reports a real coverage verdict (nothing covers
// them in this bare project) instead of could-not-parse. The coverage
// path emits no `reason` field at all — absence is part of the
// expectation.
assert.strictEqual(j.passed, fx.expectedPassed, `${fx.file}: passed. Got ${JSON.stringify(j)}`);
assert.strictEqual(j.reason, undefined,
`${fx.file}: the coverage verdict must carry no could-not-parse reason. Got ${JSON.stringify(j)}`);
assert.strictEqual(j.total, fx.expectedTotal, `${fx.file}: total. Got ${JSON.stringify(j)}`);
assert.strictEqual(j.covered, fx.expectedCovered, `${fx.file}: covered. Got ${JSON.stringify(j)}`);
} else {
assert.strictEqual(j.passed, fx.expectedPassed, `${fx.file}: passed. Got ${JSON.stringify(j)}`);
assert.strictEqual(j.reason, fx.expectedReason, `${fx.file}: reason. Got ${JSON.stringify(j)}`);