Skip to content

Commit 46dad29

Browse files
os-zhuangclaude
andcommitted
fix(scripts): guard the lintText half of the fatal-parse adoption check
`checkGuardAdoption()` asked whether `lintFilesStrict(` appears. A gate that KEPT one guarded call and measured a second population through `eslint.lintText()` answered yes to all three of its questions while that second population went entirely unguarded — ESLint returns a parse failure as a message with no rule id either way, so the count silently drops it. Measured through the checker as #10598 left it: import + `eslint.lintText(...)`, no strict call -> 1 problem (not armed) import + strict call + `eslint.lintText(...)` -> 0 problems <- the hole so the lintText-ONLY shape was already caught by #10598's positive assertion; the MIXED shape was not. And it was not hypothetical: this gate's own self-test counted through a bare `lintText()`. The `argument 1, object literal` report fixture with its closing paren removed gave hits()=0 with fatalErrorCount=1 — and hits()===0 is exactly what the ten `silent` cases assert, so a fixture that stopped parsing read as proof the rule is quiet. A blanket `.lintText(` ban was not available: the gate legitimately lints text to establish what raw ESLint does with a file that will not parse, which is ground truth the guard is built on and would be circular through the guard. Source text cannot tell that call from a measurement — which result gets COUNTED is data flow. So the check does not guess. It bans the BARE spelling and the gate declares which kind each call is: `lintTextStrict()` when the result is counted, `lintTextUnguarded({ why })` when it is not. - `lintTextStrict()` — the lintText twin of `lintFilesStrict()`, proved both ways at runtime, with the guard's own options kept out of what ESLint sees. - `lintTextUnguarded({ why })` — behaviour: none. It exists to be typed, and throws on an undeclared call so the escape hatch cannot be a rubber stamp. - The armed test now accepts EITHER strict entry point: a gate whose whole population is text never calls `lintFilesStrict(`, and reporting it unguarded would be a false positive of this change's own making. - 5 new adoption fixtures, including the reproduction and the two negative controls. FIXTURE_COUNT is now `+`-spelled for the same reason #10598's fixtures are: `stripComments` keeps string literals, so a contiguous `.lintText(` in this file's own fixtures would report this gate as unguarded. Fixes #10599 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
1 parent bde0ab9 commit 46dad29

2 files changed

Lines changed: 244 additions & 11 deletions

File tree

