* feat(#3414): promote git-cmd.js token-walk into a shared scanner, fix #3169 Phase 3 of epic #3212 (ADR-3212 §4). New src/token-scanner.cts generalizes hooks/lib/git-cmd.js's proven token-walk (#3129 — "has not re-opened"): tokenizeShellLike (quote-aware shell tokenizer, byte-identical port) and indentWidth (bullet-nesting depth). git-cmd.js migrates onto tokenizeShellLike with zero behavior change (parity-asserted against every existing #3129 fixture in tests/worktree-safety.test.cjs's folded block); isGitSubcommand's phases 1-3 (env-prefix skip, executable check, global-option consume) extracted into skipToSubcommand, shared with the new extractBranchArgument (git checkout -b / git branch <name>) — a new capability exercising the seam on the domain the ADR names, not a migration of existing duplicated logic (none existed). Fixes #3169: src/decisions.cts's parseDecisionLines couldn't distinguish a cross-reference bullet nested under an open decision from a fresh malformed declaration attempt. An earlier bold-run-content-classification design was tried and disproven against the repo's own existing FIX-B fixtures (D-02, "no colon no dash") before being adopted — both have identical shape under any content-only rule. Nesting depth (via indentWidth) is the actual distinguishing signal: a bullet indented deeper than the currently-open decision's own bullet is elaboration, folded into its text like a continuation line, never tested against the parse-miss guard. A bullet at the same-or-shallower indent is unchanged. Scope-narrowing disclosed, not silent: of the ADR's four named bugs (#3197, #3169, #2570, #2528), three no longer need this phase's work. were independently fixed and closed since the ADR was authored — #2570's fix is already a correctly-bounded regex per the ADR's own decidability test (no scanner needed); #2528's fix is a deliberate, twice-reviewed non-scanner design (its own code comment records a scanner-based attempt that regressed a symmetric case and was reverted) that this phase does not disturb. Only #3169 required new work. get_impact: isGitSubcommand CRITICAL/196 affected symbols, parseDecisionLines CRITICAL/164 affected symbols (ADR §6 due diligence). Six-gate ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary. Design: .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md Test matrix: .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3414): add required fast-check property tests per code review TESTING-STANDARDS.md:169 requires at least one fast-check property test for any module that implements parsing — src/token-scanner.cts had none, an orthogonal Standards-axis review finding. Adds two seeded property tests (mirroring Phase 1/2's fast-check-setup.cjs convention): indentWidth counts exactly a generated leading-space run; tokenizeShellLike round-trips a generated array of whitespace/quote-free words joined with single spaces. The design doc's own "no property test needed" rationale was wrong — it argued no algebraic law applied, but the standard is unconditional for parsing modules regardless of whether one "feels" applicable. Corrected in .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md. Also fixes two Spec-axis wording drifts the same review found between the design doc and the shipped code (doc-only, no behavior change): extractBranchArgument's documented signature dropped an unused subVariants parameter that was never implemented, and the #3169 fail-first fixture description corrected from "15-decision plan via cmdDecisionCoverageVerify" to the actual compact 3-decision analog via the real blocking gate, check.decision-coverage-plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3414): add changeset for #3169 fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3414): backfill changeset pr number to 3424 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
362 lines
16 KiB
TypeScript
362 lines
16 KiB
TypeScript
/**
|
||
* 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';
|
||
import { indentWidth } from './token-scanner.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*(.*)$/;
|
||
|
||
/**
|
||
* #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
|
||
* `none-present` pass.
|
||
*
|
||
* The ID-shape requirement (not "any bold bullet") is deliberate: a decisions
|
||
* block or `### Claude's Discretion` sub-section legitimately contains prose
|
||
* 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
|
||
* case) / `TODO:` (no `-<alnum>` id) — mirroring the parser's own `D-<alnum>`
|
||
* shape without hardcoding the `D`.
|
||
*/
|
||
const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
|
||
|
||
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 openIndent: number | null = null;
|
||
let parseMisses = 0;
|
||
|
||
const flush = (): void => {
|
||
if (current) {
|
||
current.text = current.text.trim();
|
||
out.push(current);
|
||
current = null;
|
||
openIndent = 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;
|
||
}
|
||
|
||
// Nested bullet under an open decision (#3212 Phase 3, #3169): a bullet
|
||
// indented deeper than the currently-open decision's own bullet is that
|
||
// decision's elaboration (e.g. a cross-reference to a sibling decision),
|
||
// not a fresh declaration attempt — fold it into current.text exactly
|
||
// like a continuation line, before it ever reaches the declaration/
|
||
// parse-miss regexes below. A bullet at the SAME or a SHALLOWER indent
|
||
// is unaffected — tested exactly as before this fix. See design doc
|
||
// .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md §1.3 for why
|
||
// nesting depth, not bullet content, is the signal that distinguishes
|
||
// this from a genuinely malformed top-level declaration.
|
||
if (
|
||
current &&
|
||
openIndent !== null &&
|
||
trimmed.startsWith('-') &&
|
||
indentWidth(line) > openIndent
|
||
) {
|
||
current.text += ' ' + trimmed;
|
||
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 };
|
||
openIndent = indentWidth(line);
|
||
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 };
|
||
openIndent = indentWidth(line);
|
||
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 };
|
||
openIndent = indentWidth(line);
|
||
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 bold-lead-in bullet (`- **…**`, any ID grammar — #2347),
|
||
// 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);
|
||
const hasBoldLeadInBullet = boldLeadInBulletRe.test(combined);
|
||
if (hasDecisionTokenInBlock || hasBoldLeadInBullet || 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.
|
||
// 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
|
||
// 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 hasBoldLeadInBulletInSection = boldLeadInBulletRe.test(section.body);
|
||
if (hasDecisionTokenInSection || hasBoldLeadInBulletInSection) {
|
||
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;
|
||
}
|