Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8eb0970
Add `typescript-typing` skill for `any`-handling and type derivation
MajorLift Jul 16, 2026
9e20763
Ground the derive rule in a real `metamask-extension` #42583 countere…
MajorLift Jul 16, 2026
be1dc98
Split typescript-typing into a `typescript` domain: avoid-any, derive…
MajorLift Jul 16, 2026
a89095d
Add "why decompose first" rationale to `decompose-large-files`: bound…
MajorLift Jul 17, 2026
388dd98
Add `migration-context-cost` skill (fan-in/fan-out) + avoid-any secon…
MajorLift Jul 17, 2026
8483d91
Fix migration-context-cost inaccuracies, reframe decompose how-to aro…
MajorLift Jul 17, 2026
801758f
Reframe decompose how-to: identifying the boundaries is the key; extr…
MajorLift Jul 17, 2026
712d17a
Drop the CHANGELOG entry — it is for the CLI package, not skills
MajorLift Jul 30, 2026
8dc5b86
Fold in `typescript-compiler-blindspots`, moved from the `testing` do…
MajorLift Jul 30, 2026
ab926b8
Rename `typescript-compiler-blindspots` to `compiler-blindspots`
MajorLift Jul 31, 2026
e170b39
Rename `compiler-blindspots` to `tsc-blindspots`
MajorLift Jul 31, 2026
d50d94f
Add exit-tracing, a runtime declaration check, and a dead-module check
MajorLift Aug 3, 2026
a1ea24a
Add false precision as a divergence shape and the `IsAny` probe that …
MajorLift Aug 4, 2026
995f3c8
Name the installed command in `tsc-blindspots`'s description
MajorLift Aug 4, 2026
4f1ee6c
Add per-repo overlays for the three base-candidate typescript skills
MajorLift Aug 31, 2026
15407c0
Re-verify `tsc-blindspots` overlays against `origin/main`
MajorLift Aug 31, 2026
c7a21a3
Merge `main` so CODEOWNERS changes land on its structure
MajorLift Sep 14, 2026
c1ea26a
Name the owners of the `typescript` domain and its repo overlays
MajorLift Sep 14, 2026
6bfa2f5
Match `BaseController`'s current `Messenger` constraint, and hedge tw…
MajorLift Sep 14, 2026
42625a0
Prefer `@ts-expect-error` over `@ts-ignore`, and name the traps in de…
MajorLift Sep 14, 2026
f8a409a
Add `avoid-widening` for literal lookup tables and template literal keys
MajorLift Sep 17, 2026
f71f143
Merge remote-tracking branch 'origin/main' into jongsun/add/typescrip…
MajorLift Sep 18, 2026
faaeaad
Merge branch 'main' into jongsun/add/typescript-typing-skill
MajorLift Sep 18, 2026
a619f15
Merge branch 'main' into jongsun/add/typescript-typing-skill
MajorLift Sep 18, 2026
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@
/domains/pr-workflow/ @MetaMask/extension-platform @MetaMask/mobile-platform
/domains/swaps/ @MetaMask/swaps-engineers
/domains/testing/ @MetaMask/qa
/domains/typescript/ @MetaMask/extension-platform @MetaMask/mobile-platform
/domains/typescript/skills/*/repos/metamask-extension.md @MetaMask/extension-platform
/domains/typescript/skills/*/repos/metamask-mobile.md @MetaMask/mobile-platform
/domains/ui/ @MetaMask/design-system-engineers
60 changes: 60 additions & 0 deletions domains/typescript/skills/avoid-any/repos/core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
repo: core
parent: avoid-any
---

# `avoid-any` in core

## The rule is a deliberate re-enable — and it does not travel with the code

`@typescript-eslint/no-explicit-any` is `'error'` at `eslint.config.mjs:152`, in the `files: ['**/*.ts', '**/*.mts']` block (`:135`) that extends the shared config (`:136`). The comment above it says so outright:

```
// Enable rules that are disabled in `@metamask/eslint-config-typescript`
```

