From 6a7a76dbff253c3f0505c7ba8093d55efeeb0fd3 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 15 Sep 2026 16:26:21 +0200 Subject: [PATCH 1/6] chore: suppress existing type errors so lint:tsc can run in CI Adds tsc-suppressions.json, which records how many type errors of each code every file currently produces, in the same shape as eslint-suppressions.json. yarn lint:tsc:check typechecks the repo and fails when a file produces an error the suppressions do not cover, or when a suppression no longer covers anything. yarn lint:tsc:suppress regenerates the file. Also gives the package lint configs a rootDir so that declarations for files outside a package, such as tests/helpers.ts, are emitted into the lint cache rather than into the repo. --- .github/workflows/lint-build-test.yml | 1 + package.json | 4 +- scripts/lib/lint-tsc.test.ts | 154 +++ scripts/lib/lint-tsc.ts | 60 ++ scripts/lib/tsc-suppressions.test.ts | 385 +++++++ scripts/lib/tsc-suppressions.ts | 318 ++++++ scripts/lint-tsc.test.ts | 39 + scripts/lint-tsc.ts | 10 + tsc-suppressions.json | 1324 +++++++++++++++++++++++++ tsconfig.packages.lint.json | 8 +- 10 files changed, 2301 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/lint-tsc.test.ts create mode 100644 scripts/lib/lint-tsc.ts create mode 100644 scripts/lib/tsc-suppressions.test.ts create mode 100644 scripts/lib/tsc-suppressions.ts create mode 100644 scripts/lint-tsc.test.ts create mode 100644 scripts/lint-tsc.ts create mode 100644 tsc-suppressions.json diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 0065ed68e48..dbd9c43f7e2 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -87,6 +87,7 @@ jobs: - lint:dependencies - lint:misc:check - lint:teams + - lint:tsc:check - lint:tsconfigs:all - messenger-action-types:check - readme-content:check diff --git a/package.json b/package.json index 7619df90932..89b4b3da2b9 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "docs:platform-api:build": "yarn workspace @metamask/platform-api-docs cli ../.. --build --project-label Core", "docs:platform-api:dev": "yarn workspace @metamask/platform-api-docs cli ../.. --dev --project-label Core", "docs:platform-api:serve": "yarn workspace @metamask/platform-api-docs cli ../.. --serve --project-label Core", - "lint": "yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams && yarn messenger-action-types:check && yarn readme-content:check && yarn lint:tsconfigs:all && yarn codeowners:check", + "lint": "yarn lint:tsc:check && yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams && yarn messenger-action-types:check && yarn readme-content:check && yarn lint:tsconfigs:all && yarn codeowners:check", "lint:dependencies": "knip --config knip.config.mts --dependencies && yarn dedupe --check", "lint:dependencies:fix": "knip --config knip.config.mts --dependencies && yarn dedupe", "lint:eslint": "yarn build:only-clean && NODE_OPTIONS='--max-old-space-size=10240' yarn eslint", @@ -38,8 +38,10 @@ "lint:misc:check": "yarn lint:misc --check", "lint:teams": "tsx scripts/lint-teams-json.ts", "lint:tsc": "tsc --build tsconfig.lint.json", + "lint:tsc:check": "tsx scripts/lint-tsc.ts", "lint:tsc:clean": "yarn lint:tsc:only-clean && yarn lint:tsc", "lint:tsc:only-clean": "rimraf -g 'packages/*/.tsc-lint-cache' '.tsc-lint-cache'", + "lint:tsc:suppress": "tsx scripts/lint-tsc.ts --update", "lint:tsconfigs": "tsx scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:all": "yarn workspaces foreach --all --parallel --interlaced --verbose run lint:tsconfigs", "lint:tsconfigs:fix": "tsx scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", diff --git a/scripts/lib/lint-tsc.test.ts b/scripts/lib/lint-tsc.test.ts new file mode 100644 index 00000000000..afaff1bddbb --- /dev/null +++ b/scripts/lib/lint-tsc.test.ts @@ -0,0 +1,154 @@ +import { jest } from '@jest/globals'; + +// `jest.mock` does not apply to ES modules, so the module registry is stubbed +// with `jest.unstable_mockModule` and the modules under test are imported +// dynamically afterwards. +jest.unstable_mockModule('execa', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.unstable_mockModule('./tsc-suppressions.js', () => ({ + parseTscOutput: jest.fn(), + buildSuppressions: jest.fn(), + compareErrorsToSuppressions: jest.fn(), + readSuppressions: jest.fn(), + writeSuppressions: jest.fn(), + printReport: jest.fn(), +})); + +const { default: execa } = await import('execa'); +const tscSuppressions = await import('./tsc-suppressions.js'); +const { lintTsc } = await import('./lint-tsc.js'); + +const ERROR = { filePath: 'a.ts', code: 'TS2322', message: 'Nope.' }; + +const PASSING_REPORT = { + unsuppressedErrors: [], + staleSuppressions: [], + didPass: true, +}; + +/** + * Stubs a `tsc` run which produces the given output and one parsed error. + * + * @param output - The output that `tsc` should produce. + */ +function mockTscRun(output: string): void { + jest.mocked(execa).mockResolvedValue({ all: output } as never); + jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([ERROR]); +} + +describe('lintTsc', () => { + let originalProcess: typeof globalThis.process; + + beforeEach(() => { + originalProcess = globalThis.process; + // The exit code is reset because it is global state that another test file + // may have set. + globalThis.process = { ...globalThis.process, exitCode: undefined }; + jest.spyOn(console, 'log').mockReturnValue(undefined); + }); + + afterEach(() => { + globalThis.process = originalProcess; + }); + + it('typechecks every package in the repo, capturing errors rather than throwing', async () => { + mockTscRun(''); + jest + .mocked(tscSuppressions.compareErrorsToSuppressions) + .mockReturnValue(PASSING_REPORT); + + await lintTsc([]); + + expect(execa).toHaveBeenCalledWith( + 'tsc', + ['--build', 'tsconfig.lint.json', '--pretty', 'false'], + expect.objectContaining({ reject: false, all: true }), + ); + }); + + it('checks the parsed errors against the suppressions file', async () => { + mockTscRun('a.ts(1,1): error TS2322: Nope.'); + const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; + jest + .mocked(tscSuppressions.readSuppressions) + .mockResolvedValue(suppressions); + jest + .mocked(tscSuppressions.compareErrorsToSuppressions) + .mockReturnValue(PASSING_REPORT); + + await lintTsc([]); + + expect(tscSuppressions.parseTscOutput).toHaveBeenCalledWith( + 'a.ts(1,1): error TS2322: Nope.', + ); + expect(tscSuppressions.compareErrorsToSuppressions).toHaveBeenCalledWith({ + errors: [ERROR], + suppressions, + }); + }); + + it('prints the report and leaves the exit code alone when the check passes', async () => { + mockTscRun(''); + jest + .mocked(tscSuppressions.compareErrorsToSuppressions) + .mockReturnValue(PASSING_REPORT); + + await lintTsc([]); + + expect(tscSuppressions.printReport).toHaveBeenCalledWith(PASSING_REPORT); + expect(process.exitCode).toBeUndefined(); + }); + + it('exits with a non-zero code when the check fails', async () => { + mockTscRun(''); + jest.mocked(tscSuppressions.compareErrorsToSuppressions).mockReturnValue({ + unsuppressedErrors: [ + { + filePath: 'a.ts', + code: 'TS2322', + count: 1, + suppressedCount: 0, + messages: ['Nope.'], + }, + ], + staleSuppressions: [], + didPass: false, + }); + + await lintTsc([]); + + expect(process.exitCode).toBe(1); + }); + + it('treats a run that produces no output as having no errors', async () => { + jest.mocked(execa).mockResolvedValue({ all: undefined } as never); + jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); + jest + .mocked(tscSuppressions.compareErrorsToSuppressions) + .mockReturnValue(PASSING_REPORT); + + await lintTsc([]); + + expect(tscSuppressions.parseTscOutput).toHaveBeenCalledWith(''); + }); + + it('rewrites the suppressions file when given --update, without failing', async () => { + mockTscRun(''); + const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; + jest + .mocked(tscSuppressions.buildSuppressions) + .mockReturnValue(suppressions); + + await lintTsc(['--update']); + + expect(tscSuppressions.buildSuppressions).toHaveBeenCalledWith([ERROR]); + expect(tscSuppressions.writeSuppressions).toHaveBeenCalledWith( + expect.objectContaining({ suppressions }), + ); + expect(tscSuppressions.compareErrorsToSuppressions).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/scripts/lib/lint-tsc.ts b/scripts/lib/lint-tsc.ts new file mode 100644 index 00000000000..9f431af776a --- /dev/null +++ b/scripts/lib/lint-tsc.ts @@ -0,0 +1,60 @@ +import execa from 'execa'; +import path from 'path'; + +import { + buildSuppressions, + compareErrorsToSuppressions, + parseTscOutput, + printReport, + readSuppressions, + writeSuppressions, +} from './tsc-suppressions.js'; + +const REPO_ROOT = path.join(import.meta.dirname, '..', '..'); + +const SUPPRESSIONS_FILE_NAME = 'tsc-suppressions.json'; + +/** + * Typechecks every package in the repo and compares the type errors it finds + * against `tsc-suppressions.json`, failing if any error is not suppressed there + * or if any suppression no longer covers an error. This keeps packages that are + * free of type errors from regressing while the existing errors are worked + * through. + * + * Passing `--update` rewrites the suppressions file from the errors that + * currently exist rather than checking against it. + * + * @param argv - The arguments passed to this script. + */ +export async function lintTsc(argv: readonly string[]): Promise { + const suppressionsFilePath = path.join(REPO_ROOT, SUPPRESSIONS_FILE_NAME); + + const { all: output } = await execa( + 'tsc', + ['--build', 'tsconfig.lint.json', '--pretty', 'false'], + { cwd: REPO_ROOT, reject: false, all: true, preferLocal: true }, + ); + const errors = parseTscOutput(output ?? ''); + + if (argv.includes('--update')) { + const suppressions = buildSuppressions(errors); + await writeSuppressions({ + filePath: suppressionsFilePath, + suppressions, + }); + console.log( + `✅ Updated ${SUPPRESSIONS_FILE_NAME}: now suppressing ${errors.length} type error(s) across ${Object.keys(suppressions).length} file(s).`, + ); + return; + } + + const report = compareErrorsToSuppressions({ + errors, + suppressions: await readSuppressions(suppressionsFilePath), + }); + printReport(report); + + if (!report.didPass) { + process.exitCode = 1; + } +} diff --git a/scripts/lib/tsc-suppressions.test.ts b/scripts/lib/tsc-suppressions.test.ts new file mode 100644 index 00000000000..453716d9604 --- /dev/null +++ b/scripts/lib/tsc-suppressions.test.ts @@ -0,0 +1,385 @@ +import { jest } from '@jest/globals'; +import { + createSandbox, + readFile, + readJsonFile, + writeJsonFile, +} from '@metamask/utils/node'; +import path from 'path'; + +import { + buildSuppressions, + compareErrorsToSuppressions, + parseTscOutput, + printReport, + readSuppressions, + writeSuppressions, +} from './tsc-suppressions.js'; + +const { withinSandbox } = createSandbox('lib/tsc-suppressions'); + +describe('parseTscOutput', () => { + it('parses an error that has a file, line, and column', () => { + const output = + "packages/foo/src/foo.test.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'."; + + expect(parseTscOutput(output)).toStrictEqual([ + { + filePath: 'packages/foo/src/foo.test.ts', + code: 'TS2322', + message: "Type 'string' is not assignable to type 'number'.", + }, + ]); + }); + + it('parses multiple errors within the same file', () => { + const output = [ + 'packages/foo/src/foo.test.ts(12,5): error TS2322: Nope.', + 'packages/foo/src/foo.test.ts(40,1): error TS2322: Nope again.', + ].join('\n'); + + expect(parseTscOutput(output)).toHaveLength(2); + }); + + it('ignores the indented lines that elaborate on an error', () => { + const output = [ + 'packages/foo/src/foo.test.ts(12,5): error TS2769: No overload matches this call.', + ' The last overload gave the following error.', + " Argument of type 'A' is not assignable to parameter of type 'B'.", + ].join('\n'); + + expect(parseTscOutput(output)).toStrictEqual([ + { + filePath: 'packages/foo/src/foo.test.ts', + code: 'TS2769', + message: 'No overload matches this call.', + }, + ]); + }); + + it('ignores lines that are not errors', () => { + const output = [ + 'packages/foo/src/foo.test.ts(12,5): error TS2322: Nope.', + '', + 'Found 1 error in 1 file.', + ].join('\n'); + + expect(parseTscOutput(output)).toHaveLength(1); + }); + + it('parses an error that has no file, using an empty file path', () => { + const output = "error TS6053: File 'nope.ts' not found."; + + expect(parseTscOutput(output)).toStrictEqual([ + { + filePath: '', + code: 'TS6053', + message: "File 'nope.ts' not found.", + }, + ]); + }); + + it('returns no errors when given empty output', () => { + expect(parseTscOutput('')).toStrictEqual([]); + }); +}); + +describe('buildSuppressions', () => { + it('counts errors by file and then by error code', () => { + const errors = [ + { filePath: 'b.ts', code: 'TS2322', message: 'One.' }, + { filePath: 'b.ts', code: 'TS2322', message: 'Two.' }, + { filePath: 'b.ts', code: 'TS7005', message: 'Three.' }, + ]; + + expect(buildSuppressions(errors)).toStrictEqual({ + 'b.ts': { + TS2322: { count: 2 }, + TS7005: { count: 1 }, + }, + }); + }); + + it('sorts files and error codes so the file stays stable across runs', () => { + const errors = [ + { filePath: 'b.ts', code: 'TS7005', message: 'One.' }, + { filePath: 'a.ts', code: 'TS2322', message: 'Two.' }, + { filePath: 'b.ts', code: 'TS2322', message: 'Three.' }, + ]; + + const suppressions = buildSuppressions(errors); + + expect(Object.keys(suppressions)).toStrictEqual(['a.ts', 'b.ts']); + expect(Object.keys(suppressions['b.ts'] ?? {})).toStrictEqual([ + 'TS2322', + 'TS7005', + ]); + }); + + it('returns an empty object when there are no errors', () => { + expect(buildSuppressions([])).toStrictEqual({}); + }); +}); + +describe('compareErrorsToSuppressions', () => { + it('reports an error whose code is not suppressed for that file', () => { + const report = compareErrorsToSuppressions({ + errors: [{ filePath: 'a.ts', code: 'TS2322', message: 'Nope.' }], + suppressions: {}, + }); + + expect(report).toStrictEqual({ + unsuppressedErrors: [ + { + filePath: 'a.ts', + code: 'TS2322', + count: 1, + suppressedCount: 0, + messages: ['Nope.'], + }, + ], + staleSuppressions: [], + didPass: false, + }); + }); + + it('reports an error whose code differs from the codes suppressed for that file', () => { + const report = compareErrorsToSuppressions({ + errors: [{ filePath: 'a.ts', code: 'TS2322', message: 'Nope.' }], + suppressions: { 'a.ts': { TS7005: { count: 1 } } }, + }); + + expect(report.unsuppressedErrors).toStrictEqual([ + { + filePath: 'a.ts', + code: 'TS2322', + count: 1, + suppressedCount: 0, + messages: ['Nope.'], + }, + ]); + }); + + it('reports an error when a file has more errors of a code than are suppressed', () => { + const report = compareErrorsToSuppressions({ + errors: [ + { filePath: 'a.ts', code: 'TS2322', message: 'One.' }, + { filePath: 'a.ts', code: 'TS2322', message: 'Two.' }, + ], + suppressions: { 'a.ts': { TS2322: { count: 1 } } }, + }); + + expect(report.unsuppressedErrors).toStrictEqual([ + { + filePath: 'a.ts', + code: 'TS2322', + count: 2, + suppressedCount: 1, + messages: ['One.', 'Two.'], + }, + ]); + expect(report.didPass).toBe(false); + }); + + it('passes when the number of errors matches the number suppressed', () => { + const report = compareErrorsToSuppressions({ + errors: [{ filePath: 'a.ts', code: 'TS2322', message: 'One.' }], + suppressions: { 'a.ts': { TS2322: { count: 1 } } }, + }); + + expect(report).toStrictEqual({ + unsuppressedErrors: [], + staleSuppressions: [], + didPass: true, + }); + }); + + it('reports a stale suppression when a file has fewer errors than are suppressed', () => { + const report = compareErrorsToSuppressions({ + errors: [{ filePath: 'a.ts', code: 'TS2322', message: 'One.' }], + suppressions: { 'a.ts': { TS2322: { count: 3 } } }, + }); + + expect(report.staleSuppressions).toStrictEqual([ + { filePath: 'a.ts', code: 'TS2322', count: 1, suppressedCount: 3 }, + ]); + expect(report.didPass).toBe(false); + }); + + it('reports a stale suppression when a file no longer has any errors', () => { + const report = compareErrorsToSuppressions({ + errors: [], + suppressions: { 'a.ts': { TS2322: { count: 1 } } }, + }); + + expect(report.staleSuppressions).toStrictEqual([ + { filePath: 'a.ts', code: 'TS2322', count: 0, suppressedCount: 1 }, + ]); + }); + + it('passes when there are neither errors nor suppressions', () => { + const report = compareErrorsToSuppressions({ + errors: [], + suppressions: {}, + }); + + expect(report.didPass).toBe(true); + }); + + it('never suppresses an error that has no file, as it is a configuration error', () => { + const report = compareErrorsToSuppressions({ + errors: [{ filePath: '', code: 'TS6053', message: 'Not found.' }], + suppressions: { '': { TS6053: { count: 1 } } }, + }); + + expect(report.unsuppressedErrors).toStrictEqual([ + { + filePath: '', + code: 'TS6053', + count: 1, + suppressedCount: 0, + messages: ['Not found.'], + }, + ]); + }); +}); + +describe('readSuppressions', () => { + it('reads the suppressions that the file holds', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'suppressions.json'); + const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; + await writeJsonFile(filePath, suppressions); + + expect(await readSuppressions(filePath)).toStrictEqual(suppressions); + }); + }); + + it('treats a missing file as having no suppressions', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.json'); + + expect(await readSuppressions(filePath)).toStrictEqual({}); + }); + }); + + it('re-throws an error that is not about a missing file', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + // A directory can be opened but not read as a file. + await expect(readSuppressions(sandbox.directoryPath)).rejects.toThrow( + expect.anything(), + ); + }); + }); +}); + +describe('writeSuppressions', () => { + it('writes the suppressions to the file', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'suppressions.json'); + const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; + + await writeSuppressions({ filePath, suppressions }); + + expect(await readJsonFile(filePath)).toStrictEqual(suppressions); + }); + }); + + it('indents the file and ends it with a newline, as Oxfmt expects', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'suppressions.json'); + + await writeSuppressions({ + filePath, + suppressions: { 'a.ts': { TS2322: { count: 1 } } }, + }); + + expect(await readFile(filePath)).toBe( + `{\n "a.ts": {\n "TS2322": {\n "count": 1\n }\n }\n}\n`, + ); + }); + }); +}); + +describe('printReport', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockReturnValue(undefined); + }); + + it('announces success when there is nothing to report', () => { + printReport({ + unsuppressedErrors: [], + staleSuppressions: [], + didPass: true, + }); + + expect(console.log).toHaveBeenCalledWith( + '✅ No new type errors detected. Good job!', + ); + }); + + it('prints each unsuppressed error along with its messages', () => { + printReport({ + unsuppressedErrors: [ + { + filePath: 'a.ts', + code: 'TS2322', + count: 2, + suppressedCount: 1, + messages: ['One.', 'Two.'], + }, + ], + staleSuppressions: [], + didPass: false, + }); + + const output = jest.mocked(console.log).mock.calls.flat().join('\n'); + expect(output).toContain('a.ts'); + expect(output).toContain('TS2322'); + expect(output).toContain('One.'); + expect(output).toContain('Two.'); + }); + + it('labels an error that has no file', () => { + printReport({ + unsuppressedErrors: [ + { + filePath: '', + code: 'TS6053', + count: 1, + suppressedCount: 0, + messages: ['Not found.'], + }, + ], + staleSuppressions: [], + didPass: false, + }); + + const output = jest.mocked(console.log).mock.calls.flat().join('\n'); + expect(output).toContain('(no file)'); + }); + + it('prints each stale suppression', () => { + printReport({ + unsuppressedErrors: [], + staleSuppressions: [ + { filePath: 'a.ts', code: 'TS2322', count: 0, suppressedCount: 1 }, + ], + didPass: false, + }); + + const output = jest.mocked(console.log).mock.calls.flat().join('\n'); + expect(output).toContain('a.ts'); + expect(output).toContain('yarn lint:tsc:suppress'); + }); +}); diff --git a/scripts/lib/tsc-suppressions.ts b/scripts/lib/tsc-suppressions.ts new file mode 100644 index 00000000000..7bdad1d50c1 --- /dev/null +++ b/scripts/lib/tsc-suppressions.ts @@ -0,0 +1,318 @@ +import { readJsonFile, writeFile } from '@metamask/utils/node'; + +/** + * A type error reported by `tsc`. + */ +export type TscError = { + filePath: string; + code: string; + message: string; +}; + +/** + * The number of type errors of a given code that are knowingly ignored within a + * given file. + */ +export type Suppression = { + count: number; +}; + +/** + * All of the type errors that are knowingly ignored across the repo, keyed by + * file and then by error code. + * + * This mirrors the shape of `eslint-suppressions.json`. Note that error + * messages are deliberately left out of the key: their wording changes between + * TypeScript releases, which would invalidate the whole file at once. + */ +export type TscSuppressions = Record>; + +/** + * A group of errors of the same code within the same file which exceeds the + * number of errors that are suppressed there. + */ +export type UnsuppressedError = { + filePath: string; + code: string; + count: number; + suppressedCount: number; + messages: string[]; +}; + +/** + * A suppression that covers more errors than its file now produces, meaning + * that some of the errors it covers have been fixed. + */ +export type StaleSuppression = { + filePath: string; + code: string; + count: number; + suppressedCount: number; +}; + +/** + * The result of checking the type errors in the repo against the suppressions + * file. + */ +export type TscSuppressionsReport = { + unsuppressedErrors: UnsuppressedError[]; + staleSuppressions: StaleSuppression[]; + didPass: boolean; +}; + +/** + * Matches a line such as: + * + * `packages/foo/src/foo.test.ts(12,5): error TS2322: Type 'string' is not ...` + * + * Lines that elaborate on an error are indented, so they never match. + */ +const ERROR_WITH_FILE_REGEXP = + /^(?[^\s(][^(]*)\((?\d+),(?\d+)\): error (?TS\d+): (?.*)$/u; + +/** + * Matches an error that `tsc` reports without a file, such as: + * + * `error TS6053: File 'nope.ts' not found.` + */ +const ERROR_WITHOUT_FILE_REGEXP = /^error (?TS\d+): (?.*)$/u; + +/** + * Builds the key under which an error of a given code within a given file is + * grouped. + * + * @param filePath - The path to the file, relative to the repo root. + * @param code - The TypeScript error code. + * @returns The key for that combination. + */ +function buildKey(filePath: string, code: string): string { + return `${filePath}::${code}`; +} + +/** + * Extracts the type errors from the output of `tsc --pretty false`. + * + * @param output - The combined stdout and stderr of a `tsc` run. + * @returns The errors, in the order that `tsc` reported them. + */ +export function parseTscOutput(output: string): TscError[] { + const errors: TscError[] = []; + + for (const line of output.split('\n')) { + const matchWithFile = ERROR_WITH_FILE_REGEXP.exec(line); + if (matchWithFile?.groups) { + errors.push({ + filePath: String(matchWithFile.groups.filePath), + code: String(matchWithFile.groups.code), + message: String(matchWithFile.groups.message), + }); + continue; + } + + const matchWithoutFile = ERROR_WITHOUT_FILE_REGEXP.exec(line); + if (matchWithoutFile?.groups) { + errors.push({ + filePath: '', + code: String(matchWithoutFile.groups.code), + message: String(matchWithoutFile.groups.message), + }); + } + } + + return errors; +} + +/** + * Tallies errors by file and then by error code, sorting both so that the + * suppressions file produces a minimal diff from one run to the next. + * + * @param errors - The errors to tally. + * @returns Suppressions that cover exactly the given errors. + */ +export function buildSuppressions( + errors: readonly TscError[], +): TscSuppressions { + const countsByFilePath = new Map>(); + + for (const error of errors) { + const countsByCode = + countsByFilePath.get(error.filePath) ?? new Map(); + countsByCode.set(error.code, (countsByCode.get(error.code) ?? 0) + 1); + countsByFilePath.set(error.filePath, countsByCode); + } + + const suppressions: TscSuppressions = {}; + for (const [filePath, countsByCode] of [...countsByFilePath].sort( + ([filePathA], [filePathB]) => filePathA.localeCompare(filePathB), + )) { + const suppressionsByCode: Record = {}; + for (const [code, count] of [...countsByCode].sort(([codeA], [codeB]) => + codeA.localeCompare(codeB), + )) { + suppressionsByCode[code] = { count }; + } + suppressions[filePath] = suppressionsByCode; + } + + return suppressions; +} + +/** + * Checks the given errors against the given suppressions. + * + * An error is unsuppressed if its file produces more errors of its code than + * the suppressions allow. A suppression is stale if its file produces fewer + * errors of that code than it covers, which means that those errors have been + * fixed and the suppression should be removed. + * + * Errors that `tsc` reports without a file are configuration errors rather than + * type errors, so they are never suppressed. + * + * @param args - The arguments to this function. + * @param args.errors - The errors from the current run. + * @param args.suppressions - The suppressions to check against. + * @returns A report of what is new and what is stale. + */ +export function compareErrorsToSuppressions({ + errors, + suppressions, +}: { + errors: readonly TscError[]; + suppressions: TscSuppressions; +}): TscSuppressionsReport { + const currentSuppressions = buildSuppressions(errors); + + // Group the messages so that the report can show what was actually found, + // keyed the same way that suppressions are. + const groups = new Map< + string, + { filePath: string; code: string; messages: string[] } + >(); + for (const error of errors) { + const key = buildKey(error.filePath, error.code); + const group = groups.get(key); + if (group) { + group.messages.push(error.message); + } else { + groups.set(key, { + filePath: error.filePath, + code: error.code, + messages: [error.message], + }); + } + } + + const unsuppressedErrors: UnsuppressedError[] = []; + for (const { filePath, code, messages } of groups.values()) { + const suppressedCount = + filePath === '' ? 0 : (suppressions[filePath]?.[code]?.count ?? 0); + if (messages.length > suppressedCount) { + unsuppressedErrors.push({ + filePath, + code, + count: messages.length, + suppressedCount, + messages, + }); + } + } + + const staleSuppressions: StaleSuppression[] = []; + for (const [filePath, suppressedByCode] of Object.entries(suppressions)) { + if (filePath === '') { + continue; + } + for (const [code, { count: suppressedCount }] of Object.entries( + suppressedByCode, + )) { + const count = currentSuppressions[filePath]?.[code]?.count ?? 0; + if (count < suppressedCount) { + staleSuppressions.push({ filePath, code, count, suppressedCount }); + } + } + } + + return { + unsuppressedErrors, + staleSuppressions, + didPass: unsuppressedErrors.length === 0 && staleSuppressions.length === 0, + }; +} + +/** + * Reads the suppressions file, treating a missing file as having no + * suppressions. + * + * @param filePath - The path to the suppressions file. + * @returns The suppressions that the file holds. + */ +export async function readSuppressions( + filePath: string, +): Promise { + try { + return await readJsonFile(filePath); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return {}; + } + throw error; + } +} + +/** + * Writes the suppressions file, formatted the way that Oxfmt expects so that + * `lint:misc` stays happy after the file is regenerated. + * + * @param args - The arguments to this function. + * @param args.filePath - The path to the suppressions file. + * @param args.suppressions - The suppressions to write. + */ +export async function writeSuppressions({ + filePath, + suppressions, +}: { + filePath: string; + suppressions: TscSuppressions; +}): Promise { + await writeFile(filePath, `${JSON.stringify(suppressions, null, 2)}\n`); +} + +/** + * Prints the results of checking type errors against the suppressions file. + * + * @param report - The report to print. + */ +export function printReport(report: TscSuppressionsReport): void { + if (report.didPass) { + console.log('✅ No new type errors detected. Good job!'); + return; + } + + if (report.unsuppressedErrors.length > 0) { + console.log('❌ Detected type errors that are not suppressed:\n'); + for (const error of report.unsuppressedErrors) { + const location = error.filePath === '' ? '(no file)' : error.filePath; + console.log( + ` ${location}: ${error.code} (${error.count} found, ${error.suppressedCount} suppressed)`, + ); + for (const message of error.messages) { + console.log(` - ${message}`); + } + } + console.log( + '\nFix these errors, or run `yarn lint:tsc:suppress` if they cannot be fixed yet.', + ); + } + + if (report.staleSuppressions.length > 0) { + console.log( + '\n❌ Detected suppressions that cover type errors which no longer occur:\n', + ); + for (const suppression of report.staleSuppressions) { + console.log( + ` ${suppression.filePath}: ${suppression.code} (${suppression.count} found, ${suppression.suppressedCount} suppressed)`, + ); + } + console.log('\nRun `yarn lint:tsc:suppress` to remove these suppressions.'); + } +} diff --git a/scripts/lint-tsc.test.ts b/scripts/lint-tsc.test.ts new file mode 100644 index 00000000000..5bdb4b7e656 --- /dev/null +++ b/scripts/lint-tsc.test.ts @@ -0,0 +1,39 @@ +import { jest } from '@jest/globals'; + +// `jest.mock` does not apply to ES modules, so the module registry is stubbed +// with `jest.unstable_mockModule` and the modules under test are imported +// dynamically afterwards. +jest.unstable_mockModule('./lib/lint-tsc.js', () => ({ + lintTsc: jest.fn(), +})); + +const { lintTsc } = await import('./lib/lint-tsc.js'); + +describe('lint-tsc', () => { + let originalProcess: typeof globalThis.process; + + beforeEach(() => { + originalProcess = globalThis.process; + // The exit code is reset because it is global state that another test file + // may have set. + globalThis.process = { ...globalThis.process, exitCode: undefined }; + }); + + afterEach(() => { + globalThis.process = originalProcess; + }); + + it('runs the linter, reporting any error it throws', async () => { + jest.mocked(lintTsc).mockRejectedValue('foo'); + jest.spyOn(console, 'error').mockReturnValue(undefined); + + // Importing the entry point runs it, which is the behaviour under test. + await import('./lint-tsc.js'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(lintTsc).toHaveBeenCalledTimes(1); + expect(lintTsc).toHaveBeenCalledWith(process.argv.slice(2)); + expect(console.error).toHaveBeenCalledWith('foo'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/scripts/lint-tsc.ts b/scripts/lint-tsc.ts new file mode 100644 index 00000000000..1e4aba6e776 --- /dev/null +++ b/scripts/lint-tsc.ts @@ -0,0 +1,10 @@ +/** + * Entry point file for the `lint:tsc:check` and `lint:tsc:suppress` scripts. + */ + +import { lintTsc } from './lib/lint-tsc.js'; + +lintTsc(process.argv.slice(2)).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tsc-suppressions.json b/tsc-suppressions.json new file mode 100644 index 00000000000..f8f20a528b0 --- /dev/null +++ b/tsc-suppressions.json @@ -0,0 +1,1324 @@ +{ + "packages/account-tree-controller/src/AccountTreeController.test.ts": { + "TS18048": { + "count": 1 + }, + "TS2305": { + "count": 1 + }, + "TS2322": { + "count": 2 + }, + "TS2345": { + "count": 3 + }, + "TS2353": { + "count": 1 + }, + "TS2532": { + "count": 3 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/legacy.test.ts": { + "TS2345": { + "count": 2 + } + }, + "packages/account-tree-controller/src/rule.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/account-tree-controller/src/rules/entropy.test.ts": { + "TS2322": { + "count": 6 + } + }, + "packages/account-tree-controller/src/rules/keyring.test.ts": { + "TS2741": { + "count": 4 + } + }, + "packages/account-tree-controller/src/rules/snap.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2741": { + "count": 2 + } + }, + "packages/account-tree-controller/src/state/export.test.ts": { + "TS18048": { + "count": 1 + }, + "TS2322": { + "count": 1 + }, + "TS2537": { + "count": 1 + }, + "TS2578": { + "count": 1 + } + }, + "packages/account-tree-controller/src/state/roundtrip.test.ts": { + "TS2345": { + "count": 4 + } + }, + "packages/account-tree-controller/src/state/snapshot.test.ts": { + "TS2532": { + "count": 5 + } + }, + "packages/account-tree-controller/src/state/tests/helpers.ts": { + "TS2322": { + "count": 3 + } + }, + "packages/account-tree-controller/src/state/utils.test.ts": { + "TS2775": { + "count": 4 + } + }, + "packages/accounts-controller/src/AccountsController.test.ts": { + "TS2322": { + "count": 3 + }, + "TS2353": { + "count": 2 + } + }, + "packages/approval-controller/src/ApprovalController.test.ts": { + "TS6307": { + "count": 1 + } + }, + "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { + "TS2345": { + "count": 2 + } + }, + "packages/assets-controller/src/__fixtures__/scam-token-cleanup/captureTokenApiResponses.ts": { + "TS2724": { + "count": 1 + } + }, + "packages/assets-controller/src/AssetsController.test.ts": { + "TS2322": { + "count": 9 + }, + "TS2339": { + "count": 1 + }, + "TS2345": { + "count": 5 + }, + "TS2352": { + "count": 2 + }, + "TS2445": { + "count": 2 + }, + "TS2739": { + "count": 4 + }, + "TS2820": { + "count": 1 + } + }, + "packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts": { + "TS2322": { + "count": 7 + }, + "TS2741": { + "count": 1 + } + }, + "packages/assets-controller/src/data-sources/RpcDataSource.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/assets-controller/src/data-sources/SnapDataSource.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/assets-controller/src/middlewares/ParallelMiddleware.test.ts": { + "TS2418": { + "count": 3 + } + }, + "packages/assets-controller/src/selectors/balance.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/assets-controller/src/utils/formatExchangeRatesForBridge.test.ts": { + "TS2339": { + "count": 10 + } + }, + "packages/assets-controller/src/utils/native-assets.test.ts": { + "TS7053": { + "count": 1 + } + }, + "packages/assets-controllers/src/AccountTrackerController.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/assets-controllers/src/AssetsContractController.test.ts": { + "TS4094": { + "count": 1 + } + }, + "packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts": { + "TS2307": { + "count": 1 + } + }, + "packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts": { + "TS2322": { + "count": 14 + } + }, + "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts": { + "TS2353": { + "count": 2 + } + }, + "packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/assets-controllers/src/NftController.test.ts": { + "TS2353": { + "count": 2 + }, + "TS2614": { + "count": 4 + } + }, + "packages/assets-controllers/src/NftDetectionController.test.ts": { + "TS2345": { + "count": 3 + }, + "TS2353": { + "count": 1 + } + }, + "packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts": { + "TS7031": { + "count": 2 + } + }, + "packages/assets-controllers/src/selectors/token-selectors.test.ts": { + "TS2353": { + "count": 2 + }, + "TS7006": { + "count": 12 + } + }, + "packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts": { + "TS2591": { + "count": 3 + } + }, + "packages/bitcoin-regtest-up/src/install.test.ts": { + "TS2503": { + "count": 1 + }, + "TS2591": { + "count": 11 + } + }, + "packages/bitcoin-regtest-up/src/install.ts": { + "TS2591": { + "count": 7 + } + }, + "packages/bridge-controller/src/bridge-controller.sse.batch.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 1 + }, + "TS2769": { + "count": 1 + }, + "TS6305": { + "count": 1 + } + }, + "packages/bridge-controller/src/bridge-controller.sse.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 3 + }, + "TS6305": { + "count": 1 + } + }, + "packages/bridge-controller/src/bridge-controller.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 1 + }, + "TS6305": { + "count": 1 + } + }, + "packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/bridge-controller/src/selectors.test.ts": { + "TS2339": { + "count": 4 + }, + "TS2741": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/feature-flags.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/fetch.test.ts": { + "TS2698": { + "count": 1 + } + }, + "packages/bridge-controller/tests/mock-sse.ts": { + "TS6305": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts": { + "TS2307": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts": { + "TS2322": { + "count": 2 + }, + "TS2345": { + "count": 1 + }, + "TS2741": { + "count": 2 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.test.ts": { + "TS6305": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/utils/metrics.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2353": { + "count": 2 + } + }, + "packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts": { + "TS2315": { + "count": 1 + } + }, + "packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.test.ts": { + "TS2353": { + "count": 10 + } + }, + "packages/client-utils/src/mappers/api-transaction-mapper.test.ts": { + "TS2345": { + "count": 40 + }, + "TS2739": { + "count": 1 + }, + "TS2740": { + "count": 8 + }, + "TS2741": { + "count": 2 + } + }, + "packages/client-utils/src/mappers/helpers/transactions.test.ts": { + "TS2322": { + "count": 6 + }, + "TS2345": { + "count": 1 + }, + "TS2352": { + "count": 12 + }, + "TS2739": { + "count": 3 + } + }, + "packages/client-utils/src/mappers/local-transaction-mapper.test.ts": { + "TS2322": { + "count": 8 + }, + "TS2345": { + "count": 73 + }, + "TS2739": { + "count": 1 + }, + "TS2741": { + "count": 7 + } + }, + "packages/client-utils/test/fixtures/api-transactions.ts": { + "TS2322": { + "count": 10 + }, + "TS2353": { + "count": 7 + }, + "TS2739": { + "count": 23 + }, + "TS2740": { + "count": 7 + }, + "TS2741": { + "count": 17 + } + }, + "packages/core-backend/src/api/accounts/client.test.ts": { + "TS18048": { + "count": 1 + }, + "TS2322": { + "count": 1 + }, + "TS2339": { + "count": 3 + }, + "TS2349": { + "count": 1 + }, + "TS2722": { + "count": 1 + } + }, + "packages/core-backend/src/api/prices/client.test.ts": { + "TS2349": { + "count": 6 + } + }, + "packages/core-backend/src/api/tokens/client.test.ts": { + "TS2349": { + "count": 1 + } + }, + "packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.test.ts": { + "TS2322": { + "count": 34 + } + }, + "packages/java-tron-up/src/bin/java-tron-up.ts": { + "TS2591": { + "count": 3 + } + }, + "packages/java-tron-up/src/install.test.ts": { + "TS2503": { + "count": 1 + }, + "TS2591": { + "count": 9 + } + }, + "packages/java-tron-up/src/install.ts": { + "TS2591": { + "count": 8 + } + }, + "packages/json-rpc-engine/src/v2/compatibility-utils.test.ts": { + "TS2578": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts": { + "TS2769": { + "count": 1 + }, + "TS7031": { + "count": 1 + } + }, + "packages/json-rpc-middleware-stream/src/index.test.ts": { + "TS2351": { + "count": 1 + } + }, + "packages/keyring-controller/src/KeyringController.test.ts": { + "TS2307": { + "count": 1 + } + }, + "packages/money-account-api-data-service/src/money-account-api-data-service.test.ts": { + "TS2322": { + "count": 7 + } + }, + "packages/multichain-account-service/src/MultichainAccountService.test.ts": { + "TS18048": { + "count": 1 + } + }, + "packages/multichain-account-service/src/MultichainAccountWallet.test.ts": { + "TS18048": { + "count": 3 + } + }, + "packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2740": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts": { + "TS18048": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/SolAccountProvider.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2740": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2740": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/XlmAccountProvider.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2740": { + "count": 1 + } + }, + "packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/network-controller/src/rpc-service/rpc-service.test.ts": { + "TS2578": { + "count": 1 + }, + "TS2769": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/block-hash-in-response.ts": { + "TS2339": { + "count": 4 + }, + "TS2554": { + "count": 4 + } + }, + "packages/network-controller/tests/network-client/no-block-param.ts": { + "TS2339": { + "count": 4 + }, + "TS2554": { + "count": 4 + } + }, + "packages/network-controller/tests/NetworkController.test.ts": { + "TS2339": { + "count": 3 + } + }, + "packages/network-enablement-controller/src/NetworkEnablementController.test.ts": { + "TS6305": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts": { + "TS2769": { + "count": 3 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts": { + "TS2739": { + "count": 3 + } + }, + "packages/perps-controller/tests/e2e/lighter.e2e.ts": { + "TS2322": { + "count": 3 + }, + "TS2345": { + "count": 3 + } + }, + "packages/perps-controller/tests/helpers/advancedOrders.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/perps-controller/tests/helpers/serviceMocks.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts": { + "TS2741": { + "count": 10 + } + }, + "packages/perps-controller/tests/src/PerpsController.configuration.test.ts": { + "TS2589": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts": { + "TS2339": { + "count": 1 + }, + "TS2345": { + "count": 4 + } + }, + "packages/perps-controller/tests/src/PerpsController.operations.test.ts": { + "TS2304": { + "count": 3 + }, + "TS2322": { + "count": 2 + }, + "TS2589": { + "count": 1 + }, + "TS2769": { + "count": 6 + } + }, + "packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts": { + "TS18048": { + "count": 1 + }, + "TS2322": { + "count": 1 + }, + "TS2339": { + "count": 6 + }, + "TS2739": { + "count": 3 + }, + "TS7006": { + "count": 3 + } + }, + "packages/perps-controller/tests/src/PerpsController.state.test.ts": { + "TS18046": { + "count": 1 + }, + "TS2339": { + "count": 1 + }, + "TS2554": { + "count": 1 + }, + "TS7005": { + "count": 189 + }, + "TS7006": { + "count": 29 + }, + "TS7019": { + "count": 1 + }, + "TS7034": { + "count": 6 + } + }, + "packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts": { + "TS18048": { + "count": 8 + }, + "TS2304": { + "count": 1 + }, + "TS2339": { + "count": 15 + }, + "TS2345": { + "count": 3 + } + }, + "packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts": { + "TS2740": { + "count": 6 + } + }, + "packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts": { + "TS2322": { + "count": 10 + }, + "TS2345": { + "count": 1 + }, + "TS2353": { + "count": 1 + }, + "TS2741": { + "count": 6 + } + }, + "packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts": { + "TS18048": { + "count": 1 + }, + "TS2322": { + "count": 35 + }, + "TS2339": { + "count": 1 + }, + "TS2345": { + "count": 3 + }, + "TS7006": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts": { + "TS2339": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/providers/LighterProvider.test.ts": { + "TS2322": { + "count": 5 + }, + "TS2339": { + "count": 1 + }, + "TS2739": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts": { + "TS2339": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/services/LighterWalletService.test.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/services/MarketDataService.test.ts": { + "TS2339": { + "count": 8 + } + }, + "packages/perps-controller/tests/src/services/TerminalMarketService.test.ts": { + "TS2352": { + "count": 1 + } + }, + "packages/perps-controller/tests/src/services/TradingService.test.ts": { + "TS2339": { + "count": 3 + }, + "TS2741": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts": { + "TS2741": { + "count": 10 + } + }, + "packages/perps-controller/tests/src/utils/lighterAdapter.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 2 + } + }, + "packages/perps-controller/tests/src/utils/orderCalculations.advanced-orders.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 7 + } + }, + "packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockServices.ts": { + "TS2769": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts": { + "TS2345": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/shared/utils/message-signing.test.ts": { + "TS2339": { + "count": 1 + } + }, + "packages/ramps-controller/src/NeoBankService.test.ts": { + "TS2322": { + "count": 2 + }, + "TS2345": { + "count": 2 + } + }, + "packages/ramps-controller/src/NeoBankService.ts": { + "TS6307": { + "count": 1 + } + }, + "packages/ramps-controller/src/order-syncing/controller-integration.test.ts": { + "TS2352": { + "count": 3 + } + }, + "packages/ramps-controller/src/order-syncing/utils.test.ts": { + "TS2352": { + "count": 1 + }, + "TS2353": { + "count": 1 + }, + "TS2741": { + "count": 1 + } + }, + "packages/ramps-controller/src/RampsController.order-syncing.test.ts": { + "TS2345": { + "count": 1 + }, + "TS2739": { + "count": 2 + } + }, + "packages/ramps-controller/src/RampsController.test.ts": { + "TS2345": { + "count": 2 + } + }, + "packages/ramps-controller/src/selectors.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/ramps-controller/src/TransakService.test.ts": { + "TS2739": { + "count": 16 + }, + "TS2740": { + "count": 1 + } + }, + "packages/ramps-controller/src/wallet-registration-service.test.ts": { + "TS2352": { + "count": 3 + } + }, + "packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/shield-controller/src/ShieldController.test.ts": { + "TS2578": { + "count": 2 + } + }, + "packages/snap-account-service/src/SnapAccountService.test.ts": { + "TS2322": { + "count": 23 + }, + "TS2339": { + "count": 1 + }, + "TS2345": { + "count": 1 + } + }, + "packages/snap-account-service/src/SnapPlatformWatcher.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/snap-account-service/src/SnapTracker.test.ts": { + "TS2322": { + "count": 8 + } + }, + "packages/solana-test-validator-up/src/bin/solana-test-validator-up.ts": { + "TS2591": { + "count": 3 + } + }, + "packages/solana-test-validator-up/src/install.test.ts": { + "TS2503": { + "count": 1 + }, + "TS2591": { + "count": 9 + } + }, + "packages/solana-test-validator-up/src/install.ts": { + "TS2591": { + "count": 7 + } + }, + "packages/subscription-controller/src/SubscriptionController.test.ts": { + "TS2352": { + "count": 1 + }, + "TS2741": { + "count": 1 + }, + "TS6305": { + "count": 1 + } + }, + "packages/subscription-controller/src/SubscriptionService.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts": { + "TS2353": { + "count": 4 + } + }, + "packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/transaction-controller/src/TransactionController.test.ts": { + "TS2322": { + "count": 6 + }, + "TS2345": { + "count": 2 + } + }, + "packages/transaction-controller/src/TransactionControllerIntegration.test.ts": { + "TS2344": { + "count": 1 + } + }, + "packages/transaction-controller/src/utils/batch.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 2 + } + }, + "packages/transaction-controller/src/utils/gas.test.ts": { + "TS2769": { + "count": 2 + } + }, + "packages/transaction-controller/src/utils/recipient.test.ts": { + "TS2345": { + "count": 6 + } + }, + "packages/transaction-controller/src/utils/utils.test.ts": { + "TS2739": { + "count": 4 + } + }, + "packages/transaction-controller/src/utils/validation.test.ts": { + "TS2352": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts": { + "TS6305": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts": { + "TS2339": { + "count": 3 + }, + "TS2739": { + "count": 11 + }, + "TS2741": { + "count": 73 + } + }, + "packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts": { + "TS18048": { + "count": 4 + }, + "TS2741": { + "count": 9 + } + }, + "packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts": { + "TS2352": { + "count": 5 + }, + "TS2741": { + "count": 2 + } + }, + "packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts": { + "TS2304": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts": { + "TS2741": { + "count": 5 + } + }, + "packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts": { + "TS2739": { + "count": 1 + }, + "TS2741": { + "count": 4 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts": { + "TS2352": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts": { + "TS2352": { + "count": 1 + }, + "TS2741": { + "count": 2 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts": { + "TS2739": { + "count": 2 + }, + "TS2741": { + "count": 146 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts": { + "TS2322": { + "count": 12 + }, + "TS2345": { + "count": 14 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts": { + "TS2322": { + "count": 3 + }, + "TS2339": { + "count": 6 + }, + "TS2345": { + "count": 13 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts": { + "TS2352": { + "count": 1 + }, + "TS2835": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/RelayBridge.test.ts": { + "TS2739": { + "count": 1 + }, + "TS2741": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts": { + "TS2352": { + "count": 3 + }, + "TS2741": { + "count": 2 + } + }, + "packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts": { + "TS2741": { + "count": 29 + } + }, + "packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts": { + "TS2339": { + "count": 3 + }, + "TS2353": { + "count": 2 + }, + "TS7006": { + "count": 1 + }, + "TS7031": { + "count": 6 + } + }, + "packages/transaction-pay-controller/src/strategy/server/ServerStrategy.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts": { + "TS2352": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/provider.test.ts": { + "TS2353": { + "count": 1 + }, + "TS7006": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/transaction.test.ts": { + "TS2352": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/validation.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2739": { + "count": 1 + } + }, + "packages/wallet-cli/src/commands/daemon/call.test.ts": { + "TS2345": { + "count": 12 + } + }, + "packages/wallet-cli/src/commands/daemon/list.test.ts": { + "TS2345": { + "count": 14 + } + }, + "packages/wallet-cli/src/commands/daemon/purge.test.ts": { + "TS2345": { + "count": 7 + } + }, + "packages/wallet-cli/src/commands/daemon/start.test.ts": { + "TS2345": { + "count": 4 + } + }, + "packages/wallet-cli/src/commands/daemon/status.test.ts": { + "TS2345": { + "count": 10 + } + }, + "packages/wallet-cli/src/commands/daemon/stop.test.ts": { + "TS2345": { + "count": 5 + } + }, + "packages/wallet-cli/src/commands/wallet/send.test.ts": { + "TS2345": { + "count": 18 + } + }, + "packages/wallet-cli/src/commands/wallet/unlock.test.ts": { + "TS2345": { + "count": 20 + } + }, + "packages/wallet-cli/src/daemon/daemon-client.test.ts": { + "TS2339": { + "count": 2 + } + }, + "packages/wallet-cli/src/daemon/rpc-socket-server.test.ts": { + "TS2322": { + "count": 2 + } + }, + "packages/wallet-cli/src/persistence/persistence.test.ts": { + "TS2314": { + "count": 1 + }, + "TS2578": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/claims-controller/claims-controller.test.ts": { + "TS2353": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts": { + "TS2739": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.test.ts": { + "TS2741": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts": { + "TS2322": { + "count": 1 + }, + "TS2345": { + "count": 1 + }, + "TS2352": { + "count": 1 + }, + "TS2551": { + "count": 1 + }, + "TS2741": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts": { + "TS2322": { + "count": 1 + } + }, + "packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts": { + "TS2322": { + "count": 2 + }, + "TS2739": { + "count": 1 + }, + "TS2741": { + "count": 1 + } + }, + "packages/wallet/src/Wallet.test.ts": { + "TS2322": { + "count": 2 + }, + "TS2352": { + "count": 1 + }, + "TS2740": { + "count": 1 + }, + "TS2741": { + "count": 1 + }, + "TS2769": { + "count": 1 + } + } +} diff --git a/tsconfig.packages.lint.json b/tsconfig.packages.lint.json index 9d6e40495bc..d2662b94dcc 100644 --- a/tsconfig.packages.lint.json +++ b/tsconfig.packages.lint.json @@ -4,6 +4,12 @@ */ "compilerOptions": { "emitDeclarationOnly": true, - "skipLibCheck": true + "skipLibCheck": true, + /** + * Test files may pull in files from outside of their package, such as + * `tests/helpers.ts`. Without a `rootDir` that covers them, their + * declarations are emitted outside of `outDir`, landing in the repo itself. + */ + "rootDir": "." } } From a2ba1e0b5d845b761b66767400649643429448ab Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 16 Sep 2026 15:02:29 +0200 Subject: [PATCH 2/6] chore: refresh type error suppressions after merging main Main gained 104 type errors and fixed 10 of them since the suppressions were last generated, which is what the failing lint:tsc:check run on the previous commit reports. --- tsc-suppressions.json | 56 +++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/tsc-suppressions.json b/tsc-suppressions.json index f8f20a528b0..d73499a222f 100644 --- a/tsc-suppressions.json +++ b/tsc-suppressions.json @@ -82,9 +82,15 @@ } }, "packages/accounts-controller/src/AccountsController.test.ts": { + "TS2305": { + "count": 1 + }, "TS2322": { "count": 3 }, + "TS2345": { + "count": 1 + }, "TS2353": { "count": 2 } @@ -94,11 +100,6 @@ "count": 1 } }, - "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { - "TS2345": { - "count": 2 - } - }, "packages/assets-controller/src/__fixtures__/scam-token-cleanup/captureTokenApiResponses.ts": { "TS2724": { "count": 1 @@ -112,7 +113,7 @@ "count": 1 }, "TS2345": { - "count": 5 + "count": 2 }, "TS2352": { "count": 2 @@ -140,21 +141,11 @@ "count": 1 } }, - "packages/assets-controller/src/data-sources/RpcDataSource.test.ts": { - "TS2741": { - "count": 1 - } - }, "packages/assets-controller/src/data-sources/SnapDataSource.test.ts": { "TS2353": { "count": 1 } }, - "packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts": { - "TS2741": { - "count": 1 - } - }, "packages/assets-controller/src/middlewares/ParallelMiddleware.test.ts": { "TS2418": { "count": 3 @@ -239,6 +230,11 @@ "count": 12 } }, + "packages/assets-controllers/src/TokensController.test.ts": { + "TS2345": { + "count": 10 + } + }, "packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts": { "TS2591": { "count": 3 @@ -596,6 +592,9 @@ "packages/network-controller/tests/NetworkController.test.ts": { "TS2339": { "count": 3 + }, + "TS2345": { + "count": 75 } }, "packages/network-enablement-controller/src/NetworkEnablementController.test.ts": { @@ -636,11 +635,6 @@ "count": 10 } }, - "packages/perps-controller/tests/src/PerpsController.configuration.test.ts": { - "TS2589": { - "count": 1 - } - }, "packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts": { "TS2339": { "count": 1 @@ -703,6 +697,11 @@ "count": 6 } }, + "packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts": { + "TS2589": { + "count": 1 + } + }, "packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts": { "TS18048": { "count": 8 @@ -918,6 +917,11 @@ "count": 2 } }, + "packages/signature-controller/src/SignatureController.test.ts": { + "TS2345": { + "count": 1 + } + }, "packages/snap-account-service/src/SnapAccountService.test.ts": { "TS2322": { "count": 23 @@ -995,15 +999,18 @@ }, "packages/transaction-controller/src/TransactionController.test.ts": { "TS2322": { - "count": 6 + "count": 10 }, "TS2345": { - "count": 2 + "count": 3 } }, "packages/transaction-controller/src/TransactionControllerIntegration.test.ts": { "TS2344": { "count": 1 + }, + "TS2345": { + "count": 1 } }, "packages/transaction-controller/src/utils/batch.test.ts": { @@ -1015,6 +1022,9 @@ } }, "packages/transaction-controller/src/utils/gas.test.ts": { + "TS2322": { + "count": 1 + }, "TS2769": { "count": 2 } From 21d2726919f6ea74106aef625cd79f36514cf1b2 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 16 Sep 2026 22:12:59 +0200 Subject: [PATCH 3/6] chore: refresh type error suppressions after merging main Drops the uuid suppressions now that the mock typings are fixed on main, and picks up the remaining drift. --- tsc-suppressions.json | 45 ++++++------------------------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/tsc-suppressions.json b/tsc-suppressions.json index d73499a222f..6f8c6ca32c3 100644 --- a/tsc-suppressions.json +++ b/tsc-suppressions.json @@ -82,15 +82,9 @@ } }, "packages/accounts-controller/src/AccountsController.test.ts": { - "TS2305": { - "count": 1 - }, "TS2322": { "count": 3 }, - "TS2345": { - "count": 1 - }, "TS2353": { "count": 2 } @@ -107,7 +101,7 @@ }, "packages/assets-controller/src/AssetsController.test.ts": { "TS2322": { - "count": 9 + "count": 13 }, "TS2339": { "count": 1 @@ -230,11 +224,6 @@ "count": 12 } }, - "packages/assets-controllers/src/TokensController.test.ts": { - "TS2345": { - "count": 10 - } - }, "packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts": { "TS2591": { "count": 3 @@ -366,7 +355,7 @@ "count": 40 }, "TS2739": { - "count": 1 + "count": 3 }, "TS2740": { "count": 8 @@ -377,7 +366,7 @@ }, "packages/client-utils/src/mappers/helpers/transactions.test.ts": { "TS2322": { - "count": 6 + "count": 9 }, "TS2345": { "count": 1 @@ -387,20 +376,9 @@ }, "TS2739": { "count": 3 - } - }, - "packages/client-utils/src/mappers/local-transaction-mapper.test.ts": { - "TS2322": { - "count": 8 - }, - "TS2345": { - "count": 73 - }, - "TS2739": { - "count": 1 }, "TS2741": { - "count": 7 + "count": 1 } }, "packages/client-utils/test/fixtures/api-transactions.ts": { @@ -592,9 +570,6 @@ "packages/network-controller/tests/NetworkController.test.ts": { "TS2339": { "count": 3 - }, - "TS2345": { - "count": 75 } }, "packages/network-enablement-controller/src/NetworkEnablementController.test.ts": { @@ -917,11 +892,6 @@ "count": 2 } }, - "packages/signature-controller/src/SignatureController.test.ts": { - "TS2345": { - "count": 1 - } - }, "packages/snap-account-service/src/SnapAccountService.test.ts": { "TS2322": { "count": 23 @@ -999,18 +969,15 @@ }, "packages/transaction-controller/src/TransactionController.test.ts": { "TS2322": { - "count": 10 + "count": 6 }, "TS2345": { - "count": 3 + "count": 2 } }, "packages/transaction-controller/src/TransactionControllerIntegration.test.ts": { "TS2344": { "count": 1 - }, - "TS2345": { - "count": 1 } }, "packages/transaction-controller/src/utils/batch.test.ts": { From b396a871bcf2a6bd02becfda03f1f57dfb47575f Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 16 Sep 2026 22:29:30 +0200 Subject: [PATCH 4/6] refactor: simplify the suppressions check and fail loudly when tsc breaks Drops a redundant second pass over the errors, unused regex captures, and speculative handling for errors that tsc reports without a file. That last case mattered for a different reason: tsc's exit code was being discarded, so a missing config or a crash produced no parseable errors and the check reported success. It now fails when tsc exits non-zero without reporting any type errors, which covers crashes too. --- scripts/lib/lint-tsc.test.ts | 38 ++++++++++++++++-- scripts/lib/lint-tsc.ts | 14 ++++++- scripts/lib/tsc-suppressions.test.ts | 50 ++--------------------- scripts/lib/tsc-suppressions.ts | 60 ++++++++-------------------- 4 files changed, 66 insertions(+), 96 deletions(-) diff --git a/scripts/lib/lint-tsc.test.ts b/scripts/lib/lint-tsc.test.ts index afaff1bddbb..abbed24b2fc 100644 --- a/scripts/lib/lint-tsc.test.ts +++ b/scripts/lib/lint-tsc.test.ts @@ -33,9 +33,11 @@ const PASSING_REPORT = { * Stubs a `tsc` run which produces the given output and one parsed error. * * @param output - The output that `tsc` should produce. + * @param exitCode - The code that `tsc` should exit with. It exits non-zero + * whenever it reports errors, so that is the default. */ -function mockTscRun(output: string): void { - jest.mocked(execa).mockResolvedValue({ all: output } as never); +function mockTscRun(output: string, exitCode = 1): void { + jest.mocked(execa).mockResolvedValue({ all: output, exitCode } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([ERROR]); } @@ -123,8 +125,10 @@ describe('lintTsc', () => { expect(process.exitCode).toBe(1); }); - it('treats a run that produces no output as having no errors', async () => { - jest.mocked(execa).mockResolvedValue({ all: undefined } as never); + it('treats a successful run that produces no output as having no errors', async () => { + jest + .mocked(execa) + .mockResolvedValue({ all: undefined, exitCode: 0 } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); jest .mocked(tscSuppressions.compareErrorsToSuppressions) @@ -135,6 +139,32 @@ describe('lintTsc', () => { expect(tscSuppressions.parseTscOutput).toHaveBeenCalledWith(''); }); + it('throws when tsc fails without reporting any type errors', async () => { + jest + .mocked(execa) + .mockResolvedValue({ + all: 'error TS6053: not found', + exitCode: 1, + } as never); + jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); + + await expect(lintTsc([])).rejects.toThrow( + '`tsc` failed without reporting any type errors.', + ); + expect(tscSuppressions.compareErrorsToSuppressions).not.toHaveBeenCalled(); + }); + + it('does not throw when tsc exits non-zero but reports type errors, as those may be suppressed', async () => { + mockTscRun('a.ts(1,1): error TS2322: Nope.'); + jest + .mocked(tscSuppressions.compareErrorsToSuppressions) + .mockReturnValue(PASSING_REPORT); + + await lintTsc([]); + + expect(tscSuppressions.printReport).toHaveBeenCalledWith(PASSING_REPORT); + }); + it('rewrites the suppressions file when given --update, without failing', async () => { mockTscRun(''); const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; diff --git a/scripts/lib/lint-tsc.ts b/scripts/lib/lint-tsc.ts index 9f431af776a..b19d20e9254 100644 --- a/scripts/lib/lint-tsc.ts +++ b/scripts/lib/lint-tsc.ts @@ -29,12 +29,22 @@ const SUPPRESSIONS_FILE_NAME = 'tsc-suppressions.json'; export async function lintTsc(argv: readonly string[]): Promise { const suppressionsFilePath = path.join(REPO_ROOT, SUPPRESSIONS_FILE_NAME); - const { all: output } = await execa( + const { all, exitCode } = await execa( 'tsc', ['--build', 'tsconfig.lint.json', '--pretty', 'false'], { cwd: REPO_ROOT, reject: false, all: true, preferLocal: true }, ); - const errors = parseTscOutput(output ?? ''); + const output = all ?? ''; + const errors = parseTscOutput(output); + + // `tsc` exits non-zero whenever it reports type errors, which are expected + // here and may well be suppressed. But if it failed without reporting any, + // something else went wrong — a missing config, a crash — and that must not + // be mistaken for a clean run. + if (exitCode !== 0 && errors.length === 0) { + console.log(output); + throw new Error('`tsc` failed without reporting any type errors.'); + } if (argv.includes('--update')) { const suppressions = buildSuppressions(errors); diff --git a/scripts/lib/tsc-suppressions.test.ts b/scripts/lib/tsc-suppressions.test.ts index 453716d9604..87710f1f5af 100644 --- a/scripts/lib/tsc-suppressions.test.ts +++ b/scripts/lib/tsc-suppressions.test.ts @@ -67,16 +67,10 @@ describe('parseTscOutput', () => { expect(parseTscOutput(output)).toHaveLength(1); }); - it('parses an error that has no file, using an empty file path', () => { - const output = "error TS6053: File 'nope.ts' not found."; - - expect(parseTscOutput(output)).toStrictEqual([ - { - filePath: '', - code: 'TS6053', - message: "File 'nope.ts' not found.", - }, - ]); + it('ignores an error that tsc reports without a file, as it is not a type error', () => { + expect( + parseTscOutput("error TS6053: File 'nope.ts' not found."), + ).toStrictEqual([]); }); it('returns no errors when given empty output', () => { @@ -225,23 +219,6 @@ describe('compareErrorsToSuppressions', () => { expect(report.didPass).toBe(true); }); - - it('never suppresses an error that has no file, as it is a configuration error', () => { - const report = compareErrorsToSuppressions({ - errors: [{ filePath: '', code: 'TS6053', message: 'Not found.' }], - suppressions: { '': { TS6053: { count: 1 } } }, - }); - - expect(report.unsuppressedErrors).toStrictEqual([ - { - filePath: '', - code: 'TS6053', - count: 1, - suppressedCount: 0, - messages: ['Not found.'], - }, - ]); - }); }); describe('readSuppressions', () => { @@ -350,25 +327,6 @@ describe('printReport', () => { expect(output).toContain('Two.'); }); - it('labels an error that has no file', () => { - printReport({ - unsuppressedErrors: [ - { - filePath: '', - code: 'TS6053', - count: 1, - suppressedCount: 0, - messages: ['Not found.'], - }, - ], - staleSuppressions: [], - didPass: false, - }); - - const output = jest.mocked(console.log).mock.calls.flat().join('\n'); - expect(output).toContain('(no file)'); - }); - it('prints each stale suppression', () => { printReport({ unsuppressedErrors: [], diff --git a/scripts/lib/tsc-suppressions.ts b/scripts/lib/tsc-suppressions.ts index 7bdad1d50c1..500f0b83a4d 100644 --- a/scripts/lib/tsc-suppressions.ts +++ b/scripts/lib/tsc-suppressions.ts @@ -67,19 +67,12 @@ export type TscSuppressionsReport = { * * Lines that elaborate on an error are indented, so they never match. */ -const ERROR_WITH_FILE_REGEXP = - /^(?[^\s(][^(]*)\((?\d+),(?\d+)\): error (?TS\d+): (?.*)$/u; +const ERROR_REGEXP = + /^(?[^\s(][^(]*)\(\d+,\d+\): error (?TS\d+): (?.*)$/u; /** - * Matches an error that `tsc` reports without a file, such as: - * - * `error TS6053: File 'nope.ts' not found.` - */ -const ERROR_WITHOUT_FILE_REGEXP = /^error (?TS\d+): (?.*)$/u; - -/** - * Builds the key under which an error of a given code within a given file is - * grouped. + * Builds the key under which errors are grouped, matching how suppressions are + * keyed. * * @param filePath - The path to the file, relative to the repo root. * @param code - The TypeScript error code. @@ -99,22 +92,12 @@ export function parseTscOutput(output: string): TscError[] { const errors: TscError[] = []; for (const line of output.split('\n')) { - const matchWithFile = ERROR_WITH_FILE_REGEXP.exec(line); - if (matchWithFile?.groups) { + const match = ERROR_REGEXP.exec(line); + if (match?.groups) { errors.push({ - filePath: String(matchWithFile.groups.filePath), - code: String(matchWithFile.groups.code), - message: String(matchWithFile.groups.message), - }); - continue; - } - - const matchWithoutFile = ERROR_WITHOUT_FILE_REGEXP.exec(line); - if (matchWithoutFile?.groups) { - errors.push({ - filePath: '', - code: String(matchWithoutFile.groups.code), - message: String(matchWithoutFile.groups.message), + filePath: String(match.groups.filePath), + code: String(match.groups.code), + message: String(match.groups.message), }); } } @@ -165,9 +148,6 @@ export function buildSuppressions( * errors of that code than it covers, which means that those errors have been * fixed and the suppression should be removed. * - * Errors that `tsc` reports without a file are configuration errors rather than - * type errors, so they are never suppressed. - * * @param args - The arguments to this function. * @param args.errors - The errors from the current run. * @param args.suppressions - The suppressions to check against. @@ -180,21 +160,18 @@ export function compareErrorsToSuppressions({ errors: readonly TscError[]; suppressions: TscSuppressions; }): TscSuppressionsReport { - const currentSuppressions = buildSuppressions(errors); - - // Group the messages so that the report can show what was actually found, - // keyed the same way that suppressions are. + // Group the errors by file and code, keeping their messages so that the + // report can show what was actually found. const groups = new Map< string, { filePath: string; code: string; messages: string[] } >(); for (const error of errors) { - const key = buildKey(error.filePath, error.code); - const group = groups.get(key); + const group = groups.get(buildKey(error.filePath, error.code)); if (group) { group.messages.push(error.message); } else { - groups.set(key, { + groups.set(buildKey(error.filePath, error.code), { filePath: error.filePath, code: error.code, messages: [error.message], @@ -204,8 +181,7 @@ export function compareErrorsToSuppressions({ const unsuppressedErrors: UnsuppressedError[] = []; for (const { filePath, code, messages } of groups.values()) { - const suppressedCount = - filePath === '' ? 0 : (suppressions[filePath]?.[code]?.count ?? 0); + const suppressedCount = suppressions[filePath]?.[code]?.count ?? 0; if (messages.length > suppressedCount) { unsuppressedErrors.push({ filePath, @@ -219,13 +195,10 @@ export function compareErrorsToSuppressions({ const staleSuppressions: StaleSuppression[] = []; for (const [filePath, suppressedByCode] of Object.entries(suppressions)) { - if (filePath === '') { - continue; - } for (const [code, { count: suppressedCount }] of Object.entries( suppressedByCode, )) { - const count = currentSuppressions[filePath]?.[code]?.count ?? 0; + const count = groups.get(buildKey(filePath, code))?.messages.length ?? 0; if (count < suppressedCount) { staleSuppressions.push({ filePath, code, count, suppressedCount }); } @@ -291,9 +264,8 @@ export function printReport(report: TscSuppressionsReport): void { if (report.unsuppressedErrors.length > 0) { console.log('❌ Detected type errors that are not suppressed:\n'); for (const error of report.unsuppressedErrors) { - const location = error.filePath === '' ? '(no file)' : error.filePath; console.log( - ` ${location}: ${error.code} (${error.count} found, ${error.suppressedCount} suppressed)`, + ` ${error.filePath}: ${error.code} (${error.count} found, ${error.suppressedCount} suppressed)`, ); for (const message of error.messages) { console.log(` - ${message}`); From 1ec7429777e52037cda33e090940cd4649715384 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 16 Sep 2026 22:35:00 +0200 Subject: [PATCH 5/6] chore: format lint-tsc test --- scripts/lib/lint-tsc.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/lib/lint-tsc.test.ts b/scripts/lib/lint-tsc.test.ts index abbed24b2fc..c82e0a9ec21 100644 --- a/scripts/lib/lint-tsc.test.ts +++ b/scripts/lib/lint-tsc.test.ts @@ -140,12 +140,10 @@ describe('lintTsc', () => { }); it('throws when tsc fails without reporting any type errors', async () => { - jest - .mocked(execa) - .mockResolvedValue({ - all: 'error TS6053: not found', - exitCode: 1, - } as never); + jest.mocked(execa).mockResolvedValue({ + all: 'error TS6053: not found', + exitCode: 1, + } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); await expect(lintTsc([])).rejects.toThrow( From 62477108b764aa5f6d8e4a118520db83cf18153c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 16 Sep 2026 22:42:56 +0200 Subject: [PATCH 6/6] fix: fail when tsc reports a diagnostic that belongs to no file tsc --build carries on typechecking the remaining projects after one fails to load, so a broken project reference emitted TS6053 alongside 1540 ordinary type errors. The previous guard only fired when no errors were parsed at all, so the check reported success while a whole package went unchecked. --- scripts/lib/lint-tsc.test.ts | 20 +++++++++++++++-- scripts/lib/lint-tsc.ts | 22 +++++++++++++------ scripts/lib/tsc-suppressions.test.ts | 32 ++++++++++++++++++++++++++++ scripts/lib/tsc-suppressions.ts | 26 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/scripts/lib/lint-tsc.test.ts b/scripts/lib/lint-tsc.test.ts index c82e0a9ec21..8ac017a31fb 100644 --- a/scripts/lib/lint-tsc.test.ts +++ b/scripts/lib/lint-tsc.test.ts @@ -10,6 +10,7 @@ jest.unstable_mockModule('execa', () => ({ jest.unstable_mockModule('./tsc-suppressions.js', () => ({ parseTscOutput: jest.fn(), + findFilelessDiagnostics: jest.fn(), buildSuppressions: jest.fn(), compareErrorsToSuppressions: jest.fn(), readSuppressions: jest.fn(), @@ -39,6 +40,7 @@ const PASSING_REPORT = { function mockTscRun(output: string, exitCode = 1): void { jest.mocked(execa).mockResolvedValue({ all: output, exitCode } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([ERROR]); + jest.mocked(tscSuppressions.findFilelessDiagnostics).mockReturnValue([]); } describe('lintTsc', () => { @@ -130,6 +132,7 @@ describe('lintTsc', () => { .mocked(execa) .mockResolvedValue({ all: undefined, exitCode: 0 } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); + jest.mocked(tscSuppressions.findFilelessDiagnostics).mockReturnValue([]); jest .mocked(tscSuppressions.compareErrorsToSuppressions) .mockReturnValue(PASSING_REPORT); @@ -141,13 +144,14 @@ describe('lintTsc', () => { it('throws when tsc fails without reporting any type errors', async () => { jest.mocked(execa).mockResolvedValue({ - all: 'error TS6053: not found', + all: 'boom', exitCode: 1, } as never); jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]); + jest.mocked(tscSuppressions.findFilelessDiagnostics).mockReturnValue([]); await expect(lintTsc([])).rejects.toThrow( - '`tsc` failed without reporting any type errors.', + '`tsc` failed for a reason other than the type errors it reported.', ); expect(tscSuppressions.compareErrorsToSuppressions).not.toHaveBeenCalled(); }); @@ -163,6 +167,18 @@ describe('lintTsc', () => { expect(tscSuppressions.printReport).toHaveBeenCalledWith(PASSING_REPORT); }); + it('throws when tsc reports a diagnostic that belongs to no file, even though type errors were reported too', async () => { + mockTscRun('a.ts(1,1): error TS2322: Nope.'); + jest + .mocked(tscSuppressions.findFilelessDiagnostics) + .mockReturnValue(["error TS6053: File 'nope.json' not found."]); + + await expect(lintTsc([])).rejects.toThrow( + '`tsc` failed for a reason other than the type errors it reported.', + ); + expect(tscSuppressions.compareErrorsToSuppressions).not.toHaveBeenCalled(); + }); + it('rewrites the suppressions file when given --update, without failing', async () => { mockTscRun(''); const suppressions = { 'a.ts': { TS2322: { count: 1 } } }; diff --git a/scripts/lib/lint-tsc.ts b/scripts/lib/lint-tsc.ts index b19d20e9254..da9d99822d7 100644 --- a/scripts/lib/lint-tsc.ts +++ b/scripts/lib/lint-tsc.ts @@ -4,6 +4,7 @@ import path from 'path'; import { buildSuppressions, compareErrorsToSuppressions, + findFilelessDiagnostics, parseTscOutput, printReport, readSuppressions, @@ -38,12 +39,21 @@ export async function lintTsc(argv: readonly string[]): Promise { const errors = parseTscOutput(output); // `tsc` exits non-zero whenever it reports type errors, which are expected - // here and may well be suppressed. But if it failed without reporting any, - // something else went wrong — a missing config, a crash — and that must not - // be mistaken for a clean run. - if (exitCode !== 0 && errors.length === 0) { - console.log(output); - throw new Error('`tsc` failed without reporting any type errors.'); + // here and may well be suppressed, so the exit code alone says little. A run + // is only genuinely broken if it reported a diagnostic that belongs to no + // file, or if it failed without reporting any type errors at all. Neither can + // be suppressed, and neither may be mistaken for a clean run. + const filelessDiagnostics = findFilelessDiagnostics(output); + if ( + filelessDiagnostics.length > 0 || + (exitCode !== 0 && errors.length === 0) + ) { + console.log( + filelessDiagnostics.length > 0 ? filelessDiagnostics.join('\n') : output, + ); + throw new Error( + '`tsc` failed for a reason other than the type errors it reported.', + ); } if (argv.includes('--update')) { diff --git a/scripts/lib/tsc-suppressions.test.ts b/scripts/lib/tsc-suppressions.test.ts index 87710f1f5af..3cfadca5625 100644 --- a/scripts/lib/tsc-suppressions.test.ts +++ b/scripts/lib/tsc-suppressions.test.ts @@ -10,6 +10,7 @@ import path from 'path'; import { buildSuppressions, compareErrorsToSuppressions, + findFilelessDiagnostics, parseTscOutput, printReport, readSuppressions, @@ -78,6 +79,37 @@ describe('parseTscOutput', () => { }); }); +describe('findFilelessDiagnostics', () => { + it('finds a diagnostic that tsc reports without a file', () => { + const output = "error TS6053: File 'nope.ts' not found."; + + expect(findFilelessDiagnostics(output)).toStrictEqual([ + "error TS6053: File 'nope.ts' not found.", + ]); + }); + + it('finds one even when type errors are reported alongside it', () => { + const output = [ + "error TS6053: File 'nope.ts' not found.", + 'packages/foo/src/foo.test.ts(12,5): error TS2322: Nope.', + ].join('\n'); + + expect(findFilelessDiagnostics(output)).toStrictEqual([ + "error TS6053: File 'nope.ts' not found.", + ]); + }); + + it('ignores errors that belong to a file', () => { + const output = 'packages/foo/src/foo.test.ts(12,5): error TS2322: Nope.'; + + expect(findFilelessDiagnostics(output)).toStrictEqual([]); + }); + + it('returns nothing when given empty output', () => { + expect(findFilelessDiagnostics('')).toStrictEqual([]); + }); +}); + describe('buildSuppressions', () => { it('counts errors by file and then by error code', () => { const errors = [ diff --git a/scripts/lib/tsc-suppressions.ts b/scripts/lib/tsc-suppressions.ts index 500f0b83a4d..213c2041b4f 100644 --- a/scripts/lib/tsc-suppressions.ts +++ b/scripts/lib/tsc-suppressions.ts @@ -70,6 +70,16 @@ export type TscSuppressionsReport = { const ERROR_REGEXP = /^(?[^\s(][^(]*)\(\d+,\d+\): error (?TS\d+): (?.*)$/u; +/** + * Matches a diagnostic that `tsc` reports without a file, such as: + * + * `error TS6053: File 'nope.ts' not found.` + * + * These report a broken build rather than a type error — a missing config, an + * unresolvable project reference — so they are never suppressed. + */ +const FILELESS_DIAGNOSTIC_REGEXP = /^error TS\d+: .*$/u; + /** * Builds the key under which errors are grouped, matching how suppressions are * keyed. @@ -105,6 +115,22 @@ export function parseTscOutput(output: string): TscError[] { return errors; } +/** + * Extracts the diagnostics that `tsc` reports without a file. + * + * `tsc --build` carries on typechecking the remaining projects after one fails + * to load, so these can otherwise hide behind the type errors that the other + * projects report. + * + * @param output - The combined stdout and stderr of a `tsc` run. + * @returns The matching lines, verbatim. + */ +export function findFilelessDiagnostics(output: string): string[] { + return output + .split('\n') + .filter((line) => FILELESS_DIAGNOSTIC_REGEXP.test(line)); +} + /** * Tallies errors by file and then by error code, sorting both so that the * suppressions file produces a minimal diff from one run to the next.