diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index 1961e463b4..9d51eb625e 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -421,6 +421,133 @@ export const KEBAB_DIAGNOSTIC_VOCABULARY = Object.freeze({ governedBy: 'neither — an author-time diagnostic vocabulary, ADR-0112 D6c genre', }); +/** + * ## [#13131] A code-carrying helper that stamps through an OBJECT LITERAL is + * ## invisible to `codehelper` — MEASURED, and deliberately NOT closed here + * + * `codehelper` above is anchored on an ASSIGNMENT: its regex is `.code = ident`. + * The reasoning it implements — "the identifier is a PARAMETER, so the literals + * live at the CALL SITES" — is a property of the HELPER, not of the assignment + * operator, and it holds just as well for the equally ordinary helper that + * builds an object literal: + * + * function postureError(code: string, message: string) { + * return { severity: 'error', code, message }; // <- nothing matches + * } + * + * No shape in either gate anchors there. `objlit` needs a quote, `objlitconst` + * needs a SCREAMING_SNAKE identifier after the colon (and the conventional + * parameter name is `code`), `objlittemplate` needs backticks, and `codehelper` + * needs the `.code =`. So this is worse than a wrong verdict: no pattern fires + * at all, which means there is no site AND no unresolved entry either — nothing + * is reported, and nothing says so. That is precisely the bound this gate + * states for itself ("a value it cannot reduce is REPORTED, never dropped") + * failing in the one way the bound cannot notice. + * + * ## TWO blindnesses, not one — and the second is why the first is not enough + * + * ① POSITION. The object-literal stamp position has no shape (above). + * ② DECLARATION FORM. `enclosingDeclaration`'s `DECL_HEADER_RE` recognises + * `function f(`, `constructor(` and `const|let|var f = (` — and NO class + * method. So a code-carrying helper that is a METHOD is out of reach even in + * the position `codehelper` already implements: the same body that produces + * a site as a free function produces nothing as `private error(code, …)`. + * Measured with a same-genre positive control, and pinned below. + * + * These are independent, and the card's own live instance needs BOTH: + * `Parser#error(code, message, start?, tag?)` in `packages/sdui-parser` is a + * class method that stamps through an object literal. Widening the position + * alone would still not reach it. + * + * ## The measurement (blast radius), on `packages/**` non-test source + * + * Method: the predicate — a `code` property of an object literal whose value is + * an identifier that is a PARAMETER of the enclosing function — evaluated on + * the real TypeScript AST, with call-site arguments reduced by THIS gate's own + * literal grammar (`/^[A-Za-z][A-Za-z0-9_]*$/`, so a hyphen does not reduce) + * and checked against the registered vocabulary and the declaration table. + * A regex instrument was tried first and discarded: it matched `${code}` inside + * a template and `f(a, code, b)` inside an argument list, and then LOST a true + * positive because a bracket inside a regex literal unbalanced its scan. + * + * predicate matches 13 helpers (12 named, 1 anonymous) + * (a) call sites newly reached 106 — 76 reduce to a literal, 30 do not + * (b) NEW verdict rows needed 29 — 0 of them already carry a row + * (c) undischargeable `unresolved` findings 4 + * + * Split by whether `enclosingDeclaration` can see the declaration at all: + * + * reachable today (blindness ① only) 9 helpers · 81 call sites · 29 rows · 1 unresolved + * blocked by blindness ② 4 helpers · 25 call sites · 0 rows · 3 unresolved + * + * ⚠️ (b) is not the whole cost, and reading it as the whole cost is the trap + * this note exists to prevent. All 29 new rows are LOWERCASE `FieldErrorCode` + * diagnostics (`required`, `invalid_type`, `min_value`, …) in four files — + * ADR-0112 D6 genre, not wire codes. The four `unresolved` findings are the + * expensive half: an `unresolved` entry is pushed UNCONDITIONALLY and no + * declaration row discharges it (see `reconcile`), so each one is a RED gate + * with no verdict available — including `Parser#error`, whose 16 call sites all + * pass kebab literals that this gate's grammar refuses to reduce. + * + * ## No victim today — verified, not assumed + * + * Of the 33 SCREAMING_SNAKE values reached, 33 are already registered; the + * count of values that are BOTH ADR-0112 D1 shaped AND unregistered — i.e. a + * real wire code hiding behind this blindness — is ZERO. `check:error-code-casing` + * likewise reports nothing on any of the files involved. So the value of + * closing this is preventing a future defect, not fixing a present one. + * + * ⚠️ One bound the measurement does NOT have. `helperCodesFor` scans only the + * DECLARING file, so every count here is over IN-FILE call sites. An exported + * helper called from other packages contributes 0 — `sendError` in + * `packages/types/src/response-envelope.ts` has 0 in-file calls and is one of + * the four `unresolved`. Counting cross-file callers needs resolution this + * source scan does not have; that number is UNMEASURED, and it is a lower bound + * on (a), never an upper one. + * + * ## ⛔ What this declaration deliberately does NOT do + * + * ⛔ It does not widen `codehelper`. Triage ruled the measuring round scoped to + * measurement: 「先量出加宽后的爆炸半径, ⛔ 不先加宽。数没量出来之前,"该不该加宽" + * 不是一个可裁的问题。」 The numbers above are that measurement; whether to + * widen is now a decidable question and belongs on its own card, with the + * verdict rows (`domain:cli`) as a rider on the same change. + * + * ⛔ It does not add verdict rows. A row for a site no shape derives is a + * `stale-row` finding, so the rows cannot land before the widening. + */ +export const OBJECT_LITERAL_CODE_HELPER_BLINDNESS = Object.freeze({ + /** + * The two spellings of the unseen position. Each is a complete helper whose + * only difference from `SAME_GENRE_CONTROL` is the stamping line. + */ + shorthand: ` return { severity: 'error', code, message };`, + longhand: ` return { severity: 'error', code: code, message };`, + /** + * The SAME-GENRE POSITIVE CONTROL: the identical helper stamping through the + * assignment `codehelper` does implement. `--self-test` runs both, so the + * zero on the object-literal spellings is a READING rather than a harness + * that stopped working — the discipline `KEBAB_DIAGNOSTIC_VOCABULARY` above + * records, applied to a different axis. + */ + control: ` const e = new Error(message);\n (e as any).code = code;\n return e;`, + /** Unregistered in both `StandardErrorCode` and the ledger, in both casings. */ + probe: 'HELPER_SCREAMING', + probeLowercase: 'helper_lowercase', + /** Blindness ②: the declaration forms `enclosingDeclaration` cannot see. */ + invisibleDeclarationForms: Object.freeze(['class method', 'anonymous arrow']), + /** The live instance, read as evidence only — it is NOT edited by this card. */ + liveInstance: 'packages/sdui-parser/src/parse.ts Parser#error(code, message, start?, tag?)', + /** Measured blast radius, `packages/**` non-test source. See the prose above. */ + measured: Object.freeze({ + helpers: 13, + callSitesNewlyReached: 106, + newVerdictRows: 29, + undischargeableUnresolved: 4, + unregisteredWireCodesHiding: 0, + }), +}); + const isTestFile = (rel) => /\.(test|spec)\.[cm]?tsx?$/.test(rel) || /(^|\/)(__tests__|__mocks__|fixtures)\//.test(rel); @@ -2330,6 +2457,104 @@ function selfTest() { } } + // [#13131] The OBJECT-LITERAL stamp position inside a code-carrying helper, + // and the CLASS-METHOD declaration form, are both outside this gate — + // MEASURED (see OBJECT_LITERAL_CODE_HELPER_BLINDNESS above) and declared out + // rather than closed, because the round that measured them was scoped to + // measurement. Pinned HERE, in the one place the declaration lives, so a + // later widening of either half cannot land silently: something fails, and + // what fails names the decision. + // + // ⚠️ Nothing below asserts a bare zero. Every zero is paired with the + // SAME-GENRE POSITIVE CONTROL — the identical helper stamping through the + // assignment this gate does implement — so a harness that stopped working + // fails on the control instead of passing on the subject. + { + const B = OBJECT_LITERAL_CODE_HELPER_BLINDNESS; + const DECISION = + 'a code-carrying helper that stamps through an OBJECT LITERAL, or one declared as a CLASS ' + + 'METHOD, is now visible to a gate. That is a gate-POPULATION change: #13131 measured the ' + + 'blast radius at 106 newly reached call sites, 29 new verdict rows in ' + + `${DECLARATION}, and 4 UNDISCHARGEABLE unresolved findings. Land the rows and the ` + + 'declaration together, and rewrite OBJECT_LITERAL_CODE_HELPER_BLINDNESS — do not adjust ' + + 'this pin to match.'; + + const helper = (body, probe) => + `export function postureError(code: string, message: string) {\n${body}\n}\n` + + `export function deny() { throw postureError('${probe}', 'x'); }\n`; + const REL = 'packages/x/src/a.ts'; + const derive = (source) => + deriveSites({ registered: new Set(['ALREADY_REGISTERED']), files: [{ rel: REL, source }], readFile: () => '' }); + // Population = MATCHES across BOTH gates' recognizers, never the finding + // list — the lesson the kebab pin above paid for in an ablation. + const matched = (source) => { + let n = 0; + for (const shape of SHAPES) n += [...source.matchAll(new RegExp(shape.re.source, shape.re.flags))].length; + return n + findViolations(source, REL).length; + }; + + // ① BLINDNESS ①, the stamp POSITION. The control fires; the two + // object-literal spellings of the same helper are seen by NOTHING — + // not matched, so not reported, and not unresolved either. + const control = helper(B.control, B.probe); + ok(matched(control) > 0, `the same-genre control matches no recognizer at all — the pin below cannot read as a zero`); + ok( + derive(control).sites.some((s) => s.shape === 'codehelper' && s.code === B.probe), + 'the assignment-spelled code helper no longer derives a `codehelper` site — the control for #13131 is dead', + ); + for (const [name, body] of [['shorthand', B.shorthand], ['longhand', B.longhand]]) { + for (const probe of [B.probe, B.probeLowercase]) { + const source = helper(body, probe); + ok(matched(source) === 0, `a recognizer now MATCHES the ${name} object-literal code helper ('${probe}') — ${DECISION}`); + const { sites, unresolved } = derive(source); + ok( + sites.length === 0 && unresolved.length === 0, + `the ${name} object-literal code helper ('${probe}') now derives ${sites.length} site(s) and ` + + `${unresolved.length} unresolved — ${DECISION}`, + ); + } + } + + // ② BLINDNESS ②, the DECLARATION FORM — and it is pinned on SITES rather + // than on matches, deliberately. Here the recognizer DOES match: the + // body is the very `.code = code` `codehelper` is written for. What + // drops it is structural — `enclosingDeclaration`'s DECL_HEADER_RE has + // no header for a class method, so the identifier is never recognised + // as a parameter. Same body, same probe, one declaration form apart. + const body = B.control.replace(/^/gm, ' '); + const asFunction = + `export function fail(code: string, message: string) {\n${body}\n}\n` + + `export function run() { throw fail('${B.probe}', 'x'); }\n`; + const asMethod = + `class Thing {\n private fail(code: string, message: string) {\n${body}\n }\n` + + ` run(): void { throw this.fail('${B.probe}', 'x'); }\n}\n`; + ok( + derive(asFunction).sites.some((s) => s.shape === 'codehelper' && s.code === B.probe), + 'the free-function control for #13131 blindness ② derives no site — the comparison below is dead', + ); + ok(matched(asMethod) > 0, 'the class-method form matches no recognizer — blindness ② is not what was measured'); + { + const { sites, unresolved } = derive(asMethod); + ok( + sites.length === 0 && unresolved.length === 0, + `a CLASS-METHOD code helper now derives ${sites.length} site(s) and ${unresolved.length} unresolved — ${DECISION}`, + ); + } + // The mechanism itself, named so the failure above is diagnosable. + { + const at = asMethod.indexOf('.code = code'); + const decl = enclosingDeclaration(asMethod, at); + ok( + !decl || !parseParamNames(decl.params).includes('code'), + 'enclosingDeclaration now resolves a CLASS METHOD, so `codehelper` reaches method helpers — ' + DECISION, + ); + } + ok( + B.invisibleDeclarationForms.includes('class method'), + 'OBJECT_LITERAL_CODE_HELPER_BLINDNESS no longer declares the class-method form out', + ); + } + if (fail.length) { console.error('check-dispatcher-error-vocabulary --self-test FAILED:'); for (const f of fail) console.error(` - ${f}`); @@ -2388,7 +2613,15 @@ function main() { `${KEBAB_DIAGNOSTIC_VOCABULARY.owner}) are a SEPARATE vocabulary, governed by `+ `${KEBAB_DIAGNOSTIC_VOCABULARY.governedBy}: no grammar in this gate or in check:error-code-casing `+ `admits a hyphen, so coverage of them is zero BY DECLARATION, not by accident — see `+ - `KEBAB_DIAGNOSTIC_VOCABULARY in this file, pinned by --self-test.`; + `KEBAB_DIAGNOSTIC_VOCABULARY in this file, pinned by --self-test.` + + `\n [#13131] a code-carrying helper that stamps through an OBJECT LITERAL ({ code }) — and one ` + + `declared as a CLASS METHOD, in either stamp position — is outside this gate: no shape matches, so ` + + `there is no site AND no unresolved. Coverage of them is zero BY DECLARATION, not by accident. ` + + `Measured blast radius of closing it: ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.callSitesNewlyReached} ` + + `call sites, ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.newVerdictRows} new verdict rows, ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.undischargeableUnresolved} undischargeable unresolved, ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.unregisteredWireCodesHiding} unregistered wire code(s) ` + + `hiding today — see OBJECT_LITERAL_CODE_HELPER_BLINDNESS in this file, pinned by --self-test.`; if (argv.includes('--report')) { console.log('Derived sites (code / shape / file):');