`@metamask/eslint-config-typescript@^15.0.0` (`package.json:70`) sets `no-explicit-any: 'off'` at `src/index.mjs:46`. The same is true of the majors extension and mobile run (`14.1.1:50`, `13.0.0:44`) — **the org's shared TypeScript config permits explicit `any`, and each repo bans it separately.**

The consequence is for extraction, not for writing code here: **a package that leaves core and adopts `@metamask/eslint-config-typescript` without copying `eslint.config.mjs:152` silently permits explicit `any`.** Nothing warns at the split; the code lints clean in its new home and the rule is simply gone. Copy the line with the code.

The block's glob is `**/*.ts` and `**/*.mts`. There are currently no `.tsx`, `.cts` or `.mts` sources under `packages/` (`main`, 2026-08-31), so nothing falls outside it — a first `.tsx` file would.

## The absorbed case is unreported here too

`src/index.mjs:69`–`:73` sets `no-unsafe-argument`, `no-unsafe-assignment`, `no-unsafe-call`, `no-unsafe-member-access` and `no-unsafe-return` all `'off'`, under `// Recommended rules that require type information`. `eslint.config.mjs` does not turn any of them back on. Those five are exactly the rules typescript-eslint ships for the absorbed case, so the parent skill's declared/absorbed split is this repo's literal configuration: **a written `any` fails CI; an absorbed `any` is reported by nothing.**

Core is nonetheless the tightest of the three on the neighbouring rules. Unlike extension and mobile it carries **no ESLint-v9 reversion block**, so `no-floating-promises` and `no-unsafe-function-type` are both live from the shared config's `recommended` / `recommendedTypeChecked` (verified in `@typescript-eslint/eslint-plugin@8.54.0`, `dist/configs/flat/recommended.js`). Two gaps it shares with the others anyway:

- `no-unsafe-enum-comparison` — the shared config disables it at `src/index.mjs:78`.
- `restrict-template-expressions` — on, at `src/index.mjs:171`–`:177`, but neither there nor here does anything set `allowAny`, whose default is `true` (`@typescript-eslint/eslint-plugin`, `dist/rules/restrict-template-expressions.js`, `defaultOptions`). `` `${value}` `` with an `any` operand is permitted.

## Where the absorbed `any` enters here

**Not through `.js`.** `allowJs` and `checkJs` appear in no tsconfig in the repo (`git grep -l 'allowJs\|checkJs' -- '*.json'` returns nothing on `main`). `.js` files exist — `eslint.config.mjs:128` has a `files: ['**/*.{js,cjs}']` block for them — but they are outside every TypeScript program, so a `.ts` file cannot import one without a declaration. **The route that carries most of extension's exposure does not exist here. Do not go looking for it.**

**Through the root `types/` directory instead, and it reaches every package at once.** Ten shorthand `declare module` files live there, each typing its entire module as `any` with no `any` written and no diagnostic under `strict`:

`types/@metamask/contract-metadata.d.ts` · `types/@metamask/eth-json-rpc-filters.d.ts` (`/subscriptionManager`) · `types/@metamask/ethjs-provider-http.d.ts` · `types/@metamask/ethjs-unit.d.ts` · `types/@metamask/metamask-eth-abis.d.ts` · `types/eth-ens-namehash.d.ts` · `types/eth-json-rpc-infura/src/createProvider.d.ts` · `types/ethereum-ens-network-map.d.ts` · `types/ethjs-query.d.ts` · `types/single-call-balance-checker-abi.d.ts`

Package tsconfigs pull the directory in wholesale — `packages/network-controller/tsconfig.json` ends `"include": ["../../types", "../../tests", "./src", "./tests"]` — so all ten are in every package's program, not local to whichever package imports them. An ABI or a token-metadata blob entering a controller through one of these is `any` at the point of entry, and every precise signature downstream of it is absorbed.

**And an absorbed `any` crosses package boundaries as a published type.** `tsconfig.base.json:6` sets `composite: true`, and the root `tsconfig.json` wires the packages together with project references. A dependent package sees its dependency through the emitted `.d.ts`, not through source — so an absorbed `any` on an exported signature is not a local imprecision, it is the interface every consuming package compiles against, and the extension and mobile bundles beyond them.

## Two settings that are not `any` sources

