Skip to content
1 change: 1 addition & 0 deletions .github/workflows/lint-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
198 changes: 198 additions & 0 deletions scripts/lib/lint-tsc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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(),
findFilelessDiagnostics: 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.
* @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, 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', () => {
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 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.findFilelessDiagnostics).mockReturnValue([]);
jest
.mocked(tscSuppressions.compareErrorsToSuppressions)
.mockReturnValue(PASSING_REPORT);

await lintTsc([]);

expect(tscSuppressions.parseTscOutput).toHaveBeenCalledWith('');
});

it('throws when tsc fails without reporting any type errors', async () => {
jest.mocked(execa).mockResolvedValue({
all: 'boom',
exitCode: 1,
} as never);
jest.mocked(tscSuppressions.parseTscOutput).mockReturnValue([]);
jest.mocked(tscSuppressions.findFilelessDiagnostics).mockReturnValue([]);

await expect(lintTsc([])).rejects.toThrow(
'`tsc` failed for a reason other than the type errors it reported.',
);
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('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 } } };
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();
});
});
80 changes: 80 additions & 0 deletions scripts/lib/lint-tsc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import execa from 'execa';
import path from 'path';

import {
buildSuppressions,
compareErrorsToSuppressions,
findFilelessDiagnostics,
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<void> {
const suppressionsFilePath = path.join(REPO_ROOT, SUPPRESSIONS_FILE_NAME);

const { all, exitCode } = await execa(
'tsc',
['--build', 'tsconfig.lint.json', '--pretty', 'false'],
{ cwd: REPO_ROOT, reject: false, all: true, preferLocal: true },
);
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, 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.',
);
}
Comment thread
cursor[bot] marked this conversation as resolved.

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;
}
}
Loading