fix(#4130): --context flag for check decision-coverage-plan + parseDecisions quadratic-backtracking hardening (#4374)
* test(#4130): failing-first regressions for --context flag + parseDecisions hardening Block A (flag): check decision-coverage-plan --context <path> must route identically to the positional form; flag wins over positional context; valueless --context falls through to the #2770 fail-closed caller error; verify keeps its positional surface (flag is plan-only). RED on base: the flag token lands in the args[2] phase slot (false uncovered) or the args[3] context slot (silent CONTEXT.md-missing skip). Block B (hardening): regex-lattice asserts pin the atomic-ID wrapper (?=(X))\1 and the em-dash first-separator narrowing [^*—–]*[—–] plus the no-adjacent-overlap property; a differential property compares the module against a frozen copy of the pre-hardening grammars (reference validated against the base build: 60k generated lines, 0 mismatches); 40k cliff shapes assert correct outcomes with no wall-time asserts (repo rule). A12: partitionPredicateArgs keeps one parser behind parsePredicateFlags. * fix(#4130): --context flag for check decision-coverage-plan + quadratic-backtracking hardening in parseDecisions (A) check decision-coverage-plan --context <path> — sibling convention (check predicate, #2008): --flag value pairs parsed by the new shared partitionPredicateArgs (parsePredicateFlags reimplemented as its flags half — one parser, cannot diverge), the flag winning over a same-purpose positional, positionals kept (no sibling deprecates them; the plan-phase workflow caller passes positionals), valueless --context falls through to the #2770 fail-closed caller error. Repair of the routing accident where --context landed in the args[2] phase slot (false uncovered) or the literal token in the args[3] context slot (silent green skip). (B) parseDecisions regex seam hardened, byte-identical on all legal inputs: the three bullet grammars consume the ID atomically via the (?=(X))\1 lookahead emulation (kills the tail/[^:*]* O(n^2) re-split, ~1.1s @ 40k), and the em-dash first separator narrows [^*]*[—–] to [^*—–]*[—–] (kills the dash-position O(n^2) retry, ~1.7s @ 40k). Group indices unchanged (handlers untouched). Pinned by regex-lattice tests, a differential fast-check property vs the frozen pre-hardening grammars, and 40k cliff/legal-shape outcome tests (no wall-time asserts per repo rule — no deterministic engine step counter exists in Node). * docs+test(#4130): document --context invocation; harden lattice test tooling - docs/CONFIGURATION.md Decision Coverage Gates: new 'Invoking the plan gate directly' block documenting both the positional and --context forms, flag precedence, and the valueless-flag fail-closed semantics (same place the gate's behavior is documented; sibling check predicate documents its flags the same way). - Two changeset fragments per the maintainer brief (Added: flag; Fixed: hardening), PR numbers to be backfilled. - tests/decisions.test.cjs review fixes: readRegExpTemplate template escaping (bare ')' SyntaxError), range-aware lattice checker with backreference skip and template unescape, honest A1 contract, lint escape warning. * fix(#4130): valueless --context fails closed per #2770; A8 isolates flag-vs-positional context Suite-caught fixes from the first verify run: - cmdDecisionCoveragePlan now refuses a flag-shaped token as the positional context path: a bare valueless --context stays a positional (sibling parser semantics, unchanged) but reading it as a PATH would turn a caller mistake into a silent 'CONTEXT.md missing' green skip — exactly what #2770's fail-closed law forbids. Now falls through to the missing-context-argument error, as documented. - A8 test compares decoy-positional+flag against flag-with-phase (phase held constant) so the row isolates WHICH context was read; the old form compared against a no-phase invocation that could never match. * chore(#4130): backfill PR number in changeset fragments (PR #4374) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/jolly-otters-click.md
Normal file
5
.changeset/jolly-otters-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4374
|
||||
---
|
||||
**parseDecisions no longer backtracks quadratically on pathological single bullets** — output unchanged on all legal inputs. (#4130)
|
||||
5
.changeset/sharp-deer-hop.md
Normal file
5
.changeset/sharp-deer-hop.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 4374
|
||||
---
|
||||
**check decision-coverage-plan accepts --context <path>** — same convention as sibling check verbs. (#4130)
|
||||
@@ -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 <phase-dir> <context-path>
|
||||
```
|
||||
|
||||
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 <phase-dir> --context <path>
|
||||
gsd_run check decision-coverage-plan --context <path> [<phase-dir>]
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 <phase-dir> <context-path> (positional, the workflow caller's form)
|
||||
* gsd_run check decision-coverage-plan --context <path> [<phase-dir>]
|
||||
*
|
||||
* `--context <path>` 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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
/**
|
||||
* 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 <path>` shares it).
|
||||
*/
|
||||
function partitionPredicateArgs(args: string[]): { flags: Record<string, string>; positionals: string[] } {
|
||||
const flags: Record<string, string> = {};
|
||||
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<string, string> {
|
||||
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 '<json>'. 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 <path>` (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,
|
||||
|
||||
@@ -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*(.*)$`,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <path>` 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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <path> ──────────
|
||||
|
||||
/**
|
||||
* 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 <path>` 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 <path>` alone landed `--context` in the args[2] phase slot →
|
||||
* plans scanned in a nonexistent `<project>/--context` dir → every
|
||||
* decision falsely uncovered (passed:false where the positional form
|
||||
* passes).
|
||||
* - `<phase> --context <path>` 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',
|
||||
'',
|
||||
'<decisions>',
|
||||
'- **D4-01:** use the phase-scoped datastore',
|
||||
'</decisions>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const coveredPlan = () => '# Plan\n## Must Haves\n- D4-01: provision the datastore\n';
|
||||
|
||||
test('--context <path> 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: <phase> --context <path> 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 <path> <phase> (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, [
|
||||
'<decisions>',
|
||||
'- **DEC-01:** an ID grammar the parser does not support',
|
||||
'</decisions>',
|
||||
'',
|
||||
].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 <nonexistent-path> 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 = '<value>';` 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 = `<decisions>\n- **D4-01:** ${text}\n</decisions>\n`;
|
||||
const r = extractDecisions(md);
|
||||
assert.strictEqual(r.outcome, 'parsed');
|
||||
assert.strictEqual(r.decisions[0].text, text);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user