`tsconfig.base.json:12` sets `strict: true`, and it is the only `"strict"` key in any tsconfig in the repo — no package weakens `noImplicitAny`. `noUncheckedIndexedAccess` is not set, which widens indexing results rather than producing `any`; a replacement for one of the shorthand declarations typed `Record<string, X>` will read as total when it is not.

## Checking it

```bash
git grep -nE "^declare module '[^']+';$" -- '*.d.ts' # shorthand = whole module is any
git grep -rn "eslint-disable.*no-explicit-any" # the declared any, countable
```

Neither finds the absorbed form. Probe it at the call site with `IsAny<T>` — the probe, its controls, and the `declare module` composition that turns one of these shorthand declarations into a real type are in `tsc-blindspots`.

## Open questions

- Which of the ten shorthand declarations are actually imported, and from which packages? Settled by resolving importers per specifier, not by the file list.
- Do any package `src` files re-export a value sourced from one of them, publishing the `any` through a `.d.ts`? Settled by an `IsAny<T>` probe against each package's built declaration output, not against its source.
- Whether every package tsconfig includes `../../types`, or only some. Checked here for `network-controller` only; settled by reading the `include` of each `packages/*/tsconfig.json`.
63 changes: 63 additions & 0 deletions domains/typescript/skills/avoid-any/repos/metamask-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
repo: metamask-extension
parent: avoid-any
---

# `avoid-any` in metamask-extension

## The rule is a re-enable, and the rules that would catch the absorbed case are off

`@typescript-eslint/no-explicit-any` is `'error'` at `.eslintrc.js:162`. That line **restores** a rule the shared config switches off. Extension is on `@metamask/eslint-config-typescript@^14.1.1` (`package.json:620`), whose `src/index.mjs` sets:

- `:50` — `@typescript-eslint/no-explicit-any: 'off'`
- `:73`–`:77` — `no-unsafe-argument`, `no-unsafe-assignment`, `no-unsafe-call`, `no-unsafe-member-access`, `no-unsafe-return`, all `'off'`, under the comment `// Recommended rules that require type information`

Those five are the rules typescript-eslint ships for the absorbed case. Nothing in `.eslintrc.js` turns any of them back on. So the parent skill's declared/absorbed split is this repo's literal configuration: **a written `any` fails CI, and an absorbed `any` is reported by nothing at all.**

## Three further rules off — extension's distinguishing gap

The comment at `.eslintrc.js:272`–`:276` introduces a block of reversions — *"removing changes to our shared ESLint config made after version v9 … a temporary measure to get us to ESLint v9 compatible versions, at which point we can restore the intended rules"* — and the rules it governs run from `:277` to `:306`. Three of them bear on `any`:

- **`no-floating-promises: 'off'` (`:284`)** — with the `no-unsafe-*` rules also off, a call whose return is absorbed `any` and a call that drops a real promise are the same unreported line. This rule is the last one that would have noticed the value was thenable.
- **`no-unsafe-enum-comparison: 'off'` (`:289`)** — comparing an enum member against a value that is `any` typechecks. Note this one is **not** extension-specific: the shared config already disables it at `src/index.mjs:82` (`// Recommended rules that we do not want to use`), so `:289` is redundant and the gap exists in mobile and core too.
- **`no-unsafe-function-type: 'off'` (`:291`)** — **this one is extension-only.** `Function` accepts any argument list and returns `any`, so `const f: Function` is an `any`-producing annotation with no `any` written. Mobile and core do not disable it, and it ships in typescript-eslint v8's `recommended` (verified in `@typescript-eslint/eslint-plugin@8.54.0`, `dist/configs/flat/recommended.js`).

A fourth rule looks tightened and is not. `restrict-template-expressions` gets a local config at `:262` (`allowBoolean`, `allowNumber`) — the same option object the shared config already passes at `src/index.mjs:171`–`:177`. Neither sets `allowAny`, whose default is `true` (`@typescript-eslint/eslint-plugin`, `dist/rules/restrict-template-expressions.js`, `defaultOptions`). So `` `${value}` `` with an `any` operand is permitted. Same conclusion in core, by the same default; in mobile the rule is `'off'` outright.

