From aedebdab8a9101d76dad888188111b13267ff2e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:36:13 +0000 Subject: [PATCH 1/2] fix(scripts): parseParamNames strips the full TypeScript modifier run `private readonly code: string` parsed as a parameter named `readonly`: the strip was one anchored alternation carrying a `g` flag, and `^` with no `m` matches at position 0 once, so exactly one modifier came off. `helperCodesFor`'s `indexOf(ident)` then answered -1 and the whole code helper was dropped in silence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- scripts/check-dispatcher-error-vocabulary.mjs | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index 9d51eb625e..101489889b 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -1085,12 +1085,69 @@ export function splitTopLevel(args) { return out; } -/** Parameter NAMES, in order: `readonly a: T = x` → `a`. */ +/** + * [#13227] The leading MODIFIER RUN of a TypeScript parameter, enumerated from + * the grammar rather than approximated. + * + * A parameter property admits an accessibility modifier, then `override`, then + * `readonly` — in that fixed order, up to THREE of them, and the compiler + * rejects any other order (`readonly public x` → "'public' modifier must + * precede 'readonly' modifier"; `readonly override x` → "'override' modifier + * must precede 'readonly' modifier"). Measured against the repo's own + * TypeScript 6.0.3 on a derived class, because `override` is only legal where a + * base class exists and a check on a standalone class reports it as a class + * error rather than as a parameter one. `static` and `abstract` are rejected on + * a parameter outright, and `in`/`out` are TYPE-parameter modifiers, which + * never appear in the value-parameter slice `enclosingDeclaration` hands over. + * + * The order is encoded rather than looped deliberately: a "strip any word in + * this set, repeatedly" loop accepts spellings TypeScript does not, which is + * the match-everything direction this gate has paid for before. + */ +const PARAM_MODIFIER_RUN = /^(?:(?:public|private|protected)\s+)?(?:override\s+)?(?:readonly\s+)?/; + +/** + * Parameter NAMES, in order: `readonly a: T = x` → `a`, + * [#13227] `private readonly code: string` → `code`. + * + * The strip used to be a single anchored alternation carrying a `g` flag. The + * flag reads as "strip them all", but `^` with no `m` matches at position 0 + * once, so exactly ONE modifier came off: `private readonly code: string` + * parsed as a parameter literally named `readonly`, and `helperCodesFor`'s + * `indexOf(ident)` then answered -1 — no site, no unresolved, the whole helper + * dropped in silence, one layer inside the same failure class as #9223 / + * #9460 / #10918 / #13131. + * + * ⚠️ A modifier word is only a modifier when a NAME follows it. `readonly`, + * `override` and the accessibility words are not reserved, so each is a legal + * parameter name in its own right — `override: MetricsRegistry | undefined` is + * live in this tree three times over. `override:` / `override?:` never enter + * the run (no whitespace follows the word), and the guard below covers the + * spaced spelling `readonly : T`, where the run would otherwise eat the + * parameter's own name and report nothing. + * + * ⛔ Textual on purpose, and the reason is measured rather than inherited. + * `enclosingDeclaration`'s `DECL_HEADER_RE` also matches `const x = someCall(`, + * so of the 7646 slices this function is handed on `packages/**` non-test + * source, 1218 are not a valid parameter list at all — they are ARGUMENT + * lists — and on 1072 of those a recovering TypeScript parse invents MORE THAN + * ONE parameter: `authService as any` becomes three confident parameters named + * `authService`, `as` and `any`; `await res.json()` becomes `await`, `res`, + * `json`. Those names are exactly what `helperCodesFor` searches with + * `indexOf(ident)`, so an AST route would MANUFACTURE the wrong-INDEX hazard + * this card only warns about — a finding that reads as ordinary while naming a + * value from another argument position — across a thousand slices. A textual + * reader degrades to one bad name instead of several. The over-matching header + * regex is #13226's subject and is deliberately untouched here. + */ export function parseParamNames(params) { if (!params.trim()) return []; return splitTopLevel(params).map((raw) => { - const cleaned = raw.replace(/^\s*(?:readonly|public|private|protected|\.\.\.)\s+/g, '').trim(); - const m = /^([A-Za-z_$][\w$]*)/.exec(cleaned.replace(/^\.\.\./, '')); + const rest = raw.trim().replace(/^\.\.\.\s*/, ''); + const run = PARAM_MODIFIER_RUN.exec(rest)[0]; + const tail = rest.slice(run.length); + const cleaned = /^[A-Za-z_$]/.test(tail) ? tail : rest; + const m = /^([A-Za-z_$][\w$]*)/.exec(cleaned); return m ? m[1] : ''; }); } From ae72953fdfe76bedb465db6951544a088e338377 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:37:55 +0000 Subject: [PATCH 2/2] test(scripts): pin the parameter-property modifier run in --self-test Positive cases at every run length TypeScript admits (up to three: accessibility, override, readonly), negative cases for parameters genuinely NAMED after a modifier word, and the end-to-end pair through deriveSites with the single-modifier positive control beside it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- scripts/check-dispatcher-error-vocabulary.mjs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index 101489889b..1e733d27e4 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -1901,6 +1901,78 @@ function selfTest() { 'splitTopLevel counted a nested or templated comma as a separator', ); ok(parseParamNames('readonly a: Map = x, b?: string').join(',') === 'a,b', 'parseParamNames mis-read a parameter list'); + + // [#13227] The MODIFIER RUN, pinned at every length TypeScript admits and + // in the order it admits them. `private readonly code: string` parsed as a + // parameter named `readonly` because the strip was anchored-plus-`g`, + // which takes exactly ONE modifier off. The run is up to three long + // (accessibility → `override` → `readonly`), measured against the repo's + // own TypeScript rather than assumed from the card's two-modifier example. + for (const [params, expected, note] of [ + ['code: string, msg: string', 'code,msg', 'no modifier'], + ['readonly code: string', 'code', 'one modifier'], + ['private code: string', 'code', 'one modifier, accessibility'], + ['private readonly code: string, private readonly msg: string', 'code,msg', 'two modifiers, both parameters'], + ['public readonly code: string, msg: string', 'code,msg', 'two modifiers, mixed list'], + ['protected readonly code: string', 'code', 'two modifiers, protected'], + ['override readonly code: string', 'code', 'two modifiers, no accessibility'], + ['private override readonly code: string', 'code', 'THREE modifiers — the maximum'], + ['public override readonly code: string, msg: string', 'code,msg', 'three modifiers, mixed list'], + ['private readonly code: Map = x, b?: string', 'code,b', 'modifier run plus a generic default'], + ['...rest: string[]', 'rest', 'rest parameter, unchanged by the run'], + ]) { + ok( + parseParamNames(params).join(',') === expected, + `parseParamNames mis-read a parameter list (${note}): ${JSON.stringify(params)} → ` + + `${JSON.stringify(parseParamNames(params))}, expected ${JSON.stringify(expected.split(','))}`, + ); + } + + // ⛔ The NEGATIVE half — the strip must not become a match-everything. + // None of these words is reserved, so each is a legal parameter name, and + // `override` is one THREE TIMES in this repo's own `packages/**` source + // (`observability-service-plugin.ts`, `cache-service-plugin.ts`, + // `storage-service-plugin.ts`). A run that ate them would rename a real + // parameter and hand `helperCodesFor` a wrong INDEX — a finding that reads + // as ordinary while naming a value from another argument position. + for (const [params, expected, note] of [ + ['ctx: PluginContext, override?: ErrorReporter', 'ctx,override', 'a parameter named `override`'], + ['override: MetricsRegistry | undefined', 'override', '`override` alone'], + ['readonly: string, message: string', 'readonly,message', 'a parameter named `readonly`'], + ['public: number', 'public', 'a parameter named `public`'], + ['readonly : string', 'readonly', '`readonly` spaced off its own colon — no name follows the run'], + ['private: string, readonly: string', 'private,readonly', 'two modifier-WORDS used as names'], + ]) { + ok( + parseParamNames(params).join(',') === expected, + `parseParamNames ate a real parameter name (${note}): ${JSON.stringify(params)} → ` + + `${JSON.stringify(parseParamNames(params))}, expected ${JSON.stringify(expected.split(','))}`, + ); + } + + // [#13227] End to end through the real `deriveSites`, because the parse is + // only interesting for what it costs downstream: the whole helper was + // DROPPED — no site AND no unresolved — which is the silent-drop class this + // gate exists to refuse. Paired with the single-modifier POSITIVE CONTROL, + // so the zero on `main` was a reading rather than a dead harness. + { + const helper = (params, arg) => + `class E {\n constructor(${params}) {\n (this as any).code = code;\n }\n}\n` + + `throw new E('${arg}', 'x');\n`; + const derive = (source) => + deriveSites({ registered: new Set(['ALREADY_REGISTERED']), files: [{ rel: 'packages/x/src/a.ts', source }], readFile: () => '' }); + for (const [params, probe, note] of [ + ['readonly code: string, readonly message: string', 'ONE_MODIFIER_HELPER', 'the single-modifier positive control'], + ['private readonly code: string, readonly message: string', 'PARAM_PROPERTY_HELPER', 'the two-modifier parameter property'], + ]) { + const { sites, unresolved } = derive(helper(params, probe)); + ok( + sites.some((s) => s.shape === 'codehelper' && s.code === probe), + `a constructor code helper was dropped — ${note} derived ${sites.length} site(s) and ` + + `${unresolved.length} unresolved, neither naming ${probe}`, + ); + } + } } // [#9568] The value-level reduction: a constant holding a TERNARY or a