llm-security/scripts/golden-baseline.mjs
Kjell Tore Guttormsen 8d990e06d3 test(llm-security): v8 Phase 5 step 1 - golden baseline before any table swap
Records the reference artifacts Phase 5 swaps will be measured against, and
the gate that reads them. No extraction yet: this is the "before" picture,
and it had to land first or every later comparison would be confounded.
Typed test/ rather than feat/ deliberately - the only production change is
one widened export; the rest is gate, artifacts and generator.

Written failing-first (7 red on missing artifacts), then generated.

Three layers, because the plan's "assert .source/.flags of every regex" is
necessary but not sufficient:

  1. regex records - .source/.flags off the COMPILED object. After a swap a
     pattern is new RegExp(jsonString, flags), so the plan's named hazard
     (JSON backslash-doubling on 83+18 regexes) is visible here and nowhere
     else. Source-text comparison cannot see it.
  2. table records - key/value digests. Most of what Phase 4 moves is not a
     regex at all: HOMOGLYPH_MAP (x3, AS-IS), the typosquat tokens and the
     four OWASP maps are char->char and string->string data. A regex-only
     dump is blind to a broken homoglyph swap, i.e. to the bulk of the
     payload. Operator decision: widen the dump.
  3. file records - sha256 of the five moving-set sources. This dissolves
     STATE's open question (how to enumerate every regex): it is complete by
     construction, covering inline regexes in function bodies that no export
     walk reaches, with no JS parser in a zero-dep repo. A lexical count
     would have pinned a lie - severity.mjs scores 4 "regexes" that way and
     exports none.

Both layers were proven to fire, not assumed to: mutating one HOMOGLYPH_MAP
entry reddens the table layer, and widening an inline regex inside
decodeHexEscapes (unreachable by any export walk) reddens the file layer.

HOMOGLYPH_MAP is now exported from string-utils.mjs. That export is a source
change the plan already flags as a surface hazard ("private tables become
loaded"), so it is pre-paid here rather than confounding the before/after.

Reference run: the 61 showcase payloads through the real hook entry points,
sequentially - array order is semantic and the plan forbids key-sorting, so
concurrency is removed rather than sorted away. 61/61 match expectation,
which also settles the plan's open assumption that payloads.json expectations
match current behaviour. Coverage is recorded, not assumed: 47/83 patterns,
with the other 36 listed by key so the gate never implies coverage it lacks.

Suite counts are per-file, each file run alone - a total is unattributable,
and the three known timing-sensitive files flake only under concurrency.
2045 pass / 0 fail across 91 files, matching the pre-existing count exactly.
The gate's own file is excluded (it reads the artifact the run produces) and
that exclusion is named in the artifact.

Suite: 2053/2053, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
2026-08-09 12:57:51 +02:00

88 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
// golden-baseline.mjs — regenerate the v8 Phase 5 golden baseline artifacts.
//
// node scripts/golden-baseline.mjs # print a summary, write nothing
// node scripts/golden-baseline.mjs --write # (re-)bless the artifacts
//
// Re-blessing is a deliberate act. During Phase 5, a diff here means the
// table swap under test changed observable behaviour — the correct response
// is to roll the swap back, not to re-run this with --write.
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildPatternDump,
buildReferenceRun,
buildSuiteCounts,
serialize,
} from './lib/golden-dump.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const GOLDEN_DIR = resolve(ROOT, 'tests/golden');
const write = process.argv.includes('--write');
// Opt-in: one node process per test file, sequentially. Minutes, not seconds.
const withSuite = process.argv.includes('--suite');
const artifacts = [
['patterns.json', await buildPatternDump(ROOT)],
['reference-run.json', await buildReferenceRun(ROOT)],
];
/** `fs.globSync` is Node 22+; package.json promises >=18, so walk by hand. */
function findTestFiles(dir, acc = []) {
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
const full = resolve(dir, e.name);
if (e.isDirectory()) findTestFiles(full, acc);
else if (e.name.endsWith('.test.mjs')) acc.push(full);
}
return acc;
}
if (withSuite) {
artifacts.push([
'suite-counts.json',
buildSuiteCounts(ROOT, findTestFiles(resolve(ROOT, 'tests'))),
]);
}
mkdirSync(GOLDEN_DIR, { recursive: true });
let drift = false;
for (const [name, data] of artifacts) {
const path = resolve(GOLDEN_DIR, name);
const next = serialize(data);
const prev = existsSync(path) ? readFileSync(path, 'utf8') : null;
const status = prev === null ? 'new' : prev === next ? 'unchanged' : 'CHANGED';
if (status === 'CHANGED') drift = true;
if (write) writeFileSync(path, next);
console.log(`${write ? 'wrote' : 'checked'} tests/golden/${name} [${status}]`);
}
const [, dump] = artifacts[0];
const [, run] = artifacts[1];
console.log(
`\n regex records : ${dump.counts.regex}` +
`\n table records : ${dump.counts.table}` +
`\n file digests : ${dump.counts.file}` +
`\n reference run : ${run.summary.total} cases, ` +
`${run.summary.matchingExpectation} match expectation` +
`\n coverage : ${run.coverage.patternsExercised}/${run.coverage.patternsTotal} patterns` +
` | HOMOGLYPH_MAP ${run.coverage.tablesExercised.HOMOGLYPH_MAP ? 'exercised' : 'NOT exercised'}`
);
const suite = artifacts.find(([n]) => n === 'suite-counts.json');
if (suite) {
const [, s] = suite;
console.log(
` suite : ${s.totals.pass} pass / ${s.totals.fail} fail across ${s.totals.files} files`
);
}
if (!write && drift) {
console.error('\nBaseline drift detected. Investigate before re-blessing with --write.');
process.exit(1);
}