## Where the absorbed `any` enters here

**The `.js` boundary is the largest of the three repos.** `tsconfig.json` sets `allowJs: true` and never sets `checkJs`. Inside its `include` (`app`, `development`, `shared`, `test`, `types`, `ui`) there are **1,182 `.js`/`.jsx` files against 7,373 `.ts`/`.tsx`** (`origin/main`, 2026-08-31). An untyped JS export's parameters and return are `any` at every TS call site, with zero diagnostics — verified by probe under `--strict` (`IsAny<Parameters<typeof f>[0]>` resolves `true`, `tsc` exits 0).

**Two bodyless ambient module declarations**, of 16 `declare module` lines in the repo:

- `shared/lib/declare-modules.d.ts:1` — `declare module 'human-standard-token-abi';`
- `types/lavamoat__lavadome-core.d.ts:1` — `declare module '@lavamoat/lavadome-core';`

A shorthand declaration types **every** import from that module as `any`, silently, under `strict` (verified by the same probe). Both files are inside `include`.

**The 300 story files get neither `tsc` nor the rule, and the two exclusions compound.** `.eslintrc.js:26`–`:28` builds a `tsconfig` object from `tsconfig.json` with the TypeScript API — `ts.findConfigFile`, `ts.readConfigFile`, `ts.parseJsonConfigFileContent` — and `:148` uses `tsconfig.fileNames` as the `files` list for the block that carries `no-explicit-any`. So the rule's scope *is* the tsconfig program. `tsconfig.json` excludes `**/*.stories.ts` and `**/*.stories.tsx` (`:31`–`:32`) — **300 files on `origin/main`** — which puts them outside `fileNames` and therefore outside the rule as well. The repo says so itself, in the docblock at `.eslintrc.js:684`–`:691`: *"Storybook (JavaScript only) … This block is for overriding settings from the base config. It's JavaScript-only because the Storybook TypeScript files don't have the base config applied."* In those 300 files an explicit `any` is not an error and an absorbed `any` is not even typechecked.

The same mechanism generalises: any `.ts`/`.tsx` file outside `tsconfig.json`'s `include` list is outside `no-explicit-any` too, silently, with no entry in `.eslintrc.js` naming it. Beyond the stories that is currently 3 files, all under `.devcontainer/` (7,379 `.ts`/`.tsx` on `origin/main`; 7,373 in the `include` dirs, 3 at repo root matching `*.ts`). The hole is real and small — it is the stories that carry it.

The parent skill's own absorbed example (`shared/lib/token-util.ts`, ethers dynamic-method results) is extension code; it is the shape to expect wherever a precise signature sits downstream of ethers, a `.js` module, or one of the two shorthand declarations above.

## Two settings that are not `any` sources, so they don't belong in this hunt

- `useUnknownInCatchVariables: true` is set explicitly in `tsconfig.json` (already implied by the inherited `strict`). Catch bindings are `unknown`, so no `catch (e: any)` is needed.
- `noUncheckedIndexedAccess` is not set. That widens indexing results, not `any` — a replacement for a bodyless declaration typed `Record<string, X>` will read as total when it is not.

`strict` is inherited from `@tsconfig/node22` (`node_modules/@tsconfig/node22/tsconfig.json`), not declared locally — a local `tsconfig.json` edit that changed `extends` would drop `noImplicitAny` with nothing in the file mentioning it.

## Checking it

```bash
git grep -nE "^declare module '[^']+';$" -- '*.d.ts' # shorthand = whole module is any
git grep -rn "eslint-disable.*no-explicit-any" # the declared any, countable
```

The absorbed form is invisible to both. Probe it at the call site with `IsAny<T>` — the probe, its controls, and the `declare module` composition that repairs one are in `tsc-blindspots`.

## Open questions

- How many of the 1,182 in-program `.js` files are actually imported from `.ts`? Settled by resolving each `.js` file's importers, not by the file count above.
- Do the two shorthand declarations still need to be shorthand, or do the packages now ship types? Settled by checking each package's `types`/`exports` field at the installed version.
Loading
Loading