* 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:
5
.changeset/bold-cranes-rest.md
Normal file
5
.changeset/bold-cranes-rest.md
Normal 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)
|
||||
@@ -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つのタスクアクションによって対処されていることを検証します。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user