Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c096585
feat: block new type errors from being added to the suppressions file
cryptodev-2s Sep 21, 2026
95bbaa9
chore: use execa's named export in the ratchet
cryptodev-2s Sep 26, 2026
5cd129c
chore: use .ts import extensions in the ratchet
cryptodev-2s Sep 29, 2026
fde5960
chore: apply review feedback to the ratchet
cryptodev-2s Sep 29, 2026
c9c09fc
chore: let the merge commit supply the baseline
cryptodev-2s Sep 30, 2026
d2b5e8d
feat: guard the oxlint suppressions too, and deduplicate
cryptodev-2s Sep 30, 2026
197abc3
chore: drop the comment above fetch-depth
cryptodev-2s Sep 30, 2026
15b2767
chore: drop the jest.unstable_mockModule comment
cryptodev-2s Sep 30, 2026
f565e57
chore: name the oxlint suppressions file
cryptodev-2s Sep 30, 2026
4981df8
Merge branch 'main' into tsc-suppressions-ratchet
cryptodev-2s Sep 30, 2026
70e0a7c
chore: drop the strip types flag
cryptodev-2s Sep 30, 2026
4feb1d0
chore: rename to lint:suppressions
cryptodev-2s Sep 30, 2026
fe17f7b
chore: make the check work locally without arguments
cryptodev-2s Sep 30, 2026
c78ee09
chore: assert the git calls without indexing them
cryptodev-2s Sep 30, 2026
9b97e79
fix: only trust the first parent in CI
cryptodev-2s Sep 30, 2026
131f07e
chore: reword the comment about resetting global state
cryptodev-2s Sep 30, 2026
2c02eff
Merge branch 'main' into tsc-suppressions-ratchet
cryptodev-2s Oct 1, 2026
39074d1
feat: let a label waive the suppressions check
cryptodev-2s Oct 1, 2026
d888d1a
fix: read the waiver label back over the API
cryptodev-2s Oct 1, 2026
18da785
refactor: move the suppressions check to its own workflow
cryptodev-2s Oct 1, 2026
bc5d8d8
chore: drop a comment
cryptodev-2s Oct 1, 2026
19e0119
chore: drop a comment
cryptodev-2s Oct 1, 2026
d5f622d
feat: give core platform ownership of the suppressions files
cryptodev-2s Oct 1, 2026
dd64190
chore: stop pointing everyone at the waiver label
cryptodev-2s Oct 1, 2026
22907bf
chore: let the check speak for itself on failure
cryptodev-2s Oct 1, 2026
2f63e00
chore: check the waiver label before running the check
cryptodev-2s Oct 1, 2026
76171d7
chore: guard the whole job with the waiver label
cryptodev-2s Oct 1, 2026
89fa89f
Merge branch 'main' into tsc-suppressions-ratchet
cryptodev-2s Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/lint-suppressions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Lint Suppressions

on:
pull_request:
types: [opened, synchronize, labeled, unlabeled]
merge_group:

permissions:
contents: read

jobs:
lint-suppressions:
name: Lint suppressions
if: ${{ github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'allow-new-suppressions') }}
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24.x]
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v3
with:
is-high-risk-environment: false
persist-credentials: false
node-version: ${{ matrix.node-version }}
fetch-depth: 2
- name: Run yarn lint:suppressions
run: yarn lint:suppressions
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
fi
41 changes: 0 additions & 41 deletions oxlint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -7601,27 +7601,11 @@
}
},
"scripts/create-package/cli.test.ts": {
"no-shadow": {
"count": 1
},
"typescript/no-unsafe-argument": {
"count": 2
}
},
"scripts/create-package/commands.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/create-package/index.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/create-package/utils.test.ts": {
"no-shadow": {
"count": 1
},
"typescript/no-unsafe-assignment": {
"count": 2
}
Expand All @@ -7631,11 +7615,6 @@
"count": 1
}
},
"scripts/lib/changelog-conflicts.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/lib/changelog-conflicts.ts": {
"n/no-unsupported-features/node-builtins": {
"count": 1
Expand All @@ -7644,16 +7623,6 @@
"count": 2
}
},
"scripts/lib/lint-tsc.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/lib/tsc-suppressions.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/lib/workspaces.ts": {
"jsdoc/require-param": {
"count": 1
Expand All @@ -7679,11 +7648,6 @@
"count": 2
}
},
"scripts/lint-tsc.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/lint-tsconfigs/utils.ts": {
"n/no-unsupported-features/node-builtins": {
"count": 1
Expand All @@ -7697,11 +7661,6 @@
"count": 1
}
},
"scripts/merge-changelog-conflicts.test.ts": {
"no-shadow": {
"count": 1
}
},
"scripts/update-readme-content.ts": {
"n/no-unsupported-features/node-builtins": {
"count": 1
Expand Down
8 changes: 8 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ export default createConfig({
},
},

{
// Jest does not inject its globals in ESM, so the scripts' tests import
// `jest` from `@jest/globals`. Declaring it as a global as well would
// make every one of those imports shadow it.
files: ['scripts/**/*.test.ts'],
rules: { 'no-shadow': ['error', { allow: ['jest'] }] },
},
Comment thread
cryptodev-2s marked this conversation as resolved.

{
files: ['scripts/**/*.ts'],
rules: {
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@
"lint:misc": "oxfmt --ignore-path .gitignore",
"lint:misc:check": "yarn lint:misc --check",
"lint:oxlint": "oxlint",
"lint:suppressions": "node scripts/lint-suppressions.ts",
"lint:teams": "node --import ./scripts/resolver/register.ts --experimental-transform-types scripts/lint-teams-json.ts",
"lint:tsc": "tsc --build tsconfig.lint.json",
"lint:tsc:check": "node --experimental-strip-types scripts/lint-tsc.ts",
"lint:tsc:check": "node 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:prune": "node --experimental-strip-types scripts/lint-tsc.ts --prune-suppressions",
"lint:tsc:suppress": "node --experimental-strip-types scripts/lint-tsc.ts --suppress-all",
"lint:tsc:prune": "node scripts/lint-tsc.ts --prune-suppressions",
"lint:tsc:suppress": "node scripts/lint-tsc.ts --suppress-all",
"lint:tsconfigs": "node --import ./scripts/resolver/register.ts --experimental-transform-types scripts/lint-tsconfigs/lint-tsconfigs.ts",
"lint:tsconfigs:all": "yarn workspaces foreach --all --parallel --interlaced --verbose run lint:tsconfigs",
"lint:tsconfigs:fix": "node --import ./scripts/resolver/register.ts --experimental-transform-types scripts/lint-tsconfigs/lint-tsconfigs.ts --fix",
Expand Down
227 changes: 227 additions & 0 deletions scripts/lib/lint-suppressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { jest } from '@jest/globals';

jest.unstable_mockModule('execa', () => ({
execa: jest.fn(),
}));

jest.unstable_mockModule('./tsc-suppressions.ts', () => ({
SUPPRESSIONS_FILE_NAME: 'tsc-suppressions.json',
readSuppressions: jest.fn(),
}));

const { execa } = await import('execa');
const tscSuppressions = await import('./tsc-suppressions.ts');
const { findAddedSuppressions, printAddedSuppressions, lintSuppressions } =
await import('./lint-suppressions.ts');

describe('findAddedSuppressions', () => {
it('flags a file that the baseline does not suppress at all', () => {
expect(
findAddedSuppressions({
current: { 'a.ts': { 'no-shadow': { count: 1 } } },
base: {},
}),
).toStrictEqual([
{ filePath: 'a.ts', rule: 'no-shadow', count: 1, baseCount: 0 },
]);
});

it('flags a rule that the baseline does not suppress within a file it does', () => {
expect(
findAddedSuppressions({
current: {
'a.ts': { 'no-shadow': { count: 1 }, 'id-length': { count: 1 } },
},
base: { 'a.ts': { 'no-shadow': { count: 1 } } },
}),
).toStrictEqual([
{ filePath: 'a.ts', rule: 'id-length', count: 1, baseCount: 0 },
]);
});

it('flags a count that has grown', () => {
expect(
findAddedSuppressions({
current: { 'a.ts': { TS2322: { count: 3 } } },
base: { 'a.ts': { TS2322: { count: 2 } } },
}),
).toStrictEqual([
{ filePath: 'a.ts', rule: 'TS2322', count: 3, baseCount: 2 },
]);
});

it('allows a count that is unchanged', () => {
expect(
findAddedSuppressions({
current: { 'a.ts': { TS2322: { count: 2 } } },
base: { 'a.ts': { TS2322: { count: 2 } } },
}),
).toStrictEqual([]);
});

it('allows a count that has shrunk', () => {
expect(
findAddedSuppressions({
current: { 'a.ts': { TS2322: { count: 1 } } },
base: { 'a.ts': { TS2322: { count: 5 } } },
}),
).toStrictEqual([]);
});

it('allows a rule or a file to disappear entirely', () => {
expect(
findAddedSuppressions({
current: {},
base: { 'a.ts': { TS2322: { count: 5 }, TS7005: { count: 1 } } },
}),
).toStrictEqual([]);
});

it('reports every addition, not just the first', () => {
expect(
findAddedSuppressions({
current: {
'a.ts': { TS2322: { count: 1 } },
'b.ts': { TS7005: { count: 2 } },
},
base: {},
}),
).toHaveLength(2);
});
});

describe('printAddedSuppressions', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockReturnValue(undefined);
});

it('announces success when nothing was added, naming the file', () => {
printAddedSuppressions('oxlint-suppressions.json', []);

expect(console.log).toHaveBeenCalledWith(
'✅ Nothing has been added to oxlint-suppressions.json. Good job!',
);
});

it('prints each addition and how to resolve it', () => {
printAddedSuppressions('oxlint-suppressions.json', [
{ filePath: 'a.ts', rule: 'no-shadow', count: 3, baseCount: 2 },
]);

const output = jest.mocked(console.log).mock.calls.flat().join('\n');
expect(output).toContain('oxlint-suppressions.json');
expect(output).toContain('a.ts');
expect(output).toContain('no-shadow');
expect(output).toContain('3');
expect(output).toContain('2');
});
});

/**
* Stubs the Git commands the check runs.
*
* @param suppressions - The baseline the commands should produce.
*/
function mockGit(suppressions = '{}'): void {
jest.mocked(execa).mockImplementation((async (
_file: string,
args: string[],
) => {
if (args[0] === 'merge-base') {
return { stdout: 'abc123\n' };
}
return { stdout: suppressions };
}) as never);
}

describe('lintSuppressions', () => {
let originalProcess: typeof globalThis.process;

beforeEach(() => {
originalProcess = globalThis.process;
// The exit code is reset because another test file may have set it.
// `GITHUB_ACTIONS` is cleared because this suite runs in CI, where it is
// set, which would otherwise send every test down the CI path.
globalThis.process = {
...globalThis.process,
exitCode: undefined,
env: { ...globalThis.process.env, GITHUB_ACTIONS: undefined },
};
jest.spyOn(console, 'log').mockReturnValue(undefined);
mockGit();
jest.mocked(tscSuppressions.readSuppressions).mockResolvedValue({});
});

afterEach(() => {
globalThis.process = originalProcess;
});

it('reads the baseline from the first parent of the merge commit CI checks out', async () => {
process.env.GITHUB_ACTIONS = 'true';

await lintSuppressions([]);

expect(execa).toHaveBeenCalledWith(
'git',
['show', 'HEAD^1:oxlint-suppressions.json'],
expect.anything(),
);
expect(execa).toHaveBeenCalledWith(
'git',
['show', 'HEAD^1:tsc-suppressions.json'],
expect.anything(),
);
});

it('takes the merge base when running outside of CI, where a merge commit means something else', async () => {
await lintSuppressions([]);

expect(jest.mocked(execa)).toHaveBeenCalledWith(
'git',
['merge-base', 'HEAD', 'origin/main'],
expect.anything(),
);
expect(execa).toHaveBeenCalledWith(
'git',
['show', 'abc123:oxlint-suppressions.json'],
expect.anything(),
);
expect(execa).toHaveBeenCalledWith(
'git',
['show', 'abc123:tsc-suppressions.json'],
expect.anything(),
);
});

it('takes the merge base against the branch it is given', async () => {
await lintSuppressions(['origin/release']);

expect(jest.mocked(execa)).toHaveBeenCalledWith(
'git',
['merge-base', 'HEAD', 'origin/release'],
expect.anything(),
);
});

it('leaves the exit code alone when nothing has been added', async () => {
await lintSuppressions([]);

expect(process.exitCode).toBeUndefined();
});

it('exits with a non-zero code when a file has grown', async () => {
jest
.mocked(tscSuppressions.readSuppressions)
.mockResolvedValue({ 'a.ts': { 'no-shadow': { count: 1 } } });

await lintSuppressions([]);

expect(process.exitCode).toBe(1);
});

it('throws when a baseline cannot be read, rather than passing', async () => {
jest.mocked(execa).mockRejectedValue(new Error('unknown revision'));

await expect(lintSuppressions([])).rejects.toThrow('unknown revision');
});
});
Loading
Loading