diff --git a/.changeset/jolly-otters-click.md b/.changeset/jolly-otters-click.md new file mode 100644 index 000000000..3c08138ff --- /dev/null +++ b/.changeset/jolly-otters-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4374 +--- +**parseDecisions no longer backtracks quadratically on pathological single bullets** — output unchanged on all legal inputs. (#4130) diff --git a/.changeset/sharp-deer-hop.md b/.changeset/sharp-deer-hop.md new file mode 100644 index 000000000..300a678e2 --- /dev/null +++ b/.changeset/sharp-deer-hop.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4374 +--- +**check decision-coverage-plan accepts --context ** — same convention as sibling check verbs. (#4130) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dcd8d14bb..02e7f5700 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1398,6 +1398,31 @@ overall verification status. The asymmetry is deliberate — by verify time the work is done, and a fuzzy substring miss should not fail an otherwise green phase. +### Invoking the plan gate directly + +The plan-phase translation gate is runnable standalone (the same form the +workflow's gate dispatch uses): + +```bash +gsd_run check decision-coverage-plan +``` + +The context path may also be supplied with the `--context` flag, following +the same convention as the other flag-taking check verbs (e.g. +`check predicate`): + +```bash +gsd_run check decision-coverage-plan --context +gsd_run check decision-coverage-plan --context [] +``` + +The flag wins when both a positional context path and `--context` are given; +the positional form keeps working unchanged. A `--context` with no value is +a caller error — the gate fails closed with the missing-argument error, the +same as calling it with no context path at all, rather than silently +skipping. The phase directory remains a separate positional argument (there +is no `--phase` flag; the workflow caller passes both positionals). + ### How to write decisions the gates accept The discuss-phase template already produces `D-NN`-numbered decisions. diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 380113fc4..9a6508996 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -289,9 +289,34 @@ function loadDecisionExtraction(contextPath: string): { trackable: Decision[]; o }; } +/** + * `check decision-coverage-plan` — blocking plan-phase decision-coverage gate + * (#2492, #1365 fail-loud, #2770 empty-arg fail-closed). + * + * Invocation (the context path may be supplied EITHER way; #4130 follow-up): + * gsd_run check decision-coverage-plan (positional, the workflow caller's form) + * gsd_run check decision-coverage-plan --context [] + * + * `--context ` follows the sibling flag convention (`check predicate`, + * #2008): `--flag value` pairs parsed by the shared partitionPredicateArgs + * pass, the flag WINNING over a same-purpose positional when both appear, + * and a valueless `--context` counting as no context at all (it falls + * through to the #2770 caller-error branch, not to the "CONTEXT.md missing" + * green skip). The positional form keeps working unchanged — no sibling + * check verb deprecates positionals and the plan-phase workflow passes them. + */ function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolean): void { - const phaseDir = args[2] ? resolvePath(args[2], projectDir) : ''; - const contextArg = args[3]; + // args[0]='check', args[1]=subcommand — partition the REST so flag tokens + // and their values never land in a positional slot. + const { flags, positionals } = partitionPredicateArgs(args.slice(2)); + const phaseDir = positionals[0] ? resolvePath(positionals[0], projectDir) : ''; + // A VALUELESS `--context` stays a bare token in the positionals (sibling + // parser semantics); it must not then be read as the context PATH — a + // `--`-prefixed "path" is a caller mistake, and #2770's law says a missing + // context argument fails CLOSED, never a silent "CONTEXT.md missing" green + // skip. So only a non-flag positional may serve as the context. + const positionalContext = positionals[1] && !positionals[1].startsWith('--') ? positionals[1] : ''; + const contextArg = flags['context'] ?? positionalContext ?? ''; const contextPath = contextArg ? resolvePath(contextArg, projectDir) : ''; if (!gateEnabled(projectDir)) { @@ -1268,21 +1293,41 @@ function buildPredicateDeps() { }; } -/** Parse `--flag value` pairs from an args array into a map (last write wins). */ -function parsePredicateFlags(args: string[]): Record { - const out: Record = {}; +/** + * Split an args array into `--flag value` pairs and the leftover positional + * tokens, in ONE pass, with the semantics `check predicate` established + * (#2008): a `--flag` followed by a non-`--` token consumes it as the value + * (last write wins); a `--flag` with no value stays a bare token and moves to + * the positionals; everything else is positional. `parsePredicateFlags` is + * the flags half of this same pass — there is exactly one parser, so the + * flag-taking check verbs cannot drift apart (#4130 follow-up: `check + * decision-coverage-plan --context ` shares it). + */ +function partitionPredicateArgs(args: string[]): { flags: Record; positionals: string[] } { + const flags: Record = {}; + const positionals: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (typeof a !== 'string') continue; - if (!a.startsWith('--')) continue; + if (!a.startsWith('--')) { + positionals.push(a); + continue; + } const key = a.slice(2); const next = args[i + 1]; if (key.length > 0 && typeof next === 'string' && !next.startsWith('--')) { - out[key] = next; + flags[key] = next; i++; + } else { + positionals.push(a); } } - return out; + return { flags, positionals }; +} + +/** Parse `--flag value` pairs from an args array into a map (last write wins). */ +function parsePredicateFlags(args: string[]): Record { + return partitionPredicateArgs(args).flags; } /** @@ -1821,7 +1866,9 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { // this for any gate whose `check` carries a `predicate` (instead of a `query`), // passing the predicate object as --predicate ''. NOTE: unlike the // `check.query` subcommands above (which take positional phase args), this - // subcommand parses --flag value pairs. + // subcommand is flag-driven. `decision-coverage-plan` above now ALSO accepts + // `--context ` (its positionals still work) — both share + // partitionPredicateArgs, the one flag parser. cmdCheckPredicate(cwd, args, raw); return; } @@ -1850,6 +1897,7 @@ export = { cmdCheckPredicate, buildPredicateDeps, parsePredicateFlags, + partitionPredicateArgs, // Fail-closed phase-scope reader for the api-coverage gate — exported for // in-process failure-injection tests (#2365 review). readPhaseScope, diff --git a/src/decisions.cts b/src/decisions.cts index 9688a914c..b277e08d8 100644 --- a/src/decisions.cts +++ b/src/decisions.cts @@ -17,6 +17,11 @@ * - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path) * * Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate). + * + * #4130 follow-up (hardening): the three bullet grammars below consume the + * decision ID atomically and narrow the em-dash first separator, eliminating + * the quadratic-backtracking cliff on pathological single bullets. Output is + * byte-identical on all legal inputs — see the notes at DECISION_ID_SOURCE. */ import { @@ -74,6 +79,28 @@ const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']); */ const DECISION_ID_SOURCE = 'D[0-9]*-[A-Za-z0-9][A-Za-z0-9_-]*'; +/** + * #4130 follow-up (hardening): how the three grammars below CONSUME the ID — + * atomically, via the `(?=(X))\1` lookahead emulation (lookarounds are atomic + * in ECMAScript; the backreference must replay exactly what the lookahead + * captured, so the engine can never give the ID tail back one character at a + * time). That give-back was quadratic driver #1: the tail class + * `[A-Za-z0-9_-]*` overlaps the pre-separator class `[^:*]*` (every id char + * is also `[^:*]`), so on a FAILING bullet the base regex re-split the tail + * O(n) times with an O(n) scan after each — measured ~1.1s @ 40k chars on + * `- **D-` + `a-`×20k (the #4357 review's deferred cliff). + * + * Byte-identical on all legal inputs: a successful match always consumes the + * MAXIMAL id run (the lookahead's own match is exactly that maximal run), and + * the continuation's success depends only on the position of the first + * `:`/`*` (or `*` for the em-dash form) after the id boundary — id chars + * contain neither, so moving the boundary inside the run cannot change + * success or any capture. Group 1 stays the full id (the lookahead's capture + * IS group 1), so handlers keep reading match[1]/[2]/[3] untouched. Pinned by + * the differential property test against a frozen copy of the pre-hardening + * grammars and by the regex-lattice test in tests/decisions.test.cjs. + */ + /** * #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 @@ -90,9 +117,13 @@ 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). + * The ID is consumed atomically `(?=(…))\1` — see the hardening note above + * the constants (#4130 follow-up); with the tail unable to give back, the + * remaining `[^:*]*:` scan has a single viable split and the whole match is + * linear in line length. */ const bulletColonRe = new RegExp( - `^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`, ); /** @@ -102,9 +133,20 @@ const bulletColonRe = new RegExp( * 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. + * + * #4130 follow-up (hardening), quadratic driver #2: the first separator was + * `[^*]*[—–]`, whose leading class ALSO accepts the dash — on a failing + * dash-laden title the engine retried the separator at every dash position + * with an O(n) scan after each (~1.7s @ 40k). Narrowed to `[^*—–]*[—–]`: + * the leading class now excludes the dash, so the separator is the FIRST + * dash — one viable split, single pass. Behavior-preserving because every + * candidate dash lies before the first `*` (the leading class cannot cross + * a star), so the trailing `[^*]*` reaches that same first star from any + * candidate and `**` succeeds or fails identically; no capture involves the + * dash position. The ID is atomic like the other forms (driver #1). */ const bulletEmDashRe = new RegExp( - `^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^*]*[—–][^*]*\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^*—–]*[—–][^*]*\\*\\*\\s*(.*)$`, ); /** @@ -117,9 +159,12 @@ const bulletEmDashRe = new RegExp( * (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) + * + * The ID is consumed atomically `(?=(…))\1` like the other forms — the + * hardening note above the constants explains why (#4130 follow-up). */ const bulletTitledColonRe = new RegExp( - `^\\s*-\\s+\\*\\*(${DECISION_ID_SOURCE})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`, ); /** diff --git a/tests/check-predicate.test.cjs b/tests/check-predicate.test.cjs index ea9658546..f35d7bec0 100644 --- a/tests/check-predicate.test.cjs +++ b/tests/check-predicate.test.cjs @@ -104,3 +104,52 @@ describe('parsePredicateFlags', () => { assert.deepEqual(parsePredicateFlags([]), {}); }); }); + +// ─── #4130 follow-up: partitionPredicateArgs (flags + positionals, one parser) ─ + +/** + * `partitionPredicateArgs` is the single pass behind `parsePredicateFlags`: + * it returns BOTH the --flag value map AND the non-consumed positional tokens + * under the exact same skip/consume/last-wins semantics. `check + * decision-coverage-plan --context ` uses it so the flag and the + * positional surface share one parser with `check predicate` — the two + * parsers cannot diverge because there is only one. + */ +describe('partitionPredicateArgs (#4130 follow-up)', () => { + const { partitionPredicateArgs } = require('../gsd-core/bin/lib/check-command-router.cjs'); + + test('splits --flag value pairs from positionals', () => { + const { flags, positionals } = partitionPredicateArgs( + ['check', 'decision-coverage-plan', '--context', '/tmp/CONTEXT.md', 'phases/01-init'], + ); + assert.deepEqual(flags, { context: '/tmp/CONTEXT.md' }); + assert.deepEqual(positionals, ['check', 'decision-coverage-plan', 'phases/01-init']); + }); + + test('parsePredicateFlags is exactly the flags half (one source of truth)', () => { + const vectors = [ + ['check', 'predicate', '--predicate', '{"kind":"x"}', '--phase-number', '03', '--raw'], + ['--phase-number', '01', '--phase-number', '02'], + ['--predicate', '--phase-number'], + [], + ['--context'], + ['a', '--context', 'b', '--context', 'c', 'd'], + ]; + for (const v of vectors) { + assert.deepEqual(partitionPredicateArgs(v).flags, parsePredicateFlags(v), + `flags half must equal parsePredicateFlags for ${JSON.stringify(v)}`); + } + }); + + test('value that starts with -- is not consumed: both stay flags, neither becomes positional', () => { + const { flags, positionals } = partitionPredicateArgs(['--context', '--other']); + assert.deepEqual(flags, {}); + assert.deepEqual(positionals, ['--context', '--other']); + }); + + test('last write wins; flag values never leak into positionals', () => { + const { flags, positionals } = partitionPredicateArgs(['p1', '--context', 'a', 'p2', '--context', 'b', 'p3']); + assert.equal(flags.context, 'b'); + assert.deepEqual(positionals, ['p1', 'p2', 'p3']); + }); +}); diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index d9219791b..0d07f18bd 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -2522,3 +2522,522 @@ describe('check.decision-coverage-verify — phase-prefixed decisions are readab `A plan mentioning D4-01 honors it. Got: ${JSON.stringify(parsed)}`); }); }); + +// ─── #4130 follow-up: check decision-coverage-plan --context ────────── + +/** + * Row-1 failing-first regression for the --context flag (maintainer-directed + * follow-up to #4130, merged as #4357). + * + * Convention mirrored from the ONE flag-driven sibling check verb + * (`check predicate`, src/check-command-router.cts): `--flag value` pairs + * parsed with parsePredicateFlags semantics, `--context ` supplying the + * CONTEXT.md path, the flag WINNING over a same-purpose positional, and the + * positional form kept working (no sibling deprecates positionals; the + * plan-phase workflow caller passes positionals). + * + * Before the fix (probed on the base build): + * - `--context ` alone landed `--context` in the args[2] phase slot → + * plans scanned in a nonexistent `/--context` dir → every + * decision falsely uncovered (passed:false where the positional form + * passes). + * - ` --context ` put the literal `--context` in the context + * slot → silent "CONTEXT.md missing" green skip. + */ +describe('check.decision-coverage-plan — --context flag matches the positional form (#4130 follow-up)', () => { + let tmpDir; + let planningDir; + let phaseDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-4130fu-'); + planningDir = path.join(tmpDir, '.planning'); + phaseDir = path.join(planningDir, 'phases', '01-init'); + fs.mkdirSync(phaseDir, { recursive: true }); + }); + + afterEach(() => cleanup(tmpDir)); + + /** Invoke the gate with a raw arg vector (after the subcommand). */ + const runDcp = (args) => runGsdTools(['query', 'check.decision-coverage-plan', ...args], tmpDir); + + const coveredContext = () => [ + '# Phase 4 Context', + '', + '', + '- **D4-01:** use the phase-scoped datastore', + '', + '', + ].join('\n'); + + const coveredPlan = () => '# Plan\n## Must Haves\n- D4-01: provision the datastore\n'; + + test('--context alone routes the path into the context slot (no stray flag token anywhere)', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + // The phase dir is a SEPARATE positional; `--context`-only means no phase + // was given, which routes exactly like an empty phase positional. The + // assertion that matters: the flag's VALUE reaches the gate (the decision + // is counted and reported uncovered — parsed from the flag's path), and + // the output is identical to the equivalent positional invocation. + const viaFlag = JSON.parse(runDcp(['--context', contextPath]).output || '{}'); + const viaEmptyPhasePositional = JSON.parse(runDcp(['', contextPath]).output || '{}'); + + assert.deepStrictEqual(viaFlag, viaEmptyPhasePositional, + `--context-only must route like the empty-phase positional.\nflag: ${JSON.stringify(viaFlag)}\npos: ${JSON.stringify(viaEmptyPhasePositional)}`); + assert.strictEqual(viaFlag.total, 1, + `the decision must be read from the flag's path. Got: ${JSON.stringify(viaFlag)}`); + assert.strictEqual(viaFlag.passed, false, 'no phase → no plans scanned → coverage gap, not a parse accident'); + assert.deepStrictEqual((viaFlag.uncovered || []).map((u) => u.id), ['D4-01']); + }); + + test('ROW-1 RED: --context composes (flag value reaches the gate, not the phase slot)', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + const viaFlag = JSON.parse(runDcp([phaseDir, '--context', contextPath]).output || '{}'); + const viaPositional = JSON.parse(runDcp([phaseDir, contextPath]).output || '{}'); + + // Before the fix: the literal `--context` was taken as the context path → + // silent "CONTEXT.md missing" green skip. + assert.deepStrictEqual(viaFlag, viaPositional, + `phase + --context must compose.\nflag: ${JSON.stringify(viaFlag)}\npos: ${JSON.stringify(viaPositional)}`); + assert.strictEqual(viaFlag.passed, true); + }); + + test('ROW-1 RED: --context (flag first) is order-independent', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + const viaFlagFirst = JSON.parse(runDcp(['--context', contextPath, phaseDir]).output || '{}'); + const viaPositional = JSON.parse(runDcp([phaseDir, contextPath]).output || '{}'); + assert.deepStrictEqual(viaFlagFirst, viaPositional); + assert.strictEqual(viaFlagFirst.passed, true); + assert.strictEqual(viaFlagFirst.covered, 1); + }); + + test('ROW-1 RED: --context wins when both flag and positional context are supplied', () => { + // Flag file: one covered decision (passed:true, total:1). + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const flagContext = path.join(phaseDir, 'CONTEXT.md'); + // Positional decoy: a none-present file (would skip with total:0). + const decoyContext = path.join(phaseDir, 'DECOY-CONTEXT.md'); + fs.writeFileSync(decoyContext, '# Nothing decision-shaped here.\n'); + + // Hold the PHASE constant so the comparison isolates WHICH context file + // was read: decoy-positional + flag must equal flag-alone-with-phase. + const viaBoth = JSON.parse(runDcp([phaseDir, decoyContext, '--context', flagContext]).output || '{}'); + const viaFlag = JSON.parse(runDcp([phaseDir, '--context', flagContext]).output || '{}'); + + assert.deepStrictEqual(viaBoth, viaFlag, + `--context must win over the positional context.\nboth: ${JSON.stringify(viaBoth)}\nflag: ${JSON.stringify(viaFlag)}`); + assert.strictEqual(viaBoth.passed, true, 'the flag file (covered decision) must be the one read'); + assert.strictEqual(viaBoth.total, 1); + }); + + test('uncovered decision via --context reports the coverage gap (no false pass, no false fail)', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- Something unrelated.\n'); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + const viaFlag = JSON.parse(runDcp(['--context', contextPath]).output || '{}'); + const viaPositional = JSON.parse(runDcp([phaseDir, contextPath]).output || '{}'); + assert.deepStrictEqual(viaFlag, viaPositional); + assert.strictEqual(viaFlag.passed, false); + assert.deepStrictEqual((viaFlag.uncovered || []).map((u) => u.id), ['D4-01']); + }); + + test('could-not-parse CONTEXT via --context fails loud exactly like the positional form', () => { + writeContextFile(phaseDir, [ + '', + '- **DEC-01:** an ID grammar the parser does not support', + '', + '', + ].join('\n')); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + const viaFlag = JSON.parse(runDcp(['--context', contextPath]).output || '{}'); + const viaPositional = JSON.parse(runDcp([phaseDir, contextPath]).output || '{}'); + assert.deepStrictEqual(viaFlag, viaPositional); + assert.strictEqual(viaFlag.passed, false); + assert.strictEqual(viaFlag.reason, 'could-not-parse'); + }); + + test('back-compat: the positional form is byte-identical to a no-flag run (control row)', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + const a = runDcp([phaseDir, contextPath]).output; + const b = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir).output; + assert.strictEqual(a, b); + assert.strictEqual(JSON.parse(a || '{}').passed, true); + }); + + test('--context keeps the legitimate green skip', () => { + const missing = path.join(phaseDir, 'NOPE-CONTEXT.md'); + const viaFlag = JSON.parse(runDcp(['--context', missing]).output || '{}'); + const viaPositional = JSON.parse(runDcp([phaseDir, missing]).output || '{}'); + assert.deepStrictEqual(viaFlag, viaPositional); + assert.strictEqual(viaFlag.passed, true); + assert.strictEqual(viaFlag.skipped, true); + assert.strictEqual(viaFlag.reason, 'CONTEXT.md missing'); + }); + + test('valueless trailing --context falls through to the #2770 fail-closed caller error', () => { + // Mirrors the sibling parser (parsePredicateFlags): a `--flag` with no + // value is a boolean, never a path. The caller supplied no context path, + // which #2770 treats as a caller error — NOT as "no CONTEXT.md". + const viaFlag = JSON.parse(runDcp([phaseDir, '--context']).output || '{}'); + assert.strictEqual(viaFlag.passed, false, + `A valueless --context is a caller error, not a green skip. Got: ${JSON.stringify(viaFlag)}`); + assert.strictEqual(viaFlag.reason, 'missing context path argument'); + }); + + test('negative space: decision-coverage-verify keeps its positional arg surface (flag is plan-only)', () => { + writeContextFile(phaseDir, coveredContext()); + writePlanFile(phaseDir, '01', coveredPlan()); + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + + // The verify gate is out of scope by directive: its positional contract + // is unchanged, and it does not gain --context handling. + const verify = JSON.parse(runGsdTools(['query', 'check.decision-coverage-verify', phaseDir, contextPath], tmpDir).output || '{}'); + assert.strictEqual(verify.total, 1); + assert.strictEqual(verify.honored, 1); + + const verifyFlagForm = JSON.parse(runGsdTools(['query', 'check.decision-coverage-verify', phaseDir, '--context', contextPath], tmpDir).output || '{}'); + assert.strictEqual(verifyFlagForm.skipped, true, + `verify must keep reading args[3] positionally (unchanged base behavior). Got: ${JSON.stringify(verifyFlagForm)}`); + assert.strictEqual(verifyFlagForm.reason, 'CONTEXT.md missing'); + }); +}); + +// ─── #4130 follow-up: parseDecisions regex hardening (quadratic backtracking) ─ + +/** + * Row-1 failing-first regression for the regex-seam hardening. + * + * The #4357 security review measured ~740ms @ 40k chars on pathological + * single bullets and deferred the fix here. Mechanism (10-diagnosis.md): + * (1) the ID tail `[A-Za-z0-9][A-Za-z0-9_-]*` overlaps the pre-separator + * class `[^:*]*`, so a failing match re-splits the tail O(n) times with + * an O(n) scan each — O(n²); + * (2) the em-dash form's `[^*]*[—–]` first separator can retry at every dash + * position with an O(n) scan after each — O(n²). + * + * Hardening under test (byte-identical on all legal inputs): + * - atomic ID via the `(?=(X))\1` lookahead emulation (group 1 unchanged); + * - em-dash first separator narrowed to `[^*—–]*[—–]` (FIRST dash, unique + * split point). + * + * Repo rule: no wall-time asserts. Node's RegExp engine exposes no injectable + * step counter, so no honest deterministic op-count proxy exists (documented + * in 10-diagnosis.md); the pin is structural (lattice), differential (vs the + * frozen pre-hardening reference below), and correctness-at-scale. + */ +describe('parseDecisions hardening — regex lattice pins the mechanism (#4130 follow-up)', () => { + const SRC = path.resolve(__dirname, '../src/decisions.cts'); + + /** Extract a `const NAME = '';` single-quoted string literal. */ + function readStringConst(source, name) { + const m = source.match(new RegExp(`^const ${name} = '([^']*)';$`, 'm')); + assert.ok(m, `source must declare const ${name} as a plain string literal`); + return m[1]; + } + + /** + * Extract a `new RegExp(`...`)` template body, substitute ${CONSTS}, and + * unescape the template-literal double backslashes — the result is the + * exact regex SOURCE STRING the module compiles. + */ + function readRegExpTemplate(source, varName, consts) { + const m = source.match(new RegExp(`^const ${varName} = new RegExp\\(\n \`([^\`]+)\`,?\n?\\);?`, 'm')); + assert.ok(m, `source must declare ${varName} as a template-literal RegExp`); + let out = m[1]; + for (const [name, value] of Object.entries(consts)) { + out = out.split(`\${${name}}`).join(value); + } + assert.ok(!out.includes('$' + '{'), `unsubstituted template placeholder in ${varName}`); + return out.replace(/\\\\/g, '\\'); + } + + const source = fs.readFileSync(SRC, 'utf8'); + const idSource = readStringConst(source, 'DECISION_ID_SOURCE'); + const idAttempt = readStringConst(source, 'ID_ATTEMPT_SOURCE'); + const consts = { DECISION_ID_SOURCE: idSource, ID_ATTEMPT_SOURCE: idAttempt }; + const colonSrc = readRegExpTemplate(source, 'bulletColonRe', consts); + const emDashSrc = readRegExpTemplate(source, 'bulletEmDashRe', consts); + const titledSrc = readRegExpTemplate(source, 'bulletTitledColonRe', consts); + + test('ROW-1 RED: the ID grammar constants are unchanged (the parity pin survives hardening)', () => { + assert.strictEqual(idSource, 'D[0-9]*-[A-Za-z0-9][A-Za-z0-9_-]*'); + assert.strictEqual(idAttempt, 'D(?:[0-9][A-Za-z0-9]*)?-'); + }); + + test('ROW-1 RED: all three bullet grammars consume the ID atomically (no tail re-split)', () => { + // The (?=(X))\1 lookahead emulation is what makes the ID give-back + // impossible: lookarounds are atomic in ECMAScript, and the backreference + // must replay exactly what the lookahead captured. On the base the + // sources had `(D...-...)` bare — the quadratic driver #1. + const atomic = `(?=(${idSource}))\\1`; + for (const [name, src] of [['bulletColonRe', colonSrc], ['bulletEmDashRe', emDashSrc], ['bulletTitledColonRe', titledSrc]]) { + assert.ok(src.includes(atomic), `${name} must wrap the ID in the atomic (?=(X))\\1 emulation:\n${src}`); + } + }); + + test('ROW-1 RED: the em-dash first separator is narrowed to the FIRST dash (no dash re-split)', () => { + // `[^*]*[—–]` admits O(k) separator split points on a dash-laden title; + // `[^*—–]*[—–]` has exactly one. The narrowing is behavior-preserving + // because every candidate dash lies before the first `*` and the second + // `[^*]*` scan reaches that same first star from any candidate. + assert.ok(emDashSrc.includes('[^*—–]*[—–]'), + `bulletEmDashRe must use the narrowed first separator:\n${emDashSrc}`); + assert.ok(!emDashSrc.includes('[^*]*[—–]'), + `bulletEmDashRe must not retain the overlapping first separator:\n${emDashSrc}`); + }); + + test('lattice: no unbounded class quantifier is immediately followed by an atom its class accepts', () => { + // The adjacency that admitted both quadratic drivers: `C*` directly + // followed by an atom that can start with a char C also accepts lets the + // engine trade characters between the two — O(n) splits × O(n) rescans. + // After the hardening every unbounded bracketed class run in the three + // grammars is followed by a token disjoint from its class (or by a group + // boundary / the atomic backreference replay, which cannot re-split). + const joined = `${colonSrc}\n${emDashSrc}\n${titledSrc}`; + const quantifiers = [...joined.matchAll(/\[((?:[^\]\\]|\\.)*)\](\*|\+)/g)]; + assert.ok(quantifiers.length >= 6, 'expected the seam\'s class quantifiers to be found'); + + /** Membership predicate for a class BODY, honoring ranges and escapes. */ + function classPredicate(body) { + const negated = body.startsWith('^'); + const inner = negated ? body.slice(1) : body; + const members = new Set(); + const chars = [...inner]; + for (let i = 0; i < chars.length; i++) { + let ch = chars[i]; + if (ch === '\\' && i + 1 < chars.length) ch = chars[++i]; + // Range: a-b where a and b are single member chars. + if (chars[i + 1] === '-' && chars[i + 2] !== undefined && chars[i + 2] !== ']') { + const lo = ch; + let hi = chars[i + 2]; + if (hi === '\\' && chars[i + 3] !== undefined) { i += 3; hi = chars[i]; } else { i += 2; } + for (let c = lo.charCodeAt(0); c <= hi.charCodeAt(0); c++) members.add(String.fromCharCode(c)); + continue; + } + members.add(ch); + } + return (ch) => (negated ? !members.has(ch) : members.has(ch)); + } + + /** + * First-set of the atom that follows a quantified class, as a membership + * predicate. `null` = boundary — group close, anchor, end, or the `\1` + * backreference of the atomic wrapper (its first-set is the captured id + * run, replayed verbatim: it cannot trade characters with the quantifier, + * which is the entire point of the wrapper). + */ + function nextAtomFirstSet(src, at) { + if (at >= src.length) return null; + const c = src[at]; + if (c === ')' || c === '$' || c === '|') return null; + if (c === '\\') { + const nxt = src[at + 1]; + if (nxt >= '0' && nxt <= '9') return null; // backreference replay — skip + return (ch) => ch === nxt; + } + if (c === '[') { + const close = src.indexOf(']', at); + return classPredicate(src.slice(at + 1, close)); + } + return (ch) => ch === c; + } + + for (const m of quantifiers) { + const body = m[1]; + const quant = m[2]; + const classAccepts = classPredicate(body); + const firstSet = nextAtomFirstSet(joined, m.index + m[0].length); + if (firstSet === null) continue; + // A witness char the class accepts that the following atom also accepts. + const ALPHABET = '*:-[]()Ds01_—–\\ \tnA'; + const witness = [...ALPHABET].find((ch) => classAccepts(ch) && firstSet(ch)); + assert.ok(witness === undefined, + `unbounded quantifier [${body}]${quant} is followed by an atom accepting '${witness}' which its class also accepts — adjacency overlap:\n${joined.slice(m.index, m.index + m[0].length + 10)}`); + } + }); + + test('lattice: capture-group indices are preserved (id, tags, body)', () => { + // (?=(X))\1 keeps group 1 = the full id (the lookahead's capture IS group + // 1), so the handlers' match[1]/[2]/[3] reads stay untouched. Pin the + // count so a refactor cannot silently renumber the groups. + for (const [name, src] of [['bulletColonRe', colonSrc], ['bulletEmDashRe', emDashSrc], ['bulletTitledColonRe', titledSrc]]) { + const groups = (src.match(/\(/g) || []).length - (src.match(/\(\?:/g) || []).length - (src.match(/\(\?=/g) || []).length; + assert.strictEqual(groups, 3, `${name} must keep exactly 3 capturing groups (id/tags/body):\n${src}`); + } + }); +}); + +describe('parseDecisions hardening — byte-identical vs the pre-hardening reference (#4130 follow-up)', () => { + /** + * FROZEN REFERENCE — the three bullet grammars exactly as they shipped on + * origin/next @ e6d047decc (PR #4357). The hardened module must agree with + * this reference on match/no-match AND all capture groups for every input + * the generator can produce. If the reference and the module ever disagree, + * behavior drifted — this is the "pure hardening" contract. + */ + const REF_ID = 'D[0-9]*-[A-Za-z0-9][A-Za-z0-9_-]*'; + const refColon = new RegExp(`^\\s*-\\s+\\*\\*(${REF_ID})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`); + const refEmDash = new RegExp(`^\\s*-\\s+\\*\\*(${REF_ID})(?:\\s*\\[([^\\]]+)\\])?[^*]*[—–][^*]*\\*\\*\\s*(.*)$`); + const refTitled = new RegExp(`^\\s*-\\s+\\*\\*(${REF_ID})(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`); + const refGuard = /^\s*-\s+\*\*D(?:[0-9][A-Za-z0-9]*)?-/; + const refBoldLeadIn = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m; + const refToken = /\bD[0-9]*-[A-Za-z0-9]/m; + + /** + * The expected single-bullet outcome, computed by the frozen reference: + * the three grammars in the module's precedence order, then the parse-miss + * guard, then the FIX A evidence detectors (bold-lead-in / bare token) — + * exactly the module's single-line block-path decision order. + */ + function referenceOutcome(line) { + const m = refColon.exec(line) || refEmDash.exec(line) || refTitled.exec(line); + if (m) { + const tags = m[2] ? m[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean) : []; + return { + outcome: 'parsed', + decisions: [{ + id: m[1], + text: (m[3] || '').trim(), + category: '', + tags, + trackable: !tags.some((t) => ['informational', 'folded', 'deferred'].includes(t)), + }], + }; + } + if (refGuard.test(line)) return { outcome: 'could-not-parse', decisions: [] }; + if (refBoldLeadIn.test(line) || refToken.test(line)) return { outcome: 'could-not-parse', decisions: [] }; + return { outcome: 'none-present', decisions: [] }; + } + + // Generator: adversarial single-line bullets around the seam's alphabet. + const idArb = fc.oneof( + fc.integer({ min: 1, max: 99 }).map((n) => `D-${String(n).padStart(2, '0')}`), + fc.tuple(fc.integer({ min: 1, max: 12 }), fc.integer({ min: 1, max: 99 })) + .map(([p, n]) => `D${p}-${String(n).padStart(2, '0')}`), + fc.constantFrom('D-INFRA-01', 'D-7', 'D-carry_2', 'D4x-01', 'DEC-01', 'D-notes'), + ); + const fuzzArb = (maxWords) => fc.array( + fc.constantFrom('use', 'the:', 'a*', '**', '—', '–', '[x]', 'y]', 'ratio 3:1', '-', 'D4-01', 'x,', 'why', 'ok', '**D-99:', 'until'), + { maxLength: maxWords }, + ).map((w) => w.join(' ')); + const sepArb = fc.constantFrom(':', ' — ', ': ', ' —', ':** ', ' '); + const leadArb = fc.constantFrom('', ' ', '\t'); + + const lineArb = fc.tuple(leadArb, idArb, fc.constantFrom('', ' [informational]', ' [a, b]', ' [unterminated'), sepArb, fuzzArb(14), fuzzArb(10), fc.boolean()) + .map(([lead, id, tags, sep, mid, tail, boldClose]) => { + const close = boldClose ? '**' : ''; + return `${lead}- **${id}${tags}${sep}${mid}${close} ${tail}`; + }); + + test('property: hardened module === frozen pre-hardening reference on every generated bullet', () => { + fc.assert(fc.property(lineArb, (line) => { + const expected = referenceOutcome(line); + const got = extractDecisions(inBlock(line)); + assert.strictEqual(got.outcome, expected.outcome, + `outcome drifted on ${JSON.stringify(line)}: got ${got.outcome}, want ${expected.outcome}`); + assert.deepStrictEqual(got.decisions, expected.decisions, + `decisions drifted on ${JSON.stringify(line)}:\ngot: ${JSON.stringify(got.decisions)}\nwant: ${JSON.stringify(expected.decisions)}`); + return true; + })); + }); + + test('em-dash titles with MULTIPLE dashes capture identically to the reference (first-dash narrowing)', () => { + fc.assert(fc.property( + decisionIdArb, + fuzzArb(6), + fuzzArb(6), + (id, title, body) => { + const line = `- **${id} — ${title} — ${title}** ${body}`; + const expected = referenceOutcome(line); + const got = extractDecisions(inBlock(line)); + assert.strictEqual(got.outcome, expected.outcome); + assert.deepStrictEqual(got.decisions, expected.decisions, + `multi-dash title drifted on ${JSON.stringify(line)}`); + return true; + }, + )); + }); + + test('the #4130/#3939/#1639 fixture grammar round-trips byte-identically', () => { + const fixtures = [ + ['- **D-01:** a short single-line decision.', 'D-01', 'a short single-line decision.'], + ['- **D4-01:** phase-prefixed.', 'D4-01', 'phase-prefixed.'], + ['- **D12-01:** two-digit phase.', 'D12-01', 'two-digit phase.'], + ['- **D-INFRA-01:** alnum tail.', 'D-INFRA-01', 'alnum tail.'], + ['- **D-01 [informational]:** tagged.', 'D-01', 'tagged.'], + ['- **D4-01 — title** body here', 'D4-01', 'body here'], + ['- **D-01 — a — b — c** body', 'D-01', 'body'], + ['- **D-01: Title.** body', 'D-01', 'body'], + ['- **D-01 pre-colon prose:** text', 'D-01', 'text'], + ]; + for (const [line, wantId, wantText] of fixtures) { + const r = extractDecisions(inBlock(line)); + assert.strictEqual(r.outcome, 'parsed', `fixture must still parse: ${JSON.stringify(line)}`); + assert.strictEqual(r.decisions[0].id, wantId, `id drifted on ${JSON.stringify(line)}`); + assert.strictEqual(r.decisions[0].text, wantText, `text drifted on ${JSON.stringify(line)}`); + } + // Typo'd prefix and prose labels keep their #4130 outcomes. + assert.strictEqual(extractDecisions(inBlock('- **D4x-01:** typo')).outcome, 'could-not-parse'); + assert.strictEqual(extractDecisions(inBlock('- **Deferred-until-X:** prose')).outcome, 'none-present'); + }); +}); + +describe('parseDecisions hardening — pathological single bullets terminate correctly (#4130 follow-up)', () => { + // The #4357 cliff shapes at full scale. NO wall-time assert (repo rule): + // under the hardening these complete in well under a millisecond each; if + // the quadratic ambiguity is ever reintroduced these become CI timeouts, + // never false passes. What is asserted is the CORRECT outcome. + test('40k hyphen-laden ID tail (colon cliff shape) → could-not-parse via the guard', () => { + const bullet = '- **D-' + 'a-'.repeat(20000); + const r = extractDecisions(inBlock(bullet)); + assert.strictEqual(r.outcome, 'could-not-parse', + 'the malformed mega-bullet must fail loud (parse-miss guard), not hang or vanish'); + assert.deepStrictEqual(r.decisions, []); + }); + + test('40k dash run after the separator (em-dash cliff shape) → could-not-parse via the guard', () => { + const bullet = '- **D-01 —' + '–'.repeat(40000 - 10); + const r = extractDecisions(inBlock(bullet)); + assert.strictEqual(r.outcome, 'could-not-parse'); + assert.deepStrictEqual(r.decisions, []); + }); + + test('40k colon run (titled-colon cliff shape) → could-not-parse via the guard', () => { + const bullet = '- **D-01: ' + 'x: '.repeat(13000); + const r = extractDecisions(inBlock(bullet)); + assert.strictEqual(r.outcome, 'could-not-parse'); + }); + + test('40k LEGAL single-line decision parses with its text byte-identical (no clamp)', () => { + const text = 'use the phase-scoped datastore '.repeat(1400).trim(); + const bullet = `- **D4-01:** ${text}`; + const r = extractDecisions(inBlock(bullet)); + assert.strictEqual(r.outcome, 'parsed', 'a legal mega-bullet must parse — no line-length clamp exists'); + assert.strictEqual(r.decisions.length, 1); + assert.strictEqual(r.decisions[0].id, 'D4-01'); + assert.strictEqual(r.decisions[0].text, text); + }); + + test('40k LEGAL wrapped-form decision still joins and parses (cliff shapes do not regress #3939)', () => { + const text = 'provision the datastore '.repeat(1500).trim(); + const md = `\n- **D4-01:** ${text}\n\n`; + const r = extractDecisions(md); + assert.strictEqual(r.outcome, 'parsed'); + assert.strictEqual(r.decisions[0].text, text); + }); +});