‎scripts/check-query-options-erasure-ratchet.mjs‎

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ import {
102102
collectFatalMessages,
103103
guardAdoptionProblems,
104104
lintFilesStrict,
105+
lintTextStrict,
106+
lintTextUnguarded,
105107
} from './eslint-fatal-guard.mjs';
106108
import {
107109
HEADROOM_CANARY_FILE,
@@ -288,12 +290,16 @@ function baselineKeysAddedSinceMergeBase(baselineKeys) {
288290
const FIXTURE_CALL_STRICT = 'const results = await lintFilesStrict' + '(eslint, [TARGET], { gate: G });';
289291
const FIXTURE_CALL_RAW = 'const results = await eslint.lintFiles' + '([TARGET]);';
290292
const FIXTURE_IMPORT = "import { lintFilesStrict } from './eslint-fatal-guard.mjs';";
293+
const FIXTURE_IMPORT_TEXT = "import { lintTextStrict } from './eslint-fatal-guard.mjs';";
291294
const FIXTURE_PROSE = '// on the same input. scripts/eslint-fatal-guard.mjs carries the measurement and';
292-
const FIXTURE_COUNT = 'const sites = (await eslint.lintText(code)).messages.filter(matches).length;';
295+
const FIXTURE_COUNT = 'const sites = (await eslint.lintText' + '(code)).messages.filter(matches).length;';
296+
const FIXTURE_CALL_TEXT_STRICT = 'const [r] = await lintTextStrict' + '(eslint, code, { gate: G });';
297+
const FIXTURE_CALL_TEXT_DECLARED = "const [r] = await lintTextUnguarded" + "(eslint, code, { why: 'ground truth' });";
293298

294299
const NO_IMPORT = 'does not import scripts/eslint-fatal-guard.mjs';
295300
const NOT_ARMED = 'Importing the guard does not arm it';
296301
const RAW_CALL = 'directly, so a parse failure in its population';
302+
const BARE_TEXT = 'A counted lintText result discards a parse failure';
297303

298304
/**
299305
* The adoption check in both directions, over sources written here.
@@ -311,10 +317,13 @@ const GUARD_ADOPTION_CASES = [
311317
// The measured reproduction: the real import line deleted, the docblock left
312318
// exactly as it was. Against the raw text this came back CLEAN and the
313319
// self-test printed "both gates still routed through it".
314-
['a docblock mention is not an import', [FIXTURE_PROSE, FIXTURE_COUNT], [NO_IMPORT]],
320+
// (FIXTURE_COUNT is a bare `lintText` count, so it now trips #10599's test
321+
// too — the fixture really does carry both defects, and a case that under-
322+
// states what its own source does is a case nobody can re-derive.)
323+
['a docblock mention is not an import', [FIXTURE_PROSE, FIXTURE_COUNT], [NO_IMPORT, BARE_TEXT]],
315324
// "A guard imported once is not a guard still called" — the docblock's own
316325
// thesis, which nothing used to assert.
317-
['imports the guard and never calls it', [FIXTURE_PROSE, FIXTURE_IMPORT, FIXTURE_COUNT], [NOT_ARMED]],
326+
['imports the guard and never calls it', [FIXTURE_PROSE, FIXTURE_IMPORT, FIXTURE_COUNT], [NOT_ARMED, BARE_TEXT]],
318327
// The same sentence one step further: commenting the call out leaves the
319328
// identifier in the text.
320329
['a commented-out call is not a call', [FIXTURE_IMPORT, '// ' + FIXTURE_CALL_STRICT], [NOT_ARMED]],
@@ -326,6 +335,31 @@ const GUARD_ADOPTION_CASES = [
326335
// fabricates a finding out of prose (#9367), and this check must not.
327336
['a commented-out raw call is not a raw call',
328337
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, '// was: ' + FIXTURE_CALL_RAW], []],
338+
339+
// ── The lintText half (#10599) ─────────────────────────────────────────
340+
//
341+
// THE REPRODUCTION. Against the checker as #10458 left it this source came
342+
// back with ZERO problems: it imports the guard, it still calls
343+
// lintFilesStrict() for the first population, and it never touches
344+
// `.lintFiles(` — so all three tests passed while the SECOND population,
345+
// counted out of `eslint.lintText()`, went entirely unguarded.
346+
['a guarded call plus a second population measured through lintText',
347+
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, FIXTURE_COUNT], [BARE_TEXT]],
348+
// The negative control that makes the ban a rule rather than a spelling
349+
// preference: the counted call routed through the guard is CLEAN.
350+
['a counted lintText routed through the guard is guarded',
351+
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, FIXTURE_CALL_TEXT_STRICT], []],
352+
// And the declared non-measurement — the shape this gate's own ground-truth
353+
// fixtures use, which must not be a finding or the ban is unusable.
354+
['a declared non-measurement is not a finding',
355+
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, FIXTURE_CALL_TEXT_DECLARED], []],
356+
// A gate whose whole population is text is ARMED without ever calling
357+
// lintFilesStrict(). Reporting that one unguarded would be a false positive
358+
// of this card's own making.
359+
['armed through lintTextStrict alone', [FIXTURE_IMPORT_TEXT, FIXTURE_CALL_TEXT_STRICT], []],
360+
// The mask, on the new test too: prose about a bare call is not a bare call.
361+
['a commented-out lintText is not a lintText',
362+
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, '// was: ' + FIXTURE_COUNT], []],
329363
];
330364

331365
async function selfTest() {
@@ -344,8 +378,24 @@ async function selfTest() {
344378
baseConfig: measuringConfig(drop),
345379
allowInlineConfig: false,
346380
});
381+
// Counted, therefore guarded (#10599). A fixture that stops parsing yields
382+
// zero messages matching the rule — which is precisely what the `silent`
383+
// cases below assert — so an unguarded count here reads a TYPO as proof the
384+
// rule is correctly quiet. Measured on this tree, the `argument 1, object
385+
// literal` report fixture with its closing paren removed:
386+
//
387+
// parses → hits()=1, fatalErrorCount=0
388+
// typo → hits()=0, fatalErrorCount=1, "Parsing error: ')' expected."
389+
//
390+
// The fatal is now a self-test FAILURE naming the fixture, not a zero.
347391
const hits = async (code, filePath = 'packages/objectql/src/__selftest__.ts') => {
348-
const [result] = await eslint.lintText(code, { filePath, warnIgnored: false });
392+
const [result] = await lintTextStrict(eslint, code, {
393+
filePath,
394+
warnIgnored: false,
395+
gate: 'self-test fixture',
396+
repoRoot,
397+
onFatal: (report) => { failures.push(report); return []; },
398+
});
349399
return (result?.messages ?? []).filter((m) => m.ruleId === QUERY_OPTIONS_RULE_ID).length;
350400
};
351401

@@ -416,9 +466,13 @@ async function selfTest() {
416466
baseConfig: eslintConfig,
417467
allowInlineConfig: false,
418468
});
419-
const [result] = await blocking.lintText(code, {
469+
// Counted, and the assertion below is `=== 0` — same reason as hits().
470+
const [result] = await lintTextStrict(blocking, code, {
420471
filePath: 'packages/objectql/src/__selftest__.test.ts',
421472
warnIgnored: false,
473+
gate: 'self-test fixture (blocking config)',
474+
repoRoot,
475+
onFatal: (report) => { failures.push(report); return []; },
422476
});
423477
const blocked = (result?.messages ?? []).filter((m) => m.ruleId === QUERY_OPTIONS_RULE_ID).length;
424478
assert(blocked === 0, 'a *.test.ts path must NOT be blocked by the rule (first cut is non-test)');
@@ -460,9 +514,11 @@ async function selfTest() {
460514
// are therefore driven through real ESLint output, and the fixture is a file
461515
// that genuinely does not parse rather than a hand-built message object.
462516
{
463-
const [broken] = await eslint.lintText('export const x = (', {
517+
const [broken] = await lintTextUnguarded(eslint, 'export const x = (', {
464518
filePath: 'packages/objectql/src/__selftest_unparseable__.ts',
465519
warnIgnored: false,
520+
why: 'ground truth: what raw ESLint returns for a file that will not parse. '
521+
+ 'Routing this through the guard would prove the guard with the guard.',
466522
});
467523
assert(
468524
(broken?.messages ?? []).some((m) => m.fatal),
@@ -480,9 +536,11 @@ async function selfTest() {
480536
`the collected fatal must name the file and the parse error (got ${JSON.stringify(fatals[0])})`,
481537
);
482538

483-
const [parses] = await eslint.lintText('export const x = 1;', {
539+
const [parses] = await lintTextUnguarded(eslint, 'export const x = 1;', {
484540
filePath: 'packages/objectql/src/__selftest_parses__.ts',
485541
warnIgnored: false,
542+
why: 'ground truth, the other direction: the guard must stay silent on this one, '
543+
+ 'so it has to reach collectFatalMessages() unfiltered.',
486544
});
487545
assert(
488546
collectFatalMessages([parses], repoRoot).length === 0,
@@ -514,6 +572,59 @@ async function selfTest() {
514572
'lintFilesStrict must pass the results through when every file parsed',
515573
);
516574

575+
// The lintText twin, both ways (#10599). The source ban below is only
576+
// worth having if the spelling it forces actually guards, and a
577+
// pass-through that never checked would satisfy every source test in this
578+
// file while doing nothing — the exact shape #10123 was.
579+
{
580+
let textReported = null;
581+
let forwarded = null;
582+
const stubBroken = { lintText: async (_code, options) => { forwarded = options; return [broken]; } };
583+
const textRefused = await lintTextStrict(stubBroken, 'irrelevant', {
584+
filePath: 'packages/objectql/src/__selftest_unparseable__.ts',
585+
warnIgnored: false,
586+
gate: 'self-test',
587+
repoRoot,
588+
onFatal: (report) => { textReported = report; return 'refused'; },
589+
});
590+
assert(textRefused === 'refused', 'lintTextStrict must not return results when the text did not parse');
591+
assert(
592+
(textReported ?? '').includes('__selftest_unparseable__.ts') && /Parsing error/i.test(textReported ?? ''),
593+
`lintTextStrict's failure text must name the file and the parse error (got: ${textReported})`,
594+
);
595+
// The guard's own options must not reach ESLint: it rejects unknown keys,
596+
// so a leak here is a crash at every call site, not a silent oddity.
597+
assert(
598+
forwarded !== null
599+
&& Object.keys(forwarded).sort().join(',') === 'filePath,warnIgnored',
600+
`lintTextStrict must forward only lintText's own options (forwarded ${JSON.stringify(forwarded)})`,
601+
);
602+
603+
let textFired = false;
604+
const stubClean = { lintText: async () => [parses] };
605+
const textPassed = await lintTextStrict(stubClean, 'irrelevant', {
606+
gate: 'self-test',
607+
repoRoot,
608+
onFatal: () => { textFired = true; },
609+
});
610+
assert(
611+
!textFired && Array.isArray(textPassed) && textPassed.length === 1,
612+
'lintTextStrict must pass the results through when the text parsed',
613+
);
614+
615+
// And the declaration is a declaration: an undeclared escape is a throw,
616+
// not a quiet pass. An escape hatch nobody has to name is just the bare
617+
// call with extra steps.
618+
let threw = null;
619+
try {
620+
await lintTextUnguarded(stubClean, 'irrelevant', { filePath: 'x.ts' });
621+
} catch (err) { threw = err; }
622+
assert(
623+
threw instanceof TypeError && /requires `why`/.test(threw?.message ?? ''),
624+
`lintTextUnguarded must refuse an undeclared call (threw: ${threw?.message ?? 'nothing'})`,
625+
);
626+
}
627+
517628
// A guard imported once is not a guard still called — proved in both
518629
// directions over the fixtures above, because the live-tree call that
519630
// follows can only ever confirm the direction this tree is already in.
@@ -613,8 +724,9 @@ async function selfTest() {
613724
console.log(
614725
`✓ self-test: ${reports.length} reporting shape(s), ${silent.length} silent counterpart(s), ` +
615726
`grandfathering + test-glob channels proved in both directions, ${cases.length} ratchet case(s), ` +
616-
`fatal-parse guard proved both ways over real ESLint output, both gates still routed through it ` +
617-
`(adoption proved both ways over ${GUARD_ADOPTION_CASES.length} synthetic gate source(s)), ` +
727+
`fatal-parse guard proved both ways over real ESLint output, every counted lint call in both ` +
728+
`gates routed through it (adoption proved both ways over ${GUARD_ADOPTION_CASES.length} synthetic ` +
729+
`gate source(s), files AND text), ` +
618730
`and ${HEADROOM_CANARY_FILE} parses at --stack-size=${PARSER_STACK_SIZE_KB} through this gate's own channel.`,
619731
);
620732
}

0 commit comments

Comments
 (0)