chore(#2880): close ADR-2143 deployment misses — table-regex fingerprint + state-document seam migration (#2889)

* chore(#2880): close ADR-2143 seam misses — widen table-regex fingerprint, migrate state-document onto the seam

The no-adhoc-markdown-parsing rule matched only a negated class whose sole
member was a pipe ([^|]), so the stricter and more common [^|\n] spelling
evaded it entirely -- src/state-document.cts hand-rolled exactly that shape
and linted clean. Widen the fingerprint to any negated class excluding a
pipe, which is the ADR-2143 section 7 prohibition as written.

With the rule fixed, state-document.cts goes red. Replace tableRowPattern
with locateFieldRow: a line scan using the markdown-table seam's
splitTableRow for cell semantics, returning the value cell's byte range, and
splice that range instead of running a whole-document content.replace. An
edit now physically cannot cross a row boundary (section 4).

Behavior is frozen -- stateReplaceField has 79 dependents across 5 command
processes. Characterization tests lock all 14 table-branch rows plus CRLF,
extract round-trip and the withFallback caller shape; a fast-check property
asserts every non-target line stays byte-identical.

Refs #2880, epic #2143

* fix(#2880): address adversarial review — lone-CR rows, field-name padding, quadratic scan, over-broad fingerprint

Isolated adversarial review found four defects in the first commit.

1. locateFieldRow split lines on \n only. JS treats a lone \r as a line
   terminator, so the regex it replaced matched rows separated by bare CR.
   "| Phase | 3 |\r| Other | 9 |" returned 3 before and null after. Now
   CR, LF and CRLF are all terminators, byte offsets unchanged.

2. The field name was normalised with trim().toLowerCase(). The old regex
   embedded it verbatim, so its whitespace had to be absorbed by the row's
   own padding -- and because the group is a literal-character match rather
   than a whitespace class, a tab-padded cell does not accept a
   space-padded name. Replaced with an offset-aligned search reproducing
   the original backtracking exactly.

3. The widened fingerprint regex had two unbounded [^\]]* around an
   optional and ran quadratically over every regex source in every linted
   file: 256000 chars took 23 seconds. Replaced with a single-pass scanner
   that never rescans; the same input is now ~1ms.

4. The fingerprint also matched non-table idioms such as [^\s|] and [^"|].
   Narrowed to a class excluding the pipe plus only \n, \r or \t.

Differential fuzz against origin/next: 20000 cases, 0 mismatches.

Refs #2880, epic #2143

* test(#2880): drop wall-clock assertion from the ReDoS regression guard

local/no-elapsed-assertion flagged the elapsed-time check, and CLAUDE.md
bans timing assertions outright as flaky. The 256000-char input stays as
the regression guard for the quadratic scan; correctness of the verdict is
what is asserted. If the quadratic path returns, the test stops completing
and surfaces as a suite timeout rather than a silent pass.

Also adds the changeset fragment for #2880.

Refs #2880

* fix(#2880): spec-correct case folding, property tests, naming

Code-review findings.

The field-name comparison used toLowerCase(). The regex it replaced used
/i WITHOUT /u, and ECMAScript Canonicalize deliberately does not fold a
non-ASCII character onto an ASCII one -- KELVIN SIGN U+212A matched ASCII
K where the old code returned null. Replaced with spec-correct
Canonicalize, including the multi-character uppercase case (eszett -> SS),
which a naive uppercase comparison also gets wrong.

Added the fast-check property tests CLAUDE.md requires for parsers: one
for the negated-class scanner, one for the field-name fold semantics, each
against an independent reference implementation. Both reference impls
failed on first run against real bugs, so neither property is vacuous.

Renamed p2/p3 to name the exactly-three-pipes invariant, and reduced a
duplicated comment to a cross-reference.

Differential fuzz vs origin/next: 20000 runs, 0 mismatches, with the
harness proven to discriminate the KELVIN case.

Refs #2880

* chore(#2880): backfill changeset PR number (#2889)

* docs(#2890): correct the local ESLint plugin path in CONTEXT.md

CONTEXT.md named the local AST-rule plugin directory as
scripts/eslint-rules/, which does not exist. The real location is
eslint-rules/ at the repo root -- what eslint.config.mjs actually
imports -- and CONTEXT.md's own later entry already says so
explicitly, so the file disagreed with itself.

Found by a line-by-line audit of all 1036 lines against the live
graph; this was the only confirmed inaccuracy.

Closes #2890

---------

Co-authored-by: Test <test@example.com>
This commit is contained in:
Tom Boucher
2026-07-30 19:55:03 -04:00
committed by GitHub
parent 7372d99a26
commit 4bd6fb066b
6 changed files with 864 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2889
---
**The markdown-parsing lint rule now catches the stricter cell-regex spelling it previously missed** — a hand-rolled table scan written as `[^|\n]` (excluding both the pipe and the newline, which is the more correct form) slipped past the guard entirely, so `STATE.md` field replacement kept parsing tables with a local regex and rewriting the whole document. The rule now flags any pipe-excluding character class, and the STATE.md field writer edits a bounded byte range instead. (#2880)

View File

@@ -407,7 +407,7 @@ A test that generates many adversarial inputs automatically (via `fast-check`) a
Stryker injects small code mutations (e.g., flipping a `>` to `>=`, deleting a `return` statement) and reruns the test suite for each. A mutation is "killed" if at least one test fails; "surviving" if all tests pass despite the mutation. Mutation score = killed / total. Score below 80 % on the changed scope blocks PR merge. See `RULESET.TESTS.mutation-score`.
### ESLint harness
The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint-harness.md`): ESLint flat config (`eslint.config.mjs`) with `typescript-eslint`, `eslint-plugin-n`, `eslint-plugin-no-only-tests`, and a local AST-rule plugin at `scripts/eslint-rules/`. Replaces the homegrown `scripts/lint-*.cjs` regex scanners. The three custom test-rigor rules (`local/no-source-grep`, `local/no-magic-sleep-in-tests`, `local/no-elapsed-assertion`) initially ship at `warn`; they become `error` after the cleanup sweep tracked at issue #453 merges.
The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint-harness.md`): ESLint flat config (`eslint.config.mjs`) with `typescript-eslint`, `eslint-plugin-n`, `eslint-plugin-no-only-tests`, and a local AST-rule plugin at `eslint-rules/`. Replaces the homegrown `scripts/lint-*.cjs` regex scanners. The three custom test-rigor rules (`local/no-source-grep`, `local/no-magic-sleep-in-tests`, `local/no-elapsed-assertion`) initially ship at `warn`; they become `error` after the cleanup sweep tracked at issue #453 merges.
### External-job-waiting half-state
A legal deferred state of an Execute step (`external_job_waiting`): the executor has dispatched a long-running async external job and committed an async-job manifest at `.planning/async-jobs/<job>.json` instead of a SUMMARY.md. Distinct from the synchronous "mid-production-commits" half-state and from an illegal partial-plan state. The core loop's step-completion + safe-resume/pause contract treats a non-terminal manifest as legal and reconciles against it (never re-dispatching the plan, which would duplicate the external job); SUMMARY.md is deferred until the job reaches a terminal state and its `expected_artifacts` are verified. The manifest is a versioned stability contract (`docs/reference/planning-artifacts.md`); core *consumes* it while a default-off scheduler-adapter Capability (#1164) *produces* it at `execute:wave:post` — the contract-is-core / producer-is-capability seam mirrors ADR-857's verification-substrate decision. Status enum is closed and scheduler-agnostic: `submitted`, `running`, `completed-unverified`, `failed`, `cancelled`, `timeout`.

View File

@@ -170,11 +170,92 @@ const rule = {
// [^\|]), indicating a hand-rolled table-row/cell scan such as /\|[^|]*\|/.
// Conservative by design: a bare escaped-pipe delimiter probe with no
// negated-pipe cell class is NOT flagged.
//
// The negated class qualifies ONLY when its body is the pipe (escaped or
// bare) plus zero or more of the two-character line-terminator/tab escapes
// `\n`, `\r`, `\t` (#2880): the common, and strictly MORE correct, spelling
// is `[^|\n]` (a GFM cell can span neither a pipe nor a line break) —
// `src/state-document.cts` used exactly that and evaded the rule entirely,
// which is the ADR-2143 §7 enforcement hole this widening closes. A
// negated class that excludes the pipe alongside anything ELSE — e.g.
// `[^\s|]`, `[^"|]`, `[^a-z|]` — is a different (non-table) idiom and is
// NOT flagged, and a negated class that does not exclude a pipe at all
// (`[^\n]`, `[^a-z]`) is still NOT a cell scan.
//
// Implemented as a single-pass scanner (not a regex) — see
// hasQualifyingNegatedPipeClass below for why the regex encoding of this
// fingerprint was rejected (quadratic-on-failure blowup).
function isTableRegexSource(src) {
// Must contain an escaped pipe
if (!src.includes('\\|')) return false;
// Must ALSO contain a negated-pipe cell-capture class: [^|] or [^\|]
return /\[\^\\?\|\]/.test(src);
return hasQualifyingNegatedPipeClass(src);
}
// ── Single-pass negated-character-class scanner (FIX 3 + FIX 4) ─────────
// Walks `src` once, left to right. On encountering a negated class
// `[^...]` it scans forward to the class's closing `]` (honoring `\`
// escapes within the class) exactly once, then resumes scanning
// immediately AFTER that `]` — never backtracking into the class body.
// This keeps the whole walk O(n) regardless of how many negated classes
// (or how large) the source contains — unlike the regex it replaces,
// /\[\^[^\]]*\\?\|[^\]]*\]/, which is quadratic on failure (two unbounded
// [^\]]* runs around an optional), measured at ~23s for a 256000-char
// adversarial input.
//
// Returns true if ANY negated class in `src` QUALIFIES as a hand-rolled
// GFM cell-capture class: it excludes a pipe (`\|` or bare `|`) and,
// after removing that pipe, every remaining member is one of the
// two-character line-terminator/tab escapes `\n`, `\r`, `\t` (zero extra
// members is fine — `[^|]` alone qualifies). A class that excludes the
// pipe alongside anything ELSE (`[^\s|]`, `[^"|]`, `[^a-z|]`) does NOT
// qualify — that is a different, non-table idiom. A class that never
// excludes a pipe at all (`[^\n]`, `[^a-z]`) does not qualify either.
function hasQualifyingNegatedPipeClass(src) {
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '[' && src[i + 1] === '^') {
let j = i + 2;
let classHasPipe = false;
let classIsPure = true;
let closed = false;
while (j < src.length) {
const cc = src[j];
if (cc === '\\') {
const next = src[j + 1];
if (next === '|') {
classHasPipe = true;
}
else if (next !== 'n' && next !== 'r' && next !== 't') {
classIsPure = false;
}
j += 2;
continue;
}
if (cc === ']') {
closed = true;
j += 1;
break;
}
if (cc === '|') {
classHasPipe = true;
}
else {
classIsPure = false;
}
j += 1;
}
if (closed && classHasPipe && classIsPure) return true;
i = closed ? j : src.length;
continue;
}
i += 1;
}
return false;
}
function isTableRegex(node) {

View File

@@ -7,6 +7,8 @@
* from the prior hand-written .cjs; only types are added.
*/
import { splitTableRow } from './markdown-table.cjs';
// Internal helpers
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -42,25 +44,171 @@ function isTableSeparatorRow(firstCell: string): boolean {
return /^[\s\-:]+$/.test(firstCell.trim());
}
function countLeading(str: string): number {
const match = /^[ \t]*/.exec(str);
return match ? match[0].length : 0;
}
/**
* Build a regex that matches a pipe-table row `| FieldName | value |` for the
* given (already-escaped) field name. The match is case-insensitive and
* tolerates variable amounts of whitespace around the cell contents.
*
* Capture group 1: leading pipe + whitespace before the field cell
* Capture group 2: the field name cell text (trimmed)
* Capture group 3: whitespace between field cell and separator pipe
* Capture group 4: the value cell text (trimmed)
* Capture group 5: trailing whitespace + closing pipe(s)
*
* We use a single-line match (`m` flag so ^ anchors work on each line) to
* avoid cross-row replacement.
* Canonicalize one UTF-16 code unit per the ECMAScript non-unicode
* `Canonicalize` abstract operation, which governs how a case-insensitive
* (`/i`, no `u` flag) RegExp compares characters: take `ch.toUpperCase()`.
* The uppercasing is REJECTED (the original character is kept as-is) in
* either of two cases: (1) `ch.toUpperCase()` does not produce exactly one
* character (e.g. "ß" -> "SS" — a multi-character case-fold can never be a
* per-character regex match, so Canonicalize leaves it alone), or (2) it
* produces exactly one character but the original character's code point is
* >= 128 while the uppercased character's code point is < 128 (this is what
* stops a non-ASCII character from folding onto an ASCII one under `/i` —
* e.g. KELVIN SIGN U+212A uppercases to ASCII "K" (U+004B), so this rule
* rejects the fold and keeps U+212A, meaning `/k/i`/`/K/i` do NOT match
* U+212A). Otherwise, the uppercased character is used. Plain
* `.toLowerCase()`/`.toUpperCase()` folds both of these cases, which is
* exactly why they diverge from real regex `/i` semantics.
*/
function tableRowPattern(escapedFieldName: string): RegExp {
return new RegExp(
`^(\\|[ \\t]*)(${escapedFieldName})([ \\t]*\\|[ \\t]*)([^|\\n]*?)([ \\t]*\\|[ \\t]*)$`,
'im',
);
function canonicalizeCharForCaselessCompare(ch: string): string {
const upper = ch.toUpperCase();
if (upper.length !== 1) {
return ch;
}
if (ch.charCodeAt(0) >= 128 && upper.charCodeAt(0) < 128) {
return ch;
}
return upper;
}
/**
* Canonicalize a whole string, one UTF-16 code unit at a time, per the
* ECMAScript non-unicode `Canonicalize` rule (see
* canonicalizeCharForCaselessCompare) so that two strings compare equal
* under this function iff a non-`u`-flag `/i` RegExp would treat them as
* the same literal text. This is the correct replacement for
* `.toLowerCase()` when replicating a non-`u` `/i` regex: `.toLowerCase()`
* folds some non-ASCII characters (e.g. KELVIN SIGN U+212A) onto their
* ASCII counterparts, which real `/i` regex semantics do not. Iteration is
* by UTF-16 code unit (not code point) to match how a non-`u` regex engine
* itself operates on surrogate halves individually.
*/
function canonicalizeForCaselessCompare(str: string): string {
let result = '';
for (let i = 0; i < str.length; i++) {
result += canonicalizeCharForCaselessCompare(str[i]);
}
return result;
}
/**
* Return true when the caller's raw (untrimmed) `fieldName` may be considered
* to match a row's raw (untrimmed) field cell text. Faithfully replicates the
* backtracking of the regex this function replaced: `^(\|[ \t]*)(FieldName)
* ([ \t]*\|...)`. Group 1 (`\|[ \t]*`, greedy but backtrackable) can hand any
* PREFIX of the cell's leading `[ \t]` run over to group 2 (the literal,
* case-insensitive `fieldName` text) — so `fieldName` is tried at every offset
* `j` from 0 up to the length of that leading run. For a given `j` to be a
* genuine match, two things must hold: `rawCell.slice(j, j + fieldName.length)`
* must equal `fieldName` case-insensitively (group 2), AND everything left
* over after it — `rawCell.slice(j + fieldName.length)` — must be entirely
* `[ \t]` characters, because group 3 (`[ \t]*\|`) must consume that leftover
* as whitespace before it can reach the delimiter pipe.
*
* A simple count-of-leading/trailing-whitespace comparison is NOT equivalent:
* it ignores that group 2 is a literal-character match, not a whitespace-
* class match, so it can produce false positives whenever `fieldName`'s own
* padding is a different run of `[ \t]` characters than the cell's (e.g.
* `fieldName` padded with spaces against a cell padded with tabs) — caught by
* differential fuzzing against the regex this replaces.
*
* The case-insensitive comparison itself is done via
* canonicalizeForCaselessCompare, NOT `.toLowerCase()`: the replaced regex
* used `/i` WITHOUT the `u` flag, whose case-folding is the ECMAScript
* non-unicode `Canonicalize` operation. `.toLowerCase()` folds some non-ASCII
* characters onto ASCII ones (e.g. KELVIN SIGN U+212A -> "k") that `/i`
* (no `u`) does NOT fold, so `.toLowerCase()` alone would NOT faithfully
* replicate the old regex's semantics; canonicalizeForCaselessCompare does.
*/
function fieldNameMatchesRawCell(fieldName: string, rawCell: string): boolean {
const n = fieldName.length;
const cellLength = rawCell.length;
if (n > cellLength)
return false;
const leadingRun = countLeading(rawCell);
const maxOffset = Math.min(leadingRun, cellLength - n);
const canonicalFieldName = canonicalizeForCaselessCompare(fieldName);
for (let j = 0; j <= maxOffset; j++) {
if (canonicalizeForCaselessCompare(rawCell.slice(j, j + n)) !== canonicalFieldName)
continue;
if (/^[ \t]*$/.test(rawCell.slice(j + n)))
return true;
}
return false;
}
/**
* Locate the value cell of a pipe-table row `| FieldName | value |` for the
* given field name, by scanning `content` line by line (no whole-document
* regex). Only a strict two-column row (exactly 3 `|` chars, starting the
* line, ending the line after trailing space/tab is stripped) is considered;
* this is what makes a 3-column row or an unescaped-pipe-bearing value cell
* fail to match, mirroring the previous regex's behaviour. Separator rows
* (`| --- | --- |`) are skipped, not matched. The match is case-insensitive.
* A line terminator is `\r\n`, a lone `\r`, or a lone `\n` — matching the `m`
* flag semantics of the regex this function replaced. Returns the byte range
* of the value cell (after trimming surrounding space/tab) so the caller can
* splice it directly.
*/
function locateFieldRow(content: string, fieldName: string): { valueStart: number; valueEnd: number; rawValue: string } | null {
let lineStart = 0;
while (lineStart <= content.length) {
// A line terminator is `\r\n`, a lone `\r`, or a lone `\n` (JS treats a
// bare `\r` as a line terminator too — the regex this replaced used the
// `m` flag, which honors all three). Scan for whichever of `\r`/`\n`
// occurs first; if it's `\r` immediately followed by `\n`, the terminator
// is 2 chars wide, otherwise 1.
let terminatorIndex = -1;
let terminatorLength = 0;
for (let i = lineStart; i < content.length; i++) {
const ch = content[i];
if (ch === '\n') {
terminatorIndex = i;
terminatorLength = 1;
break;
}
if (ch === '\r') {
terminatorIndex = i;
terminatorLength = content[i + 1] === '\n' ? 2 : 1;
break;
}
}
const lineEnd = terminatorIndex === -1 ? content.length : terminatorIndex;
const line = content.slice(lineStart, lineEnd);
if (line.startsWith('|')) {
const pipeCount = (line.match(/\|/g) || []).length;
const trimmedEnd = line.replace(/[ \t]+$/, '');
if (pipeCount === 3 && trimmedEnd.endsWith('|')) {
const cells = splitTableRow(line);
if (cells.length === 2 && !isTableSeparatorRow(cells[0])) {
// Line has exactly 3 pipes (enforced above): opening pipe, the
// field/value separator pipe, and the row-closing pipe.
const fieldValueSeparatorPipe = line.indexOf('|', line.indexOf('|') + 1);
const rawCell = line.slice(1, fieldValueSeparatorPipe);
if (fieldNameMatchesRawCell(fieldName, rawCell)) {
const rowClosingPipe = line.indexOf('|', fieldValueSeparatorPipe + 1);
let valueStart = lineStart + fieldValueSeparatorPipe + 1;
while (content[valueStart] === ' ' || content[valueStart] === '\t')
valueStart++;
let valueEnd = lineStart + rowClosingPipe;
while (valueEnd - 1 >= valueStart && (content[valueEnd - 1] === ' ' || content[valueEnd - 1] === '\t'))
valueEnd--;
return { valueStart, valueEnd, rawValue: content.slice(valueStart, valueEnd) };
}
}
}
}
if (terminatorIndex === -1)
break;
lineStart = terminatorIndex + terminatorLength;
}
return null;
}
export function stateExtractField(content: string, fieldName: string): string | null {
@@ -77,9 +225,9 @@ export function stateExtractField(content: string, fieldName: string): string |
return plainMatch[1].trim();
// Pipe-table format: | FieldName | value |
// (Separator rows such as `| --- | --- |` are excluded.)
const tableMatch = content.match(tableRowPattern(escaped));
if (tableMatch && !isTableSeparatorRow(tableMatch[2]))
return tableMatch[4].trim();
const hit = locateFieldRow(content, fieldName);
if (hit)
return hit.rawValue.trim();
return null;
}
@@ -97,13 +245,9 @@ export function stateReplaceField(content: string, fieldName: string, newValue:
}
// Pipe-table format: | FieldName | value |
// Preserve the surrounding pipe/whitespace structure; only swap the value cell.
const tblPat = tableRowPattern(escaped);
const tblMatch = content.match(tblPat);
if (tblMatch && !isTableSeparatorRow(tblMatch[2])) {
// Reconstruct the row, preserving the original surrounding whitespace/pipes.
return content.replace(tblPat, (_m, leadPipe: string, fieldCell: string, midPipe: string, _oldVal: string, trailPipe: string) =>
`${leadPipe}${fieldCell}${midPipe}${newValue}${trailPipe}`,
);
const hit = locateFieldRow(content, fieldName);
if (hit) {
return content.slice(0, hit.valueStart) + newValue + content.slice(hit.valueEnd);
}
return null;
}

View File

@@ -14,6 +14,7 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { RuleTester } = require('eslint');
const fc = require('fast-check');
const noSourceGrep = require('../eslint-rules/no-source-grep.cjs');
const noMagicSleepInTests = require('../eslint-rules/no-magic-sleep-in-tests.cjs');
@@ -1295,4 +1296,296 @@ describe('no-adhoc-markdown-parsing rule', () => {
invalid: [],
});
});
// ── TABLE-REGEX widening: [^|\n] and escaped-pipe-plus-others classes (#2880) ──
test('invalid: content.replace(<inline [^|\\n] cell-class regex>) — the exact shape that evaded the rule before #2880', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
// /\|[^|\n]*\|/ — pipe-excluding cell class ALSO excludes newline; this
// is the src/state-document.cts shape that the sole-member-class check
// missed prior to the #2880 widening.
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x');`,
filename: 'src/state-document.cts',
// CallExpression is the outer/enter-first node (adhocReplaceMutation);
// its Literal argument (visited next, on descent) is the second,
// independent tableRegex finding — same ordering as the established
// roadmapContent.replace(...) case above.
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
},
],
});
});
test('invalid: factory function returning new RegExp(<template literal with [^|\\n] cell class>)', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
code: 'function buildRowPattern() {\n return new RegExp(`\\\\|[^|\\\\n]*\\\\|`, \'im\');\n}',
filename: 'src/state-document.cts',
errors: [{ messageId: 'tableRegex' }],
},
],
});
});
test('invalid: cell class with the pipe escaped alongside another excluded member [^\\|\\n]', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
code: String.raw`const cellRe = /\|[^\|\n]*\|/;`,
filename: 'src/state-document.cts',
errors: [{ messageId: 'tableRegex' }],
},
],
});
});
test('valid: negated class with NO pipe at all is not a cell scan (e.g. /^[^\\n]*$/)', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [
{
code: String.raw`content.replace(/^[^\n]*$/, 'x');`,
filename: 'src/state-document.cts',
},
],
invalid: [],
});
});
test('invalid: body.replace(...) — non-matching receiver name (bounded withSection callback) suppresses ONLY adhocReplaceMutation; the regex literal itself is still an independent tableRegex finding', () => {
// The ADHOC-REPLACE-MUTATION check is scoped to receivers matching
// /roadmap|state|reqContent|content/i — "body" (the withSection callback
// parameter name) does not match, so no adhocReplaceMutation fires here.
// But the standalone Literal visitor inspects EVERY regex literal in the
// file regardless of call-site context, so the pipe-excluding-class regex
// is still caught as a bare tableRegex finding either way.
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
code: String.raw`body.replace(/\|[^|\n]*\|/, 'x');`,
filename: 'src/state-document.cts',
errors: [{ messageId: 'tableRegex' }],
},
],
});
});
test('valid: allow-adhoc-markdown suppresses the widened [^|\\n] shape', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [
{
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x'); // allow-adhoc-markdown: reason`,
filename: 'src/state-document.cts',
},
],
invalid: [],
});
});
// ── TABLE-REGEX narrowing: negated class excluding pipe + something ELSE
// is a different (non-table) idiom, not flagged (#2880 FIX 4) ───────────
test('valid: [^\\s|] (pipe excluded alongside \\s, not a pure line-terminator class) is NOT flagged', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [
{
code: String.raw`const re = /[^\s|]+\|cmd/;`,
filename: 'src/some-module.cts',
},
],
invalid: [],
});
});
test('valid: [^"|] (pipe excluded alongside a quote) is NOT flagged', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [
{
code: String.raw`const re = /\|[^"|]*\|/;`,
filename: 'src/some-module.cts',
},
],
invalid: [],
});
});
test('invalid: [^|\\r\\n] (pipe plus only line-terminator escapes) IS flagged', () => {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
code: String.raw`const rowRe = /\|[^|\r\n]*\|/;`,
filename: 'src/some-module.cts',
errors: [{ messageId: 'tableRegex' }],
},
],
});
});
test('performance: a 256000-char adversarial regex-literal source does not hang the rule (ReDoS regression)', () => {
// The previous regex-based fingerprint, /\[\^[^\]]*\\?\|[^\]]*\]/, was
// quadratic on failure — an unclosed negated class of this size took
// ~23s. The single-pass scanner must stay linear. The adversarial text is
// embedded directly inside a single string-literal argument to
// `new RegExp(...)` (not built via `+` at the source-code level under
// test) so `getNewRegExpSource` actually resolves it and the scanner
// walks the full 256000-char unclosed negated class.
const bigPipeRun = '|'.repeat(256000);
const code = `const re = new RegExp('\\\\|[^${bigPipeRun}');`;
// The 256000-char input is the regression guard for the O(n^2) scan fixed
// in #2880: the pre-fix regex took ~23s on this input. There is deliberately
// no elapsed-time assertion (banned by local/no-elapsed-assertion and flaky
// by nature) — if the quadratic path is ever reintroduced this test stops
// completing, which surfaces as a suite timeout rather than a silent pass.
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [
{
code,
filename: 'src/some-module.cts',
},
],
invalid: [],
});
});
// ── property test: negated-pipe-class scanner (hasQualifyingNegatedPipeClass) ──
test('property: single-pass negated-class scanner verdict matches an independent reference implementation', () => {
// hasQualifyingNegatedPipeClass is a closure private to the rule's
// `create(context)` — it cannot be called directly, so it is exercised
// through the public surface: `new RegExp(<string literal>)` feeds
// `arg.value` through UNCHANGED as the "effective regex source" (see
// getNewRegExpSource), so any generated string, however malformed as a
// real regex, reaches the scanner byte-for-byte via JSON.stringify(...).
// A guaranteed literal `\|` is prepended so isTableRegexSource's OTHER
// gate (`src.includes('\\|')`) is always satisfied — the property is then
// solely a probe of the negated-class scanner's own verdict, matching the
// instruction to test the scanner in isolation.
//
// Reference implementation (independent tokenizer, NOT a copy of the
// scanner under test): tokenize `src` once into {esc, text} units,
// tracking escapes; then walk the tokens with a MONOTONIC cursor: on
// finding a `[` char-token immediately followed by a `^` char-token,
// consume forward to the first unescaped `]` (or to the end if there is
// none) as a single committed unit, decide qualification for that unit,
// and resume scanning strictly AFTER whatever was consumed — a class
// candidate found INSIDE an already-consumed (opened) class body is
// never separately reconsidered. Qualifies iff the collected body
// contains a pipe (bare `|` or escaped `\|`) AND every other member is
// one of the escapes `\n`, `\r`, `\t`.
function referenceHasQualifyingNegatedPipeClass(src) {
const tokens = [];
let i = 0;
while (i < src.length) {
if (src[i] === '\\') {
const next = i + 1 < src.length ? src[i + 1] : '';
tokens.push({ esc: true, text: next });
i += 2;
}
else {
tokens.push({ esc: false, text: src[i] });
i += 1;
}
}
let t = 0;
while (t < tokens.length) {
const opensClass = !tokens[t].esc && tokens[t].text === '['
&& t + 1 < tokens.length && !tokens[t + 1].esc && tokens[t + 1].text === '^';
if (!opensClass) {
t += 1;
continue;
}
let u = t + 2;
const members = [];
let closed = false;
while (u < tokens.length) {
const tok = tokens[u];
if (!tok.esc && tok.text === ']') {
closed = true;
u += 1;
break;
}
members.push(tok);
u += 1;
}
if (closed) {
let hasPipe = false;
let isPure = true;
for (const m of members) {
if (!m.esc && m.text === '|') {
hasPipe = true;
continue;
}
if (m.esc && m.text === '|') {
hasPipe = true;
continue;
}
if (m.esc && (m.text === 'n' || m.text === 'r' || m.text === 't')) continue;
isPure = false;
}
if (hasPipe && isPure) return true;
}
// Monotonic advance: whether this candidate qualified, failed, or
// ran off the end unclosed, never re-enter the bytes just consumed.
t = closed ? u : tokens.length;
}
return false;
}
// Composed of random characters PLUS randomly inserted `[^...]` classes
// with and without pipes (some closed, some not; some qualifying, some
// not) so both the "flagged" and "not flagged" verdicts are well
// exercised — a purely uniform character soup almost never assembles a
// well-formed `[^...|...]` class by chance.
const pipeMemberArb = fc.constantFrom('|', '\\|');
const pureFillerArb = fc.constantFrom('\\n', '\\r', '\\t');
const impureFillerArb = fc.constantFrom('a', 'Z', '1', '\\s', '\\d', '\\w', '\\\\', '-', ' ', '\\]', '\\[');
const classMemberArb = fc.oneof(pipeMemberArb, pureFillerArb, impureFillerArb);
const classBodyArb = fc.array(classMemberArb, { minLength: 1, maxLength: 3 }).map((members) => members.join(''));
const classChunkArb = fc
.record({ body: classBodyArb, closed: fc.boolean() })
.map(({ body, closed }) => '[^' + body + (closed ? ']' : ''));
const noiseCharArb = fc.constantFrom('[', ']', '^', 'x', 'y', '0', '9', ' ', '.', '-', '(', ')');
const escapeChunkArb = fc
.tuple(fc.constant('\\'), fc.constantFrom('n', 'r', 't', '|', 's', 'd', '\\', '[', ']', '^', 'a'))
.map(([bs, c]) => bs + c);
const chunkArb = fc.oneof(
{ weight: 5, arbitrary: classChunkArb },
{ weight: 2, arbitrary: escapeChunkArb },
{ weight: 2, arbitrary: noiseCharArb },
);
const srcArb = fc.array(chunkArb, { minLength: 0, maxLength: 5 }).map((chunks) => chunks.join(''));
fc.assert(
fc.property(srcArb, (fuzzed) => {
const src = '\\|' + fuzzed;
const expected = referenceHasQualifyingNegatedPipeClass(src);
const code = `const re = new RegExp(${JSON.stringify(src)});`;
if (expected) {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [],
invalid: [
{
code,
filename: 'src/some-module.cts',
errors: [{ messageId: 'tableRegex' }],
},
],
});
}
else {
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
valid: [{ code, filename: 'src/some-module.cts' }],
invalid: [],
});
}
}),
{ numRuns: 200, seed: 2880 },
);
});
});

