diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 01a6532791..626de4c806 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -354,7 +354,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; -import { blank, maskComments, scanSource } from './js-comment-mask.mjs'; +import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -2410,13 +2410,6 @@ export function isErasedType(text) { return /^(?:any|unknown)$/.test(s); } -/** Comment AND string spans blanked -- offsets preserved. The house scanner, not a private one. */ -function maskCommentsAndLiterals(source) { - const { comment, literal } = scanSource(source); - const flags = comment.map((c, i) => c || literal[i]); - return blank(source, flags); -} - /** * The type text of a `(...)` RETURN ANNOTATION, or a stated absence. * diff --git a/scripts/check-registry-log-declared.mjs b/scripts/check-registry-log-declared.mjs index e4620d5242..ba7d5b7160 100644 --- a/scripts/check-registry-log-declared.mjs +++ b/scripts/check-registry-log-declared.mjs @@ -127,7 +127,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { dirname, join, resolve, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { blank, maskComments, scanSource } from './js-comment-mask.mjs'; +import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { workspacePackageDirs } from './check-console-intercept-disarm.mjs'; @@ -178,28 +178,19 @@ const REMEDY = ` // #13517: quiet the registry's per-item registration chatte // default. Enforced by scripts/check-registry-log-declared.mjs. env: { OS_REGISTRY_LOG: 'warn' },`; -/** - * Comments AND string/template/regex content blanked, offsets kept. Used where - * the signal is a bare CODE position (`new SchemaRegistry(`, a property key), so - * a spelling inside prose or a template literal can never satisfy it. - * - * It COMPOSES the shared scanner — `scanSource`'s `comment` and `literal` flags - * OR-ed through `blank` — and carries no scanning logic of its own; it stays - * local only because `js-comment-mask.mjs` publishes no comments+literals - * projection yet, and hoisting one waits on a follow-up card. - * - * The imported `maskComments` (comments blanked, string/template/regex content - * INTACT — S2/S3 read import specifiers out of it) and this mask both preserve - * offsets, so a range brace-matched on the code mask indexes the comment mask - * identically — which is how the level VALUE (a string, blanked by this mask) - * is read out of a block located with it. - */ -function maskCode(source) { - const { comment, literal } = scanSource(source); - const flags = new Uint8Array(comment.length); - for (let i = 0; i < flags.length; i++) flags[i] = comment[i] | literal[i]; - return blank(source, flags); -} +// This gate reads TWO projections of the same source and relies on them +// agreeing offset-for-offset: `maskCommentsAndLiterals` (the signal is a bare +// CODE position — `new SchemaRegistry(`, a property key — so a spelling inside +// prose or a template must never satisfy it) and `maskComments` (S2/S3 read +// import specifiers, which ARE quoted text, out of it). Both preserve offsets, +// so a range brace-matched on the code mask indexes the comment mask +// identically — which is how the level VALUE (a string, blanked by the code +// mask) is read out of a block located with it. +// +// The comments+literals projection used to be spelled here as a local +// `maskCode`, "local only because js-comment-mask.mjs publishes no +// comments+literals projection yet". It publishes one now (#15594), so this +// gate composes it like every other projection it reads. /** * The level vocabulary, read from the engine's own declaration rather than @@ -269,7 +260,7 @@ export function bootSignals(dir) { const comments = maskComments(raw); if (!s2 && S2_DEFINE_RE.test(comments)) s2 = true; if (!isTest(file)) continue; - if (!s1 && S1_REGISTRY_RE.test(maskCode(raw))) s1 = true; + if (!s1 && S1_REGISTRY_RE.test(maskCommentsAndLiterals(raw))) s1 = true; if (!s2 && S2_IMPORT_RE.test(comments)) s2 = true; if (!s3 && S3_EXAMPLE_RE.test(comments)) s3 = true; } @@ -411,7 +402,7 @@ export function scan(root, levels = readRegistryLogLevels(root)) { continue; } const raw = readFileSync(join(dir, configName), 'utf8'); - const code = maskCode(raw); + const code = maskCommentsAndLiterals(raw); const comments = maskComments(raw); const where = `${name}/${configName}`; diff --git a/scripts/js-comment-mask.d.mts b/scripts/js-comment-mask.d.mts index 5e2678c913..25158dac9e 100644 --- a/scripts/js-comment-mask.d.mts +++ b/scripts/js-comment-mask.d.mts @@ -76,6 +76,18 @@ export function stripComments(source: string): string; */ export function maskComments(source: string): string; +/** + * `source` with its COMMENT spans AND its LITERAL content both blanked. + * Offsets and line numbers both survive, exactly as under `maskComments`. + * + * Pick this when the signal is a bare CODE position (`new SchemaRegistry(`, a + * property key): under `maskComments` the same spelling inside a string or a + * template still satisfies it, so the gate reports a finding made of quoted + * text. Literal DELIMITERS are not literal content, so the quotes survive and + * a caller can still pair them. + */ +export function maskCommentsAndLiterals(source: string): string; + /** * The outcome of walking the body of the regex literal opening at `at`. * diff --git a/scripts/js-comment-mask.mjs b/scripts/js-comment-mask.mjs index f7a0a6419d..7956343dd8 100644 --- a/scripts/js-comment-mask.mjs +++ b/scripts/js-comment-mask.mjs @@ -541,6 +541,39 @@ export function maskComments(source) { return blank(source, scanSource(source).comment); } +/** + * The source with its COMMENT spans AND its LITERAL content both blanked -- + * offsets and line numbers both survive, exactly as under `maskComments`. + * + * The THIRD projection, and the one to reach for when the signal is a bare + * CODE position: `new SchemaRegistry(`, a property key, a call to a named + * member. `maskComments` deliberately leaves strings, templates and regex + * literals intact, so under it a spelling inside quoted text still satisfies + * such a signal and the gate FABRICATES a finding out of a string. Only the + * position the language would EXECUTE survives this one. + * + * ## Picking between the two, which is the same question `stripComments` asks + * + * Ask what the signal IS, not what the source contains. The signal is itself + * quoted text -- an import specifier, an error code, a level name -- -> + * `maskComments`, which MUST leave literals intact or it erases the thing + * being looked for. The signal is a code position -> this one. + * + * A caller that needs both at once gets them for free: both preserve offsets, + * so a range brace-matched on this mask indexes `maskComments`'s output + * identically (`check-registry-log-declared.mjs` reads a level VALUE -- a + * string, blanked here -- out of a block it located with this mask). + * + * Literal DELIMITERS are not literal content (see `scanSource`), so the quotes + * themselves survive and a caller can still pair them. + */ +export function maskCommentsAndLiterals(source) { + const { comment, literal } = scanSource(source); + const flags = new Uint8Array(comment.length); + for (let k = 0; k < flags.length; k++) flags[k] = comment[k] | literal[k]; + return blank(source, flags); +} + // --------------------------------------------------------------------------- // Self-test -- the shapes, not the corpus // --------------------------------------------------------------------------- @@ -654,6 +687,32 @@ const SELF_TEST_RECOGNISER_BATTERIES = Object.freeze({ }); const SELF_TEST_RECOGNISER_FLOOR = 9; +// -- The COMMENTS+LITERALS PROJECTION's own roster and floor (#15594) ------- +// +// Declared as a LITERAL for the same reason as the two rosters above: a +// deleted or renamed row must name ITSELF in the refusal rather than quietly +// lowering a count it also supplies. +// +// This section exists instead of rows in the `cases` table because the table +// asserts the OPPOSITE property for quoted text. There `REAL` code inside a +// string MUST survive -- `maskComments` and `stripComments` leave literals +// intact by design, and a row that removed one would be reporting a bug. Under +// `maskCommentsAndLiterals` that same spelling must NOT survive. One table +// cannot state both directions about the same fixture, so the projection is +// driven here, against a fixture whose signal is a bare CODE position. +const SELF_TEST_PROJECTION_BATTERIES = Object.freeze({ + 'the fixture spells one bare-code signal four times': 1, + 'a code signal inside a COMMENT does not survive the projection': 1, + '...nor one inside a STRING': 1, + '...nor one inside a TEMPLATE': 1, + '...while the REAL code position DOES survive': 1, + 'the control: under maskComments the string and template spellings both survive': 1, + 'the projection IS blank(source, comment OR literal), recomputed independently here': 1, + '...and that equality holds on every row of the corpus table too': 1, + 'offsets and line count survive, so a caller can index the original text': 1, +}); +const SELF_TEST_PROJECTION_FLOOR = 9; + export function selfTest() { const BT = String.fromCharCode(96); // backtick, kept out of the literal below const cases = [ @@ -901,7 +960,70 @@ export function selfTest() { console.log(` ${ok ? '\u2713' : '\u2717'} ${name}${ok ? '' : ' -- ' + JSON.stringify(detail)}`); } - const total = cases.length + extra.length + recog.length; + // -- the COMMENTS+LITERALS projection (#15594) ---------------------------- + // + // The fixture spells ONE bare-code signal four times -- in prose, in a + // string, in a template, and once for real -- and every row is asserted at + // the signal's own OFFSET rather than by counting occurrences, so a mask that + // moved bytes could not be read as one that removed the right ones. + const proj = []; + const xp = (name, ok, detail) => proj.push([name, Boolean(ok), detail]); + + const SIG = 'new SchemaRegistry('; + const projSrc = [ + '// ' + SIG + 'ghostInProse);', + "const HINT = '" + SIG + "ghostInString)';", + 'const TPL = ' + BT + SIG + 'ghostInTemplate)' + BT + ';', + 'const registry = ' + SIG + 'realCode);', + ].join('\n'); + const sigOffsets = []; + for (let k = projSrc.indexOf(SIG); k !== -1; k = projSrc.indexOf(SIG, k + 1)) sigOffsets.push(k); + const projMasked = maskCommentsAndLiterals(projSrc); + const survives = sigOffsets.map((k) => projMasked.startsWith(SIG, k)); + + xp('the fixture spells one bare-code signal four times', sigOffsets.length === 4, sigOffsets); + xp('a code signal inside a COMMENT does not survive the projection', survives[0] === false, projMasked); + xp('...nor one inside a STRING', survives[1] === false, projMasked); + xp('...nor one inside a TEMPLATE', survives[2] === false, projMasked); + xp('...while the REAL code position DOES survive', survives[3] === true, projMasked); + + // The CONTROL, and the reason this fixture can fail: under the + // comments-only projection the string and template spellings DO survive. + // Without it every row above would also pass on a mask that blanked the + // whole file, and the corpus table's own rows would not notice -- none of + // them puts a bare-code signal inside a literal. + const projComments = maskComments(projSrc); + const survivesComments = sigOffsets.map((k) => projComments.startsWith(SIG, k)); + xp('the control: under maskComments the string and template spellings both survive', + survivesComments[0] === false && survivesComments[1] === true + && survivesComments[2] === true && survivesComments[3] === true, + survivesComments); + + // The projection RESTATED, computed here from the two flag arrays with the + // other spelling (`||` over a plain array, which is how one of the two + // converted callers wrote it). An oracle re-derived on purpose: pinning the + // export against itself would pin nothing. + const orView = (src) => { + const { comment, literal } = scanSource(src); + return blank(src, comment.map((c, i) => c || literal[i])); + }; + xp('the projection IS blank(source, comment OR literal), recomputed independently here', + maskCommentsAndLiterals(projSrc) === orView(projSrc), [projMasked, orView(projSrc)]); + const corpusDisagreement = cases.find(([, src]) => maskCommentsAndLiterals(src) !== orView(src)); + xp('...and that equality holds on every row of the corpus table too', + corpusDisagreement === undefined, corpusDisagreement && corpusDisagreement[0]); + + xp('offsets and line count survive, so a caller can index the original text', + projMasked.length === projSrc.length + && projMasked.split('\n').length === projSrc.split('\n').length, + [projSrc.length, projMasked.length]); + + for (const [name, ok, detail] of proj) { + if (!ok) failed++; + console.log(` ${ok ? '\u2713' : '\u2717'} ${name}${ok ? '' : ' -- ' + JSON.stringify(detail)}`); + } + + const total = cases.length + extra.length + recog.length + proj.length; // ── The floor: every declared row RAN, and ran its case (#13489) ─────── // @@ -978,6 +1100,33 @@ export function selfTest() { ); } + // The same treatment for the comments+literals projection section (#15594). + const declaredProjection = Object.keys(SELF_TEST_PROJECTION_BATTERIES); + if (declaredProjection.length < SELF_TEST_PROJECTION_FLOOR) { + floorBreached = true; + floorFailure( + `SELF_TEST_PROJECTION_BATTERIES declares ${declaredProjection.length} assertions, below the pinned ` + + `${SELF_TEST_PROJECTION_FLOOR} — an assertion deleted from the roster takes its own floor with it.`, + ); + } + const projectionRan = proj.map(([name]) => name); + for (const name of projectionRan) { + if (declaredProjection.includes(name)) continue; + floorBreached = true; + floorFailure( + `comments+literals assertion "${name}" ran but is not declared in ` + + 'SELF_TEST_PROJECTION_BATTERIES — an assertion attributed to no declared row is one nothing floors.', + ); + } + for (const name of declaredProjection) { + if (projectionRan.filter((n) => n === name).length >= SELF_TEST_PROJECTION_BATTERIES[name]) continue; + floorBreached = true; + floorFailure( + `comments+literals assertion "${name}" DID NOT RUN — the verdict below would have claimed that a ` + + 'code signal inside prose and inside a string are both masked when one of them is not.', + ); + } + if (floorBreached) { floorFailure( 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + @@ -992,7 +1141,8 @@ export function selfTest() { } console.log( `\u2713 js-comment-mask self-test: ${total} cases pass (${cases.length} mask/strip corpus, ` - + `${extra.length} interpolation view, ${recog.length} shared recogniser).`, + + `${extra.length} interpolation view, ${recog.length} shared recogniser, ` + + `${proj.length} comments+literals projection).`, ); return SELF_TEST_VERDICT;