* 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>
312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
'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 },
|
|
);
|
|
});
|
|
});
|