View File

@@ -0,0 +1,311 @@
'use strict';
/**
* state-document.test.cjs
*
* Characterization tests for the STATE.md pipe-table branch of
* stateReplaceField / stateExtractField / stateReplaceFieldWithFallback
* (issue #2880, ADR-2143 §3/§4). These lock byte-identical behaviour across
* the migration of the table branch off a hand-rolled whole-document regex
* onto a line-scan + byte-range splice (see gsd-core/bin/lib/state-document.cjs
* locateFieldRow). Any future re-implementation of the table branch must keep
* every assertion below true.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const {
stateReplaceField,
stateExtractField,
stateReplaceFieldWithFallback,
} = require('../gsd-core/bin/lib/state-document.cjs');
describe('stateReplaceField — table branch (characterization, #2880)', () => {
test('replaces a two-cell row in place', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('returns null for a three-cell row', () => {
const input = '| Current Phase | 3 | x |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('matches the field name case-insensitively and preserves its original casing', () => {
const input = '| current phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| current phase | 7 |');
});
test('replaces a row that has a header and delimiter above it', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |'].join('\n');
const expected = ['| F | V |', '| --- | --- |', '| Current Phase | 7 |'].join('\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('replaces a header-less legacy row', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('replaces only the first of two rows naming the same field', () => {
const input = ['| Current Phase | 3 |', '| Current Phase | 9 |'].join('\n');
const expected = ['| Current Phase | 7 |', '| Current Phase | 9 |'].join('\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('returns null when the value cell contains a pipe', () => {
const input = '| Current Phase | a|b |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('inserts before the closing pipe when the value cell is all whitespace', () => {
const input = '| Current Phase | |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7|');
});
test('never treats a delimiter row as a field', () => {
const input = '| --- | --- |';
const result = stateReplaceField(input, '---', 7);
assert.equal(result, null);
});
test('ignores an indented row', () => {
const input = ' | Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('inserts a dollar-sign pattern verbatim', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', '$&X');
assert.equal(result, '| Current Phase | $&X |');
});
test("preserves the row's exact interior padding", () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('returns null when the field is absent', () => {
const input = '| Other | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('returns null for empty content', () => {
const result = stateReplaceField('', 'Current Phase', 7);
assert.equal(result, null);
});
});
describe('stateReplaceField — CRLF (#2880)', () => {
test('preserves CRLF line endings byte-for-byte', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |', ''].join('\r\n');
const expected = ['| F | V |', '| --- | --- |', '| Current Phase | 7 |', ''].join('\r\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('replaces a bold field on a CRLF document', () => {
const input = ['**Status:** old', ''].join('\r\n');
const expected = ['**Status:** new', ''].join('\r\n');
const result = stateReplaceField(input, 'Status', 'new');
assert.equal(result, expected);
});
test('replaces a row terminated by a lone carriage return', () => {
const input = ['| Phase | 3 |', '| Other | 9 |'].join('\r');
const expected = ['| Phase | 5 |', '| Other | 9 |'].join('\r');
const result = stateReplaceField(input, 'Phase', 5);
assert.equal(result, expected);
});
});
describe('stateExtractField (#2880)', () => {
test('extracts from a two-cell row', () => {
const input = '| Current Phase | 3 |';
assert.equal(stateExtractField(input, 'Current Phase'), '3');
});
test('extracts from a CRLF document', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |', ''].join('\r\n');
assert.equal(stateExtractField(input, 'Current Phase'), '3');
});
test('returns null when absent', () => {
const input = '| Other | 3 |';
assert.equal(stateExtractField(input, 'Current Phase'), null);
});
test('round-trip: extract after replace returns the new value', () => {
const input = '| Current Phase | 3 |';
const replaced = stateReplaceField(input, 'Current Phase', '7');
assert.equal(stateExtractField(replaced, 'Current Phase'), '7');
});
test('matches a row terminated by a lone carriage return', () => {
const input = ['| Phase | 3 |', '| Other | 9 |'].join('\r');
assert.equal(stateExtractField(input, 'Phase'), '3');
});
test('does not match when the field name has more padding than the cell', () => {
const input = '| Phase | 3 |';
assert.equal(stateExtractField(input, ' Phase '), null);
});
});
describe('stateReplaceFieldWithFallback (#2880)', () => {
test('uses the primary when present', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceFieldWithFallback(input, 'Current Phase', 'Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('falls back to the secondary name when the primary is absent', () => {
const input = '| Phase | 3 |';
const result = stateReplaceFieldWithFallback(input, 'Current Phase', 'Phase', 7);
assert.equal(result, '| Phase | 7 |');
});
test('returns the content UNCHANGED (not null) when both are absent', () => {
const content = '| Other | 3 |';
const result = stateReplaceFieldWithFallback(content, 'Current Phase', 'Phase', 7);
assert.equal(result, content);
});
});
describe('property: bounded mutation (#2880, ADR-2143 §4)', () => {
test('replacing one row leaves every other line byte-identical', () => {
const safeValue = fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 '.split('')), {
minLength: 1,
maxLength: 8,
})
.map((chars) => chars.join('').trim() || 'x');
fc.assert(
fc.property(
fc.integer({ min: 1, max: 6 }).chain((n) =>
fc.record({
n: fc.constant(n),
values: fc.array(safeValue, { minLength: n, maxLength: n }),
targetIndex: fc.integer({ min: 0, max: n - 1 }),
newValue: safeValue,
}),
),
({ n, values, targetIndex, newValue }) => {
const fieldNames = Array.from({ length: n }, (_, i) => `Field${i}`);
const lines = fieldNames.map((name, i) => `| ${name} | ${values[i]} |`);
const doc = lines.join('\n');
const result = stateReplaceField(doc, fieldNames[targetIndex], newValue);
assert.notEqual(result, null);
const resultLines = result.split('\n');
assert.equal(resultLines.length, lines.length);
for (let i = 0; i < lines.length; i++) {
if (i === targetIndex) continue;
assert.equal(resultLines[i], lines[i]);
}
assert.equal(resultLines[targetIndex], `| ${fieldNames[targetIndex]} | ${newValue} |`);
},
),
{ seed: 20880, numRuns: 200 },
);
});
});
describe('property: fieldNameMatchesRawCell case-fold semantics, via stateExtractField (FIX A)', () => {
test('match verdict agrees with an explicit ECMAScript non-unicode Canonicalize reference predicate', () => {
// Independent, obviously-correct reference for "are these two code units
// the same character under a non-`u`-flag `/i` RegExp": NOT
// `.toLowerCase()`, which incorrectly folds some non-ASCII characters
// (e.g. KELVIN SIGN U+212A) onto their ASCII counterparts ("k"), and
// also incorrectly folds multi-character uppercase mappings (e.g. "ß"
// toUpperCase()'s "SS") onto a two-character string. Per ECMAScript's
// non-unicode Canonicalize, a fold is REJECTED (the original character
// is kept as-is) whenever `ch.toUpperCase()` is not exactly one
// character, OR the original is non-ASCII (>= 128) while the uppercased
// result is ASCII (< 128).
function canonChar(ch) {
const upper = ch.toUpperCase();
if (upper.length !== 1) return ch;
if (ch.charCodeAt(0) >= 128 && upper.charCodeAt(0) < 128) return ch;
return upper;
}
function canonStringEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (canonChar(a[i]) !== canonChar(b[i])) return false;
}
return true;
}
// Reference predicate for the whole field-name/cell match, stated
// independently of (and structured differently from) the scanner under
// test: `fieldName` matches `rawCell` iff `fieldName` occurs as a literal
// (Canonicalize-compared) substring of `rawCell` at SOME offset `j`, with
// everything BEFORE `j` and everything AFTER the occurrence consisting
// exclusively of ' '/'\t'. This is a full, unbounded substring scan with
// no "leading run" shortcut.
function referenceFieldMatchesCell(fieldName, rawCell) {
const n = fieldName.length;
for (let j = 0; j + n <= rawCell.length; j++) {
const before = rawCell.slice(0, j);
const after = rawCell.slice(j + n);
if (!/^[ \t]*$/.test(before)) continue;
if (!/^[ \t]*$/.test(after)) continue;
if (canonStringEqual(rawCell.slice(j, j + n), fieldName)) return true;
}
return false;
}
const padArb = fc.array(fc.constantFrom(' ', '\t'), { minLength: 0, maxLength: 3 }).map((chars) => chars.join(''));
// Deliberately includes the KELVIN SIGN (U+212A) and other non-ASCII
// characters whose `.toLowerCase()`/`.toUpperCase()` folds onto an ASCII
// character — exactly the class of character FIX A addresses.
const coreCharArb = fc.constantFrom('a', 'b', 'K', 'k', 'P', 'p', 'H', 'A', '\u212A', '\u1E9E', '\u00DF', '1', '_');
const coreArb = fc.array(coreCharArb, { minLength: 1, maxLength: 5 }).map((chars) => chars.join(''));
const fieldNameArb = fc
.record({ pre: padArb, core: coreArb, post: padArb })
.map(({ pre, core, post }) => pre + core + post);
fc.assert(
fc.property(
fieldNameArb,
fc.boolean(),
fc.constantFrom('same', 'upper', 'lower'),
padArb,
padArb,
coreArb,
(fieldName, deriveFromFieldName, caseMode, cellPre, cellPost, randomCore) => {
const fieldCore = fieldName.replace(/^[ \t]+|[ \t]+$/g, '');
let derivedCore = fieldCore;
if (caseMode === 'upper') derivedCore = fieldCore.toUpperCase();
else if (caseMode === 'lower') derivedCore = fieldCore.toLowerCase();
const cellCore = deriveFromFieldName ? derivedCore : randomCore;
const cellContent = cellPre + cellCore + cellPost;
// The template fixes exactly one literal space on each side of
// cellContent, so rawCell (as locateFieldRow computes it) is
// ` ${cellContent} ` byte-for-byte.
const doc = `| ${cellContent} | value |`;
const rawCell = ` ${cellContent} `;
const expected = referenceFieldMatchesCell(fieldName, rawCell);
const result = stateExtractField(doc, fieldName);
assert.equal(result, expected ? 'value' : null);
},
),
{ seed: 28801, numRuns: 300 },
);
});
});