Files
msd-core/src/decisions.cts
Tom Boucher b205e4c2b2 fix(#1639): parseDecisions handles the titled-colon bullet form (#1665)
* fix(#1639): parseDecisions handles titled-colon bullet form

bulletColonRe anchors on ':**' (colon immediately before close-bold) and bulletEmDashRe
requires an em-dash, so the titled-colon form '- **D-NN: Title.** body' (title between the
colon and the closing **) matched neither and was dropped by the parse-miss guard. When all
decisions used the titled convention, parseDecisions returned 0 and check.decision-coverage-
plan passed vacuously — the same false-coverage failure mode as #1343/#1364/#1365. Add a
third per-form regex bulletTitledColonRe, checked LAST (strict superset of bulletColonRe,
so it only catches bullets the other two miss — minimal blast radius); id + [tags]
trackability honored. Regression folded into decisions.test.cjs: titled-colon parses,
coexists with colon/em-dash, tags, all-titled-13 no longer vacuously 0.

* fix(#1639): tighten titled-colon title to [^:*]* so malformed pre-colon-run bullets still reject

The first cut's title run [^*]* was too permissive: it matched a genuinely-malformed
bullet with a colon in the pre-separator freeform run (e.g. 'D-07 ratio 3:1:**') by
treating the 3:1 colon as the separator, regressing the #1343 parse-miss guard tests.
Tighten the title to [^:*]* (no colon, no star) so the separator colon remains the only
colon permitted before ** — matching bulletColonRe's existing [^:*]* discipline. Valid
titled forms (colon-free titles) still parse; the malformed colon-in-freeform case still
falls through to the parse-miss guard.

* chore(#1639): backfill changeset pr ref to 1665
2026-06-24 13:26:25 -04:00

311 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared parser for CONTEXT.md <decisions> blocks (ADR-457 build-at-publish:
* the hand-written bin/lib/decisions.cjs collapsed to a TypeScript source of
* 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.
* Returns {id, text, category, tags, trackable} per decision.
* CJS callers that only use {id, text} safely ignore the extra fields.
*
* ADR-1372 T1: rewritten to adopt the markdown-sectionizer seam.
* - `stripFencedCode` → seam's `stripFencedCode` (CommonMark-correct)
* - `extractDecisionsBlock` → seam's `extractTaggedBlocks(content,'decisions')`
* - Markdown-header fallback → seam's `collectSection(content, /decisions?/i, ...)`
* - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path)
*
* Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate).
*/
import {
stripFencedCode,
extractTaggedBlocks,
collectSection,
} from './markdown-sectionizer.cjs';
export interface Decision {
id: string;
text: string;
category: string;
tags: string[];
trackable: boolean;
}
/**
* Typed extraction result distinguishing three states the blocking gate cares about:
* - 'parsed' — ≥1 decision was successfully extracted
* - 'none-present' — content has no decision signals; nothing to check
* - 'could-not-parse'— content is decision-shaped (has a <decisions> block, a
* /decisions?/i heading, a \bD- token, or an unterminated fence)
* yet 0 decisions were extracted → format mismatch, fail-loud
*/
export type DecisionOutcome = 'parsed' | 'none-present' | 'could-not-parse';
export interface DecisionExtraction {
decisions: Decision[];
outcome: DecisionOutcome;
}
const DISCRETION_HEADINGS = new Set([
"claude's discretion",
'claudes discretion',
'claude discretion',
]);
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 `:**`)
*/
const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
/**
* Em-dash form: `- **D-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*(.*)$/;
/**
* Titled-colon form: `- **D-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
* bulletEmDashRe — it only catches bullets those two miss. The title run is `[^:*]*` (no
* colon, no `*`) so a genuinely-malformed bullet with a colon in the pre-separator run
* (e.g. `D-07 ratio 3:1:**`) still fails the anchor and falls through to the parse-miss
* 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*(.*)$/;
interface ParseDecisionLinesResult {
decisions: Decision[];
parseMisses: number;
}
/**
* Parse decision lines from a block of text (the inner text of a <decisions>
* or markdown-header section body). Returns the extracted decisions and a count
* of parse-misses (lines that looked like D-NN bullets but failed both regexes).
*
* FIX B (#1365): parseMisses > 0 means the caller must treat the result as
* could-not-parse even when some decisions were extracted — a silent drop is
* worse than a fail-loud signal.
*/
function parseDecisionLines(block: string): ParseDecisionLinesResult {
const lines = block.split(/\r?\n/);
const out: Decision[] = [];
let category = '';
let inDiscretion = false;
let current: Decision | null = null;
let parseMisses = 0;
const flush = (): void => {
if (current) {
current.text = current.text.trim();
out.push(current);
current = null;
}
};
for (const line of lines) {
const trimmed = line.trim();
// Track category headings (`### Heading`)
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
if (headingMatch) {
flush();
category = headingMatch[1];
// Strip the full unicode-quote family so any rendering of "Claude's
// Discretion" (ASCII apostrophe, curly U+2019 ’, U+2018 ‘,
// U+201A, U+201B, double-quote variants U+201C/D/E/F, etc.) collapses
// to the same key (FIX C + review F20).
const normalized = category
.toLowerCase()
.replace(/[‘’‚‛“”„‟''"`]/g, '')
.trim();
inDiscretion = DISCRETION_HEADINGS.has(normalized);
continue;
}
// Colon form: `- **D-NN[ [tags]]:** text`
const colonMatch = line.match(bulletColonRe);
if (colonMatch) {
flush();
const id = `D-${colonMatch[1]}`;
const tags = colonMatch[2]
? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
current = { id, text: colonMatch[3], category, tags, trackable };
continue;
}
// Em-dash form: `- **D-NN[ [tags]] — title** body`
const emDashMatch = line.match(bulletEmDashRe);
if (emDashMatch) {
flush();
const id = `D-${emDashMatch[1]}`;
const tags = emDashMatch[2]
? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
// The body (emDashMatch[3]) may be empty for the pure title form; the
// title itself is embedded in the bold run but we report the body as text
// (consistent with how the gate cares only about coverage, not title/body split).
current = { id, text: emDashMatch[3] || '', category, tags, trackable };
continue;
}
// Titled-colon form: `- **D-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 tags = titledColonMatch[2]
? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
current = { id, text: titledColonMatch[3] || '', category, tags, trackable };
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.
// parseMisses > 0 forces could-not-parse even when other decisions parsed.
if (/^\s*-\s+\*\*D-/.test(line)) {
flush();
parseMisses += 1;
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
continue;
}
// Continuation line for current decision (indented with space OR tab,
// non-bullet, non-empty) — tab indentation must work too (review F12).
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
current.text += ' ' + trimmed;
continue;
}
// Blank line or unrelated content terminates the current decision
if (trimmed === '') {
flush();
}
}
flush();
return { decisions: out, parseMisses };
}
// ─── Primary entry point: extractDecisions ────────────────────────────────────
/**
* Extract decisions from CONTEXT.md content with a typed outcome.
*
* Strategy (in priority order):
* 1. If the content (fence-stripped) contains `<decisions>...</decisions>` blocks,
* parse ONLY those blocks (canonical form; markdown-header content outside blocks
* is ignored when a block is present — existing behavior preserved).
* 2. Otherwise, look for a /decisions?/i heading and collect its section body.
* This is the T1 recall fix for #1364.
* 3. If neither is found, return outcome based on decision-shape heuristics.
*/
export function extractDecisions(content: unknown): DecisionExtraction {
if (!content || typeof content !== 'string') {
return { decisions: [], outcome: 'none-present' };
}
// Apply fence-stripping for block extraction (prevents example blocks inside
// ``` fences from polluting the parser — review F11).
const { text: stripped, unterminatedFence } = stripFencedCode(content);
// ── Path 1: <decisions> blocks present ──────────────────────────────────────
const taggedBlocks = extractTaggedBlocks(stripped, 'decisions');
if (taggedBlocks.length > 0) {
const combined = taggedBlocks.join('\n\n');
const { decisions, parseMisses } = parseDecisionLines(combined);
if (decisions.length > 0 && parseMisses === 0) {
return { decisions, outcome: 'parsed' };
}
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
if (parseMisses > 0) {
return { decisions, outcome: 'could-not-parse' };
}
// 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 \bD- token 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);
if (hasDecisionTokenInBlock || unterminatedFence) {
return { decisions: [], outcome: 'could-not-parse' };
}
return { decisions: [], outcome: 'none-present' };
}
// ── Path 2: markdown-header fallback (#1364 fix) ─────────────────────────────
// Use the seam's collectSection to find a /decisions?/i heading section.
// levelBounded:true → stop at next same-or-higher-level heading.
// stripFences:true → inner fences inside the section body are stripped.
const section = collectSection(
content,
(h) => /decisions?\b/i.test(h.text),
{ levelBounded: true, stripFences: true },
);
if (section !== null) {
const { decisions, parseMisses } = parseDecisionLines(section.body);
if (decisions.length > 0 && parseMisses === 0) {
return { decisions, outcome: 'parsed' };
}
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
if (parseMisses > 0) {
return { decisions, outcome: 'could-not-parse' };
}
// FIX A: Heading found but 0 extracted and no parse-misses.
// Only report could-not-parse when the section body contains a D- token.
// A heading with only prose, sub-headings, or all-discretion content
// (no trackable D- tokens) is a legitimate empty/discretion section → none-present.
const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
if (hasDecisionTokenInSection) {
return { decisions: [], outcome: 'could-not-parse' };
}
return { decisions: [], outcome: 'none-present' };
}
// ── 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);
if (unterminatedFence || hasDecisionToken) {
return { decisions: [], outcome: 'could-not-parse' };
}
return { decisions: [], outcome: 'none-present' };
}
// ─── parseDecisions: thin delegate (backwards-compatible entry point) ─────────
/**
* Parse trackable decisions from CONTEXT.md content.
*
* Thin delegate over extractDecisions — callers receive the decisions array
* exactly as before; nothing breaks. Use extractDecisions directly when the
* outcome enum is needed (e.g. for the fail-loud gate logic).
*
* Returns ALL D-NN decisions found (including non-trackable ones, with
* `trackable: false`). Callers that only want the gate-enforced decisions
* should filter `.filter(d => d.trackable)`.
*/
export function parseDecisions(content: unknown): Decision[] {
return extractDecisions(content).decisions;
}