Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 132 additions & 3 deletions scripts/check-dispatcher-error-vocabulary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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] : '';
});
}
Expand Down Expand Up @@ -1844,6 +1901,78 @@ function selfTest() {
'splitTopLevel counted a nested or templated comma as a separator',
);
ok(parseParamNames('readonly a: Map<string, number> = 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<string, number> = 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
Expand Down
Loading