* refactor(#3180): one owner for completion ratio, a prompt-layer drift guard, and a written behavior contract The 2026-08-08 coverage audit on #3180 found the epic's copy counts were a lower bound for the third consecutive time, and that two derivation families had never been named at all. ADR-3180 gains Decision 7 — a normative behavior contract that says what the right answer IS for each derivation, not merely who owns it. A reviewer with no written rule can only ask "does this look like the others", which is how a fifth copy passes review. Decision 4 gains (d) scan surface is every authored surface and an owner FILE is never exempt, only its named functions; and (e) a surface that cannot be consolidated today ships ratcheted, never unguarded. Completion ratio: `clampPercent` sat exported and unused beside six hand-inlined copies of its own body across five modules. All six now route through it; `clampPercentFromFraction` is added for the one caller that already held a fraction. Every migration is behaviour-identical — clampPercent's first line IS the `total > 0 ? … : 0` ternary each copy carried. Guarded by lint-completion-ratio-drift.cjs, which reports zero re-derivations with no file-level exemption. Prompt layer: workflow markdown re-derives live-plan counting in raw shell (#1762), invisible to every `src/`-scoped guard. lint-planning-prompt-drift.cjs scans it with a shrink-only baseline of the 7 sites that exist today — new sites fail, and a baseline entry that stops firing fails too, so an acknowledgment can never outlive the thing it describes. lint-milestone-window-drift.cjs stops exempting its owner file wholesale; only the four named canonical functions are exempt now. The blanket exemption was pointed at the one file most likely to grow the next copy, and it had. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3180): link Phases 6-8 sub-issues (#3216, #3217, #3218) from ADR-3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): address orthogonal review — consumer-output identity tests, count-keyed ratchet, property coverage Five findings from the two orthogonal review passes, all fixed. Decision 4(c) breach: the completion-ratio identity test asserted at the OWNER, which is exactly the bypass that decision exists to close — a consumer can call clampPercent and then post-process locally, leaving both the lint and an owner-level test green. It now drives `roadmap analyze`, `query progress` and `stats` and asserts on their own output, over a fixture containing a `status: superseded` plan so a consumer that re-counted raw files would report 60 where the owner reports 75. Decision 4(e) breach: ratchet entries named the epic (#3180) rather than the issue that removes them. They name Phase 8 (#3218) now. The ratchet keyed on (file, text) alone, so plan-phase.md's two byte-identical sites were one indistinguishable key and migrating either would have left the guard green with the other alive. Entries carry an occurrence count; fewer than acknowledged fails as a partial migration, more fails as a new copy. Adds the missing MAX_REGEX_LITERAL_LEN boundary coverage the sibling guard's test already had, and the fast-check property tests CONTRIBUTING requires for clamp/budget-limit functions. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: stop wrapping a nested double-spawn in a 15s wall-clock budget (bug #641 probes) `tests/ci-test-scope.test.cjs`'s `bug #641` block spawned `run-tests.cjs` under PROBE_TIMEOUT_MS=15000; that child then spawned a nested `node --test`. A fixed wall-clock budget around a double spawn, running inside a container that is concurrently executing the full ~31k-test suite, fails by construction under load. Confirmed against three full matrix runs. Every failure was shaped `null !== 0` — the child was KILLED, never an assertion about the thing under test. One captured probe had already printed the correct resolution (`suite="all" files=2: a.test.cjs b.test.cjs`) and was killed anyway. It reproduces on `next` alone: 5 failures on linux-node22, 0 on linux-node24. The victim subset varies by run and by lane. What these tests are actually about is suite-token RESOLUTION — `unit` as a bare token in --files/--files-from. Executing the seeded trivial files is incidental and is the entire timeout surface, so the assertions move in-process against the same functions `main()` calls, in the same order. `parseArgs`, `selectExplicitFiles`, `selectFiles` and `walkTestFiles` are exported for that; no behavior, signature or logic changed. No coverage lost: `tests/run-tests-harness.test.cjs` already spawns the harness for real and asserts exit codes end to end, on a 120s budget. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: delete the three elapsed-time assertions CLAUDE.md forbids asserting on wall-clock time. Three assertions did, and all three are load-sensitive: on a saturated bench each can fail while the code under test is correct. In every case the load-bearing assertion sits on the line above and the timing line adds no discrimination. run-with-timeout: the stated worry — "was this 124 the cap firing or the 30s harness backstop?" — is already answered by the assertion above it. A backstop kills by signal, which surfaces as status null, never 124. Observed directly this session: three matrix runs produced exactly that null shape from killed children. normalize-test-command and context-predicates: both bounded a ReDoS check. A threshold only ever separates "fast" from "slightly slow", which is bench load, not correctness — catastrophic backtracking on 800 KB of input does not take 251ms, it does not finish at all. A real regression therefore shows up as the suite being killed on that test, which is louder and more reliable than a number. The structural assertions (returned unchanged; cleanly rejected) are what actually carry those tests, and they stay. The sweep now reports zero elapsed-time assertions in tests/. The remaining Date.now() uses are unique-path suffixes, barrier deadlines, fixture timestamps and fake mtimes — none of them assertions. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3180): backfill changeset PR number (#3223) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): key the prompt-drift ratchet on POSIX paths so it works on Windows The baseline keys on (file, trimmed text). `file` came from scanTree's `path.relative()`, which uses NATIVE separators, while the committed baseline stores POSIX. On Windows every violation was therefore unmatched — reported as FRESH — and every baseline entry matched nothing — reported as STALE. The guard failed 100% of the time there, on both CI shards: ✖ scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale + { file: 'gsd-core\\workflows\\execute-plan.md', ... } The remote runner this repo gates on is Linux-only and cannot see this class at all; the GitHub Actions Windows lane is what caught it. Normalization is unconditional — never gated on process.platform. A platform-conditional normalizer makes the POSIX path the special case and leaves the Windows branch unexercised on every other OS, which is the same blind spot in a different place. It is applied at one seam inside findPromptDrift, which builds `file` on every returned violation, so the baseline key, the --update writer, the stderr report and the tests all consume one normalized value. The regression tests drive a Windows-shaped relPath directly and run on every OS rather than skipping off-Windows — a test that only runs on the platform where the bug lives is why this escaped. They include a sanity check that un-normalized input does NOT match, so the assertion cannot pass vacuously. Audited the three sibling guards: none keys against a committed cross-platform baseline, and their exemption keys are path.join-built, so producer and consumer share the native convention. Left correct code alone rather than making them look alike. scripts/lib/drift-scan.cjs is untouched — normalizing there would break those three on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
736 lines
31 KiB
JavaScript
736 lines
31 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Unit tests for src/context-predicates.cts (compiled to
|
|
* gsd-core/bin/lib/context-predicates.cjs) — the CONTEXT.md predicate
|
|
* fact-store parser/validator/selector (ADR-1671, #2928 Phase 1).
|
|
*
|
|
* FAILING-FIRST: this file targets the REQUIRED production behavior from
|
|
* .gsd/phase/chore-2928-context-predicate-store/40-design.md's behavior
|
|
* table, not today's prototype-carried-forward behavior. Eight row-groups
|
|
* are measured, provable defects in the current implementation and MUST be
|
|
* RED until a later commit fixes them: A4, A5, A6, A7 (indented-bare / `*` /
|
|
* `+` / numbered-list declaration forms are dropped), B2 (tilde fence not
|
|
* skipped), B3 (4-backtick fence containing a 3-backtick line mis-toggles),
|
|
* B5 (multi-line HTML comment not skipped).
|
|
*
|
|
* Fixture provenance (CONTRIBUTING.md #2371): the must-NOT-parse corpus
|
|
* (rows A8-A11, negative space, I4) is drawn from real repo documents that
|
|
* predate/ignore this grammar — the real CONTEXT.md and the real
|
|
* CONTRIBUTING.md — never hand-authored from the grammar spec itself.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const {
|
|
parsePredicates,
|
|
selectPredicates,
|
|
buildIndex,
|
|
} = require('../gsd-core/bin/lib/context-predicates.cjs');
|
|
|
|
const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const CRLF = '\r\n';
|
|
|
|
// ─── A. Declaration recognition ───────────────────────────────────────────
|
|
|
|
describe('parsePredicates: declaration recognition (A)', () => {
|
|
test('parsesBareBacktickDeclaration', () => {
|
|
const r = parsePredicates('`FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.deepEqual(
|
|
{ id: r.predicates[0].id, klass: r.predicates[0].klass, value: r.predicates[0].value },
|
|
{ id: 'FOO', klass: 'FOO', value: 'bar' },
|
|
);
|
|
});
|
|
|
|
test('parsesDashListItemDeclaration', () => {
|
|
const r = parsePredicates('- `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesIndentedDashListItemDeclaration', () => {
|
|
const r = parsePredicates(' - `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesIndentedBareDeclaration', () => {
|
|
// RED (measured defect): the prototype's bare-form check requires column
|
|
// 0 (`line.startsWith('`')` on a line whose only trim is trimEnd()), so
|
|
// leading whitespace with no list marker is silently dropped today.
|
|
const r = parsePredicates(' `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1, 'indented bare declaration must be tolerated (Postel honored on shape)');
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesStarListItemDeclaration', () => {
|
|
// RED (measured defect): today's list-item stripper only recognizes `-`.
|
|
const r = parsePredicates('* `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesPlusListItemDeclaration', () => {
|
|
// RED (measured defect): same as `*`, `+` is not recognized today.
|
|
const r = parsePredicates('+ `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesNumberedListItemDeclaration', () => {
|
|
// RED (measured defect): numbered list markers are not recognized today.
|
|
const r = parsePredicates('1. `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('ignoresInlineReferenceInsideProse', () => {
|
|
const r = parsePredicates('see `FOO=bar` for details');
|
|
assert.equal(r.predicates.length, 0, 'an inline mid-prose mention is a reference, not a declaration');
|
|
});
|
|
|
|
test('ignoresPredicateShapeInTableCell', () => {
|
|
const r = parsePredicates('| x | `FOO=bar` |');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('ignoresPredicateShapeInHeading', () => {
|
|
const md = ['# `FOO=bar`', '`REAL.one=value`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 1, 'the heading itself must never yield a predicate');
|
|
assert.equal(r.predicates[0].id, 'REAL.one');
|
|
assert.equal(r.predicates[0].section, '`FOO=bar`', 'the heading text (predicate-shaped or not) becomes the tracked section');
|
|
});
|
|
|
|
test('ignoresPredicateShapeInBlockquote', () => {
|
|
const r = parsePredicates('> `FOO=bar`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('tracksNearestPrecedingSectionHeading', () => {
|
|
const md = ['# My Section', '`FOO=bar`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].section, 'My Section');
|
|
});
|
|
});
|
|
|
|
// ─── B. Fenced / commented regions ────────────────────────────────────────
|
|
|
|
describe('parsePredicates: fenced/commented regions (B)', () => {
|
|
test('ignoresDeclarationInsideBacktickFence', () => {
|
|
const md = ['```', '`FOO=bar`', '```'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('ignoresDeclarationInsideTildeFence', () => {
|
|
// RED (measured defect): the naive toggle only matches triple-backtick lines.
|
|
const md = ['~~~', '`FOO=bar`', '~~~'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'a tilde fence must skip its contents exactly like a backtick fence');
|
|
});
|
|
|
|
test('ignoresDeclarationInsideLongerFenceContainingShorterFence', () => {
|
|
// RED (measured defect): a naive backtick-count-agnostic toggle mis-flips
|
|
// on the inner 3-backtick line and un-skips the remainder.
|
|
const md = ['````', '```', '`FOO=bar`', '```', '````'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'fence-length awareness must prevent the inner fence from un-skipping the outer one');
|
|
});
|
|
|
|
test('ignoresDeclarationInsideLanguageTaggedFence', () => {
|
|
const md = ['```bash', '`FOO=bar`', '```'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('ignoresDeclarationInsideMultiLineHtmlComment', () => {
|
|
// RED (measured defect): the prototype has no HTML-comment awareness at
|
|
// all — a predicate-shaped line between `<!--` and `-->` on its own line
|
|
// parses as live today.
|
|
const md = ['<!--', '`FOO=bar`', '-->'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'a commented-out predicate must not be read as live');
|
|
});
|
|
|
|
test('ignoresDeclarationInsideSingleLineHtmlComment', () => {
|
|
const r = parsePredicates('<!-- `FOO=bar` -->');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('treatsUnclosedFenceAsSkippedToEndOfFile', () => {
|
|
const md = ['```', '`FOO=bar`', '`BAZ=qux`'].join('\n');
|
|
assert.doesNotThrow(() => parsePredicates(md));
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'everything after an unclosed fence must be treated as skipped, not crash or leak');
|
|
});
|
|
|
|
test('parsesDeclarationInFourSpaceIndentedBlockAsDocumentedLimit', () => {
|
|
// Pins the documented limit (design known-limit 1): 4-space indentation
|
|
// is NOT treated as a code block, because CONTEXT.md authors real
|
|
// predicates as indented list items at that depth.
|
|
const r = parsePredicates(' - `FOO=bar`');
|
|
assert.equal(r.predicates.length, 1, 'documented limit: 4-space indent is not code, so this must still parse');
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
});
|
|
|
|
// ─── B2. Comment/fence mutual precedence
|
|
// (DEFECT.CONTEXT-PREDICATES-COMMENT-FENCE-BLIND — BLOCKER review finding) ──
|
|
//
|
|
// The HTML-comment scan and the fence scan previously ran as two
|
|
// INDEPENDENT passes: `scanFencedBlocks` is comment-blind, so a fence
|
|
// delimiter appearing INSIDE an HTML comment (with no later matching close
|
|
// in the file) was treated as a real *unterminated* fence — silently
|
|
// dropping every remaining predicate to EOF. This is invisible to `--check`
|
|
// because `--check` diffs against a baseline generated by the same
|
|
// corrupted parse. This suite locks the chosen precedence (module doc
|
|
// comment, `computeSkippedLineFlags`): the two constructs are scanned in one
|
|
// interleaved pass and mutually suppress each other's open/close detection
|
|
// while active.
|
|
|
|
describe('parsePredicates: comment/fence mutual precedence (B2)', () => {
|
|
test('fenceDelimiterInsideCommentWithNoLaterFenceDoesNotSwallowRemainingFile', () => {
|
|
// The exact BLOCKER repro: a fence delimiter inside an HTML comment, with
|
|
// no later real fence anywhere in the document, must not be treated as
|
|
// an unterminated fence — the later real predicate must still parse.
|
|
const md = ['<!-- example:', '```', '-->', '`RULESET.REAL.PREDICATE=x`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 1, 'the fence delimiter inside the comment must not swallow the rest of the file');
|
|
assert.equal(r.predicates[0].id, 'RULESET.REAL.PREDICATE');
|
|
});
|
|
|
|
test('fenceDelimiterInsideCommentWithLaterRealFenceStillBehavesCorrectly', () => {
|
|
const md = [
|
|
'<!-- example:',
|
|
'```',
|
|
'-->',
|
|
'`BEFORE=1`',
|
|
'```',
|
|
'`INSIDE=2`',
|
|
'```',
|
|
'`AFTER=3`',
|
|
].join('\n');
|
|
const r = parsePredicates(md);
|
|
const ids = r.predicates.map((p) => p.id);
|
|
assert.deepEqual(ids.sort(), ['AFTER', 'BEFORE'], 'the real fence after the comment must still skip its own content');
|
|
});
|
|
|
|
test('arrowInsideRealFencedBlockDoesNotTerminateAnythingAndFenceStillSkipsItsContent', () => {
|
|
const md = ['```', '`INSIDE=1`', 'noise line with --> token', '```', '`AFTER=2`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
const ids = r.predicates.map((p) => p.id);
|
|
assert.deepEqual(ids, ['AFTER'], '--> inside real fence content must not open/close a comment; fence must still skip INSIDE');
|
|
});
|
|
|
|
test('commentOpenerInsideRealFencedBlockDoesNotOpenAComment', () => {
|
|
const md = ['```', '<!-- looks like a comment opener, but this is fence content', '`INSIDE=1`', '```', '`AFTER=2`'].join(
|
|
'\n',
|
|
);
|
|
const r = parsePredicates(md);
|
|
const ids = r.predicates.map((p) => p.id);
|
|
assert.deepEqual(ids, ['AFTER'], '<!-- inside real fence content must not open a comment that leaks past the fence close');
|
|
});
|
|
|
|
test('unterminatedCommentAtEofSkipsRemainingLinesWithoutCrashing', () => {
|
|
const md = ['`BEFORE=1`', '<!-- unterminated', '`INSIDE=2`', '`ALSOINSIDE=3`'].join('\n');
|
|
assert.doesNotThrow(() => parsePredicates(md));
|
|
const r = parsePredicates(md);
|
|
const ids = r.predicates.map((p) => p.id);
|
|
assert.deepEqual(ids, ['BEFORE'], 'an unterminated comment must skip to EOF, not crash and not leak later predicates');
|
|
});
|
|
|
|
test('commentAndFenceTokensOnTheSameLineResolveWithoutInterference', () => {
|
|
// A fence-opener line whose CommonMark info string happens to contain a
|
|
// full inline HTML comment: the line still starts with backticks, not
|
|
// `<!--`, so it opens a real fence (comment text is just info-string
|
|
// trailing content) — the fence still closes and skips its own content.
|
|
const infoStringMd = ['`BEFORE=1`', '``` <!-- note -->', '`INSIDE=2`', '```', '`AFTER=3`'].join('\n');
|
|
const infoStringResult = parsePredicates(infoStringMd);
|
|
assert.deepEqual(
|
|
infoStringResult.predicates.map((p) => p.id).sort(),
|
|
['AFTER', 'BEFORE'],
|
|
'a fence opener whose info string contains comment-like text must still open/close as a real fence',
|
|
);
|
|
|
|
// A self-closing single-line HTML comment whose content happens to
|
|
// contain a fence-delimiter-shaped token: the line still starts with
|
|
// `<!--`, so the whole line is comment content and the embedded
|
|
// backticks never open a fence.
|
|
const commentMd = ['`BEFORE=1`', '<!-- see ``` for an example -->', '`AFTER=2`'].join('\n');
|
|
const commentResult = parsePredicates(commentMd);
|
|
assert.deepEqual(
|
|
commentResult.predicates.map((p) => p.id).sort(),
|
|
['AFTER', 'BEFORE'],
|
|
'a single-line comment whose content contains fence-shaped text must not open a fence',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── C. ID / value grammar boundaries ──────────────────────────────────────
|
|
|
|
describe('parsePredicates: ID/value grammar boundaries (C)', () => {
|
|
test('rejectsBacktickContentAtLengthTwo', () => {
|
|
// `A=` — backtick content length 2 (limit-1 of the `inner.length > 2` guard).
|
|
const r = parsePredicates('`A=`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('acceptsMinimalBacktickContentAtLengthThree', () => {
|
|
// `A=1` — length 3 (limit).
|
|
const r = parsePredicates('`A=1`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.deepEqual({ id: r.predicates[0].id, value: r.predicates[0].value }, { id: 'A', value: '1' });
|
|
});
|
|
|
|
test('acceptsBacktickContentAboveMinimumLength', () => {
|
|
// `A=12` — length 4 (limit+1).
|
|
const r = parsePredicates('`A=12`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].value, '12');
|
|
});
|
|
|
|
test('rejectsEqualsSignAtIndexZero', () => {
|
|
// `=value` — eqIdx 0 (limit-1 of the `eqIdx < 1` guard).
|
|
const r = parsePredicates('`=value`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('acceptsEqualsSignAtIndexOne', () => {
|
|
// `A=value` — eqIdx 1 (limit).
|
|
const r = parsePredicates('`A=value`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'A');
|
|
});
|
|
|
|
test('acceptsEqualsSignAboveIndexOne', () => {
|
|
// `AB=value` — eqIdx 2 (limit+1).
|
|
const r = parsePredicates('`AB=value`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'AB');
|
|
});
|
|
|
|
test('rejectsInlineCodeWithoutEqualsSign', () => {
|
|
const r = parsePredicates('`ID`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(
|
|
r.malformed.length,
|
|
0,
|
|
'ordinary inline code with no "=" at all is not a declaration attempt and must not be diagnosed',
|
|
);
|
|
});
|
|
|
|
test('reportsEmptyValueAsMalformedRatherThanDroppingSilently', () => {
|
|
const r = parsePredicates('`ID=`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(r.malformed.length, 1, 'an empty value must surface as a diagnostic, not vanish silently');
|
|
assert.equal(r.malformed[0].reason, 'empty-value');
|
|
});
|
|
|
|
test('splitsOnFirstEqualsSignOnly', () => {
|
|
const r = parsePredicates('`ID=a=b=c`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.deepEqual({ id: r.predicates[0].id, value: r.predicates[0].value }, { id: 'ID', value: 'a=b=c' });
|
|
});
|
|
|
|
test('rejectsLowercaseLeadingIdentifier', () => {
|
|
const r = parsePredicates('`foo.bar=x`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('acceptsLowercaseSubSegments', () => {
|
|
const r = parsePredicates('`PRED.k320.rule=x`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].klass, 'PRED');
|
|
});
|
|
|
|
test('acceptsHyphenatedClassSegment', () => {
|
|
const r = parsePredicates('`RELEASE-NOTES.x=y`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].klass, 'RELEASE-NOTES');
|
|
});
|
|
|
|
test('rejectsWhitespaceInIdentifier', () => {
|
|
const r = parsePredicates('`FOO BAR=x`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('acceptsSingleSegmentIdentifier', () => {
|
|
const r = parsePredicates('`FOO=x`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].klass, r.predicates[0].id);
|
|
});
|
|
|
|
test('preservesTrailingWhitespaceInValueVerbatim', () => {
|
|
const r = parsePredicates('`ID=1 `');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].value, '1 ', 'trailing whitespace in the value must not be trimmed');
|
|
});
|
|
|
|
test('acceptsBacktickWithinValue', () => {
|
|
const r = parsePredicates('`ID=a`b`');
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].value, 'a`b');
|
|
});
|
|
|
|
test('rejectsPathAndQueryCharactersInIdentifier', () => {
|
|
const r = parsePredicates('`A/B?c=d`');
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('rejectsDoubledDotEmptySegment', () => {
|
|
// DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the
|
|
// structural id validator (isValidId) splits on '.' and rejects any
|
|
// empty segment. This is a deliberate behavior CHANGE vs. the old
|
|
// `ID_RE` regex, which accepted `A..b` because `.` was inside the
|
|
// subsequent-segment character class. The real repo CONTEXT.md was
|
|
// checked (`grep -nE '`[A-Z][A-Za-z0-9_.-]*\\.\\.[A-Za-z0-9_.-]*='
|
|
// CONTEXT.md`) and contains ZERO ids with a doubled dot, so this is a
|
|
// pure grammar-tightening with no behavior loss against real data —
|
|
// pinned here so a future change cannot silently re-loosen it.
|
|
const r = parsePredicates('`A..b=value`');
|
|
assert.equal(r.predicates.length, 0, 'a doubled dot (empty segment) must be rejected, not silently accepted');
|
|
});
|
|
|
|
test('idGrammarValidationRejectsManyConsecutiveDots', () => {
|
|
// DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the old
|
|
// `ID_RE`'s `(?:\.[A-Za-z0-9_.-]+)*` group was exponential in the number
|
|
// of consecutive dots (measured: ~565ms for 40 dots). The structural
|
|
// per-segment validator is linear. No elapsed-time bound is asserted:
|
|
// the load-bearing assertion is that the result is a clean rejection
|
|
// (an id-shaped line with 60 consecutive dots has an empty segment at
|
|
// every step and must not parse). An exponential regression would not
|
|
// finish at all, not merely exceed a threshold — so a clean rejection
|
|
// is itself the discriminator.
|
|
const dots = '.'.repeat(60);
|
|
const md = `\`A${dots}x=value\``;
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'an id with 60 consecutive dots has empty segments and must cleanly reject');
|
|
});
|
|
});
|
|
|
|
// ─── D. CRLF / newline fidelity ────────────────────────────────────────────
|
|
|
|
describe('parsePredicates: CRLF/newline fidelity (D)', () => {
|
|
test('parsesBareDeclarationUnderCrlf', () => {
|
|
const r = parsePredicates('`FOO=bar`' + CRLF);
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].id, 'FOO');
|
|
});
|
|
|
|
test('parsesListItemDeclarationUnderCrlf', () => {
|
|
const r = parsePredicates('- `FOO=bar`' + CRLF);
|
|
assert.equal(r.predicates.length, 1);
|
|
});
|
|
|
|
test('parsesIndentedListDeclarationUnderCrlf', () => {
|
|
const r = parsePredicates(' - `FOO=bar`' + CRLF);
|
|
assert.equal(r.predicates.length, 1);
|
|
});
|
|
|
|
test('skipsFencedDeclarationUnderCrlf', () => {
|
|
const md = ['```', '`FOO=bar`', '```', ''].join(CRLF);
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('skipsBlockquoteDeclarationUnderCrlf', () => {
|
|
const r = parsePredicates('> `FOO=bar`' + CRLF);
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
|
|
test('tracksSectionAndLineNumbersUnderCrlf', () => {
|
|
const md = ['# Section Name', '`FOO=bar`', ''].join(CRLF);
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 1);
|
|
assert.equal(r.predicates[0].section, 'Section Name');
|
|
assert.equal(r.predicates[0].line, 2);
|
|
});
|
|
|
|
test('parsesMixedLfAndCrlfDocument', () => {
|
|
const md = '`FOO=bar`' + CRLF + '- `BAZ=qux`' + '\n';
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 2);
|
|
assert.deepEqual(r.predicates.map((p) => p.id).sort(), ['BAZ', 'FOO']);
|
|
});
|
|
|
|
test('yieldsNoPredicatesForLoneCrDocumentAsDocumentedLimit', () => {
|
|
// Pins the documented limit (design known-limit 2): lone-CR-only line
|
|
// endings are unsupported; no `\r`-only file exists in this repo.
|
|
const md = '`FOO=bar`\r`BAZ=qux`\r';
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0);
|
|
});
|
|
});
|
|
|
|
// ─── E. Duplicate detection + validation ───────────────────────────────────
|
|
|
|
describe('parsePredicates: duplicate detection + validation (E)', () => {
|
|
test('reportsDuplicateIdentifierWithDifferentValues', () => {
|
|
const md = ['`FOO=a`', '`FOO=b`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 1);
|
|
assert.deepEqual(r.duplicates[0], { id: 'FOO', count: 2 });
|
|
});
|
|
|
|
test('reportsDuplicateIdentifierEvenWhenValuesAreIdentical', () => {
|
|
const md = ['`FOO=a`', '`FOO=a`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 1, 'identical-value duplicates must not be silently deduped');
|
|
assert.deepEqual(r.duplicates[0], { id: 'FOO', count: 2 });
|
|
});
|
|
|
|
test('reportsDuplicateCountForThreeOccurrences', () => {
|
|
const md = ['`FOO=a`', '`FOO=b`', '`FOO=c`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 1);
|
|
assert.equal(r.duplicates[0].count, 3);
|
|
});
|
|
|
|
test('reportsNoDuplicateForSingleOccurrence', () => {
|
|
const r = parsePredicates('`FOO=a`');
|
|
assert.equal(r.duplicates.length, 0);
|
|
});
|
|
|
|
test('doesNotCountFenceSkippedOccurrenceAsDuplicate', () => {
|
|
const md = ['`FOO=a`', '```', '`FOO=b`', '```'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 0, 'a fence-skipped occurrence is not a declaration');
|
|
assert.equal(r.predicates.length, 1);
|
|
});
|
|
|
|
test('doesNotCountCommentedOccurrenceAsDuplicate', () => {
|
|
const md = ['`FOO=a`', '<!-- `FOO=b` -->'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 0);
|
|
assert.equal(r.predicates.length, 1);
|
|
});
|
|
|
|
test('reportsMalformedAndDuplicateDiagnosticsTogether', () => {
|
|
const md = ['`FOO=a`', '`FOO=b`', '`BAR=`'].join('\n');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 1, 'the duplicate path must not be suppressed by the malformed path');
|
|
assert.equal(r.malformed.length, 1, 'the malformed path must not be suppressed by the duplicate path');
|
|
});
|
|
|
|
test('realContextMdHasNoDuplicateIdentifiers', () => {
|
|
const md = fs.readFileSync(path.join(ROOT, 'CONTEXT.md'), 'utf8');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 0, 'the real CONTEXT.md must carry no duplicate predicate ids');
|
|
});
|
|
});
|
|
|
|
// ─── E2. Malformed diagnostics — one distinct reason per rejection class ───
|
|
|
|
describe('parsePredicates: malformed diagnostics name the exact rejection reason (E2)', () => {
|
|
test('reportsEmptySegmentForDoubledDot', () => {
|
|
const r = parsePredicates('`A..b=1`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(r.malformed.length, 1);
|
|
assert.equal(r.malformed[0].reason, 'empty-segment');
|
|
});
|
|
|
|
test('reportsInvalidIdCharsForSpaceInId', () => {
|
|
const r = parsePredicates('`FOO BAR=1`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(r.malformed.length, 1);
|
|
assert.equal(r.malformed[0].reason, 'invalid-id-chars');
|
|
});
|
|
|
|
test('reportsLowercaseLeadingClassForLowercaseFirstSegment', () => {
|
|
const r = parsePredicates('`foo.bar=1`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(r.malformed.length, 1);
|
|
assert.equal(r.malformed[0].reason, 'lowercase-leading-class');
|
|
});
|
|
|
|
test('reportsValueContainsNewlineForEmbeddedCr', () => {
|
|
// A lone embedded CR inside an otherwise well-formed, LF-terminated
|
|
// backtick line (distinct from the documented lone-CR-only-line limit
|
|
// pinned by yieldsNoPredicatesForLoneCrDocumentAsDocumentedLimit above,
|
|
// which never reaches a closing backtick at all).
|
|
const md = '`ID=ab\rcd`\n';
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'a value with an embedded CR must be rejected, not silently accepted');
|
|
assert.equal(r.malformed.length, 1);
|
|
assert.equal(r.malformed[0].reason, 'value-contains-newline');
|
|
});
|
|
|
|
test('idCheckTakesPrecedenceOverEmptyValueWhenBothFail', () => {
|
|
// `foo.bar=` fails BOTH the id (lowercase-leading) and the value (empty)
|
|
// checks — id validity is checked first per detectMalformed's documented
|
|
// precedence.
|
|
const r = parsePredicates('`foo.bar=`');
|
|
assert.equal(r.predicates.length, 0);
|
|
assert.equal(r.malformed.length, 1);
|
|
assert.equal(r.malformed[0].reason, 'lowercase-leading-class');
|
|
});
|
|
});
|
|
|
|
// ─── I. Independence + real-corpus regression ──────────────────────────────
|
|
|
|
describe('parsePredicates: independence + real-corpus regression (I)', () => {
|
|
test('parserHasNoCrossTestSharedState', () => {
|
|
// Run in an order that would surface a leaking module-level cache: parse
|
|
// a document with a duplicate, then a clean document, then re-parse the
|
|
// first — results must be identical each time, regardless of call order.
|
|
const dupMd = ['`FOO=a`', '`FOO=b`'].join('\n');
|
|
const cleanMd = '`BAR=x`';
|
|
|
|
const firstPass = parsePredicates(dupMd);
|
|
const cleanPass = parsePredicates(cleanMd);
|
|
const secondPass = parsePredicates(dupMd);
|
|
|
|
assert.equal(cleanPass.duplicates.length, 0, 'a clean parse must never see the previous call\'s duplicate');
|
|
assert.deepEqual(secondPass.duplicates, firstPass.duplicates, 'repeating the same input must repeat the same result');
|
|
assert.equal(secondPass.predicates.length, firstPass.predicates.length);
|
|
});
|
|
|
|
test('realContributingMdYieldsNoPredicates', () => {
|
|
// Fixture provenance (#2371): CONTRIBUTING.md contains
|
|
// `GITHUB_BASE_REF=next …` (inside a blockquote) and
|
|
// `export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\.com$'` (inside a
|
|
// fenced bash block) — uppercase, `=`-bearing, backtick-adjacent text
|
|
// authored by someone who never heard of this grammar.
|
|
const md = fs.readFileSync(path.join(ROOT, 'CONTRIBUTING.md'), 'utf8');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.predicates.length, 0, 'a document that never heard of the grammar must yield zero predicates');
|
|
});
|
|
|
|
test('realContextMdParsesFullPredicateSet', () => {
|
|
const md = fs.readFileSync(path.join(ROOT, 'CONTEXT.md'), 'utf8');
|
|
const r = parsePredicates(md);
|
|
assert.equal(r.duplicates.length, 0);
|
|
assert.ok(r.predicates.length > 0, 'the real CONTEXT.md must yield a non-empty predicate set');
|
|
const classes = new Set(r.predicates.map((p) => p.klass));
|
|
assert.ok(classes.size >= 20, `expected >= 20 classes in the live predicate set, got ${classes.size}`);
|
|
});
|
|
});
|
|
|
|
// ─── selectPredicates / buildIndex smoke coverage (not in the row matrix,
|
|
// but exercised here since they are pure, no-I/O functions covered by unit
|
|
// tests per the risk table — the CLI/query surfaces are covered separately). ───
|
|
|
|
describe('selectPredicates + buildIndex: pure-function smoke coverage', () => {
|
|
test('selectPredicates filters by klass/prefix/contains independently', () => {
|
|
const r = parsePredicates(['`FOO.a=hello world`', '`FOO.b=other`', '`BAR.a=hello`'].join('\n'));
|
|
const byKlass = selectPredicates(r.predicates, { klass: 'FOO' });
|
|
assert.equal(byKlass.length, 2);
|
|
const byPrefix = selectPredicates(r.predicates, { prefix: 'FOO.a' });
|
|
assert.equal(byPrefix.length, 1);
|
|
const byContains = selectPredicates(r.predicates, { contains: 'hello' });
|
|
assert.equal(byContains.length, 2);
|
|
});
|
|
|
|
test('buildIndex omits the line field from every entry (S5)', () => {
|
|
const r = parsePredicates('`FOO=bar`');
|
|
const index = buildIndex(r.predicates);
|
|
assert.equal(index.predicates.length, 1);
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(index.predicates[0], 'line'));
|
|
});
|
|
});
|
|
|
|
// ─── Parity: fenced-line determination vs markdown-sectionizer.scanFencedBlocks
|
|
// (DEFECT.GENERATIVE-FIX) ───────────────────────────────────────────────────
|
|
//
|
|
// context-predicates.cts derives its fenced-line skip flags from
|
|
// markdown-sectionizer.cts's exported `scanFencedBlocks` seam rather than
|
|
// carrying its own copy of the fence state machine. This suite asserts that
|
|
// parsePredicates' observable skip/keep decision for every ID-marker line
|
|
// agrees with what `scanFencedBlocks` independently reports for that same
|
|
// `lines` array, across the fence-shape table below — so a future change to
|
|
// the shared scanner cannot silently diverge from predicate parsing.
|
|
|
|
describe('parsePredicates: fence-skip parity with markdown-sectionizer.scanFencedBlocks', () => {
|
|
const BARE_ID_RE = /^`([A-Za-z][A-Za-z0-9._-]*)=/;
|
|
|
|
function assertFenceParity(name, lines) {
|
|
test(name, () => {
|
|
const md = lines.join('\n');
|
|
const blocks = scanFencedBlocks(lines);
|
|
const expectedSkip = new Array(lines.length).fill(false);
|
|
for (const block of blocks) {
|
|
const end = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
|
|
for (let i = block.openLineIdx; i <= end; i++) expectedSkip[i] = true;
|
|
}
|
|
|
|
const r = parsePredicates(md);
|
|
const parsedIds = new Set(r.predicates.map((p) => p.id));
|
|
|
|
let sawMarker = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = BARE_ID_RE.exec(lines[i].trim());
|
|
if (!m) continue;
|
|
sawMarker = true;
|
|
const id = m[1];
|
|
if (expectedSkip[i]) {
|
|
assert.ok(
|
|
!parsedIds.has(id),
|
|
`${name}: line ${i} (${id}) is inside a scanFencedBlocks fence and must not be parsed as live`,
|
|
);
|
|
} else {
|
|
assert.ok(
|
|
parsedIds.has(id),
|
|
`${name}: line ${i} (${id}) is outside any scanFencedBlocks fence and must be parsed as live`,
|
|
);
|
|
}
|
|
}
|
|
assert.ok(sawMarker, `${name}: fixture must contain at least one ID marker line`);
|
|
});
|
|
}
|
|
|
|
assertFenceParity('3-backtick fence', ['`BEFORE=1`', '```', '`INSIDE=2`', '```', '`AFTER=3`']);
|
|
|
|
assertFenceParity('3-tilde fence', ['`BEFORE=1`', '~~~', '`INSIDE=2`', '~~~', '`AFTER=3`']);
|
|
|
|
assertFenceParity('4-backtick fence containing a nested 3-backtick fence', [
|
|
'`BEFORE=1`',
|
|
'````',
|
|
'```',
|
|
'`INSIDE=2`',
|
|
'```',
|
|
'````',
|
|
'`AFTER=3`',
|
|
]);
|
|
|
|
assertFenceParity('language-tagged fence', [
|
|
'`BEFORE=1`',
|
|
'```bash',
|
|
'`INSIDE=2`',
|
|
'```',
|
|
'`AFTER=3`',
|
|
]);
|
|
|
|
assertFenceParity('info string containing a backtick is not a valid opener', [
|
|
'`BEFORE=1`',
|
|
'``` `evil` ',
|
|
'`STILL=2`',
|
|
'```',
|
|
'`INSIDE=3`',
|
|
'```',
|
|
'`AFTER=4`',
|
|
]);
|
|
|
|
assertFenceParity('indented (<=3 space) fence', [
|
|
'`BEFORE=1`',
|
|
' ```',
|
|
'`INSIDE=2`',
|
|
' ```',
|
|
'`AFTER=3`',
|
|
]);
|
|
|
|
assertFenceParity('unterminated fence skips to end of file', [
|
|
'`BEFORE=1`',
|
|
'```',
|
|
'`INSIDE=2`',
|
|
'`ALSOINSIDE=3`',
|
|
]);
|
|
});
|