diff --git a/.changeset/bold-cranes-rest.md b/.changeset/bold-cranes-rest.md new file mode 100644 index 000000000..647ccfcf2 --- /dev/null +++ b/.changeset/bold-cranes-rest.md @@ -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) diff --git a/docs/ja-JP/reference/context-md.md b/docs/ja-JP/reference/context-md.md index bc2fe02e0..48252bde1 100644 --- a/docs/ja-JP/reference/context-md.md +++ b/docs/ja-JP/reference/context-md.md @@ -51,7 +51,7 @@ ## 意思決定識別子フォーマット -`` 内のすべての意思決定は連番の `D-NN` 識別子を持ちます: +`` 内のすべての意思決定は連番の `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つのタスクアクションによって対処されていることを検証します。 --- diff --git a/docs/reference/context-md.md b/docs/reference/context-md.md index cc53a1190..e172b9f3c 100644 --- a/docs/reference/context-md.md +++ b/docs/reference/context-md.md @@ -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 `` carries a sequential `D-NN` identifier: +Every decision in `` 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. --- diff --git a/src/check-command-router.cts b/src/check-command-router.cts index c7d79b337..380113fc4 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -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 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; } diff --git a/src/decisions.cts b/src/decisions.cts index 1eb2cac1f..9688a914c 100644 --- a/src/decisions.cts +++ b/src/decisions.cts @@ -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…-` 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 `-` id) — mirroring the parser's own `D-` * 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]-` token (#4130) in the block text, or an unterminated fence. An empty scaffold // () 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]-` 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' }; } diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index 334c87659..d9219791b 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -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 block with a non-D- ID prefix is could-not-parse, not none-present', () => { const md = '\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' + '\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 = '\n- **D4-01:** a short single-line decision.\n\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 = '\n- **D12-01:** two-digit phase.\n\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 = '\n- **D4-01 — the chosen datastore** use Postgres.\n\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 = '\n- **D4-01: The chosen datastore.** use Postgres.\n\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 = '\n' + + '- **D5-01:** choose the primary datastore\n' + + '- **D5-02:** pick the queue technology\n' + + '- **D5-03:** settle on the auth model\n' + + '\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 = '\n- **D4-01 [informational]:** reference only.\n\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 = "\n### Claude's Discretion\n- **D4-01:** internal choice.\n\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 = '\n- **D4x-01:** a typo in the phase prefix.\n\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 = '\n- **D-01:** valid.\n- **D4x-02:** malformed prefix.\n\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 = '\n' + + '- **Deferred-until-later:** we revisit the queue choice next phase.\n' + + '- **Note:** nothing else was decided here.\n' + + '\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 = '\n- **D-01:** bare.\n- **D-INFRA-01:** alnum tail.\n\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', + '', + '', + '### Implementation', + '- **D4-01:** use the phase-scoped datastore', + '', + ].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', + '', + '', + '- **D4-01:** use the phase-scoped datastore', + '', + ].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', + '', + '', + '- **D4-01:** use the phase-scoped datastore', + '', + ].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)}`); + }); +}); diff --git a/tests/fixtures/representative/decision-coverage-guard/MANIFEST.json b/tests/fixtures/representative/decision-coverage-guard/MANIFEST.json index 10b5e2b48..83f40168e 100644 --- a/tests/fixtures/representative/decision-coverage-guard/MANIFEST.json +++ b/tests/fixtures/representative/decision-coverage-guard/MANIFEST.json @@ -4,9 +4,10 @@ "fixtures": [ { "file": "d5-prefix-context.md", - "expectedReason": "could-not-parse", "expectedPassed": false, - "note": "A 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 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." } ] } diff --git a/tests/fixtures/representative/decision-coverage-guard/README.md b/tests/fixtures/representative/decision-coverage-guard/README.md index bdba0a51d..c590cb2c9 100644 --- a/tests/fixtures/representative/decision-coverage-guard/README.md +++ b/tests/fixtures/representative/decision-coverage-guard/README.md @@ -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 `` 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`. diff --git a/tests/representative-corpus.test.cjs b/tests/representative-corpus.test.cjs index 573bb2b17..1bc6cf587 100644 --- a/tests/representative-corpus.test.cjs +++ b/tests/representative-corpus.test.cjs @@ -207,7 +207,9 @@ 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)` - : `${fx.file} → outcome could-not-parse, passed:false`; + : typeof fx.expectedTotal === 'number' + ? `${fx.file} → parsed but uncovered, passed:false (#4130)` + : `${fx.file} → outcome could-not-parse, passed:false`; test(label, () => { tmpDir = makeProject(); const phaseDir = makePhaseDir(tmpDir, '01-repcorpus'); @@ -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)}`);