"use strict"; /** * Shared parser for CONTEXT.md 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.parseDecisions = parseDecisions; const DISCRETION_HEADINGS = new Set([ "claude's discretion", 'claudes discretion', 'claude discretion', ]); const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']); /** * Strip fenced code blocks from `content` so example `` snippets * inside ```` ``` ```` do not pollute the parser (review F11). */ function stripFencedCode(content) { return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' '); } /** * Extract the inner text of EVERY `...` block in * order, concatenated by `\n\n`. Returns null when no block is present. * * CONTEXT.md may legitimately contain more than one block (for example, a * "current decisions" block plus a "carry-over from prior phase" block); * dropping all-but-the-first silently lost the second batch (review F13). */ function extractDecisionsBlock(content) { const cleaned = stripFencedCode(content); const matches = [...cleaned.matchAll(/([\s\S]*?)<\/decisions>/g)]; if (matches.length === 0) return null; return matches.map((m) => m[1]).join('\n\n'); } /** * Parse trackable decisions from CONTEXT.md content. * * Returns ALL D-NN decisions found inside `` (including * non-trackable ones, with `trackable: false`). Callers that only want the * gate-enforced decisions should filter `.filter(d => d.trackable)`. */ function parseDecisions(content) { if (!content || typeof content !== 'string') return []; const block = extractDecisionsBlock(content); if (block === null) return []; const lines = block.split(/\r?\n/); const out = []; let category = ''; let inDiscretion = false; // Bullet line: `- **D-NN[ [tags]]:** text` // Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR) // in addition to numeric-only IDs (D-42). The first character after `D-` must // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected. // CJS callers consume {id, text} and ignore the optional extras. // #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run // such as `(parenthetical)`, an em-dash, or other prose between the optional // bracket-tag group and the closing `:**` is tolerated rather than silently // dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops // correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are // unchanged. const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/; let current = null; const flush = () => { 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 // (review F20). const normalized = category .toLowerCase() .replace(/[‘’‚‛“”„‟'"`]/g, '') .trim(); inDiscretion = DISCRETION_HEADINGS.has(normalized); continue; } const bulletMatch = line.match(bulletRe); if (bulletMatch) { flush(); const id = `D-${bulletMatch[1]}`; const tags = bulletMatch[2] ? bulletMatch[2] .split(',') .map((t) => t.trim().toLowerCase()) .filter(Boolean) : []; const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t)); current = { id, text: bulletMatch[3], category, tags, trackable }; continue; } // Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet // but failed `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT // be silently dropped — a narrowed trackable set lets a blocking coverage gate // report a false pass. Surface it loudly instead. if (/^\s*-\s+\*\*D-/.test(line)) { // A malformed D-bullet still starts a (failed) new decision, so it ends the // previous one — flush before warning so a following continuation line cannot // be mis-appended to the prior valid decision. flush(); 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 out; }