From b9d6c2c02777588c706b4a110b8370a1a879de93 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 22 Jul 2026 23:23:53 -0400 Subject: [PATCH] feat(lint): replace stock max-params with conformance-aware rule bombshell-dev/max-params keeps the 2-param limit for signatures we author, but exempts functions conforming to external APIs: override methods, members of classes that extends/implements, and inline callbacks. --- .changeset/max-params-rule.md | 7 ++++ oxlintrc.json | 3 +- rules/plugin.js | 66 +++++++++++++++++++++++++++++++++++ src/commands/lint.test.ts | 36 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 .changeset/max-params-rule.md diff --git a/.changeset/max-params-rule.md b/.changeset/max-params-rule.md new file mode 100644 index 0000000..78fd1d6 --- /dev/null +++ b/.changeset/max-params-rule.md @@ -0,0 +1,7 @@ +--- +'@bomb.sh/tools': minor +--- + +Replaces the stock `max-params` lint rule with a Bombshell-aware version + +The 2-parameter limit (use an options bag beyond that) now only applies to signatures we author. Functions conforming to APIs we don't control are exempt: `override` methods, members of classes that `extends` or `implements` (e.g. Node streams, platform-shaped interfaces), and inline callbacks passed to other functions. diff --git a/oxlintrc.json b/oxlintrc.json index bf0b0d5..ac83b3f 100644 --- a/oxlintrc.json +++ b/oxlintrc.json @@ -38,7 +38,8 @@ "no-console": ["warn", { "allow": ["info", "warn", "error", "debug"] }], "prefer-const": "error", "no-var": "error", - "max-params": ["error", { "max": 2 }], + "max-params": "off", + "bombshell-dev/max-params": ["error", { "max": 2 }], "typescript/explicit-function-return-type": ["warn", { "allowExpressions": true }], "import/no-default-export": "error", diff --git a/rules/plugin.js b/rules/plugin.js index fedf0fb..64049ce 100644 --- a/rules/plugin.js +++ b/rules/plugin.js @@ -4,6 +4,72 @@ const plugin = { name: 'bombshell-dev', }, rules: { + /** + * Limit functions to 2 parameters in APIs we author. + * + * Beyond that, use an options bag. Functions that conform to an + * interface we don't control are exempt — the signature is imposed, + * not designed: + * + * - `override` methods + * - members of classes that `extends` or `implements` + * - inline callbacks (arguments, object-literal properties) + */ + 'max-params': { + meta: { + schema: [ + { + type: 'object', + properties: { max: { type: 'number' } }, + additionalProperties: false, + }, + ], + }, + create(context) { + const max = context.options?.[0]?.max ?? 2; + + function isExempt(node) { + const parent = node.parent; + if (!parent) return false; + + // Class members: exempt when conforming to a base class or + // interface; standalone class members are authored API. + if (parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition') { + if (parent.override) return true; + const classNode = parent.parent?.parent; + return Boolean(classNode?.superClass || classNode?.implements?.length); + } + + // Named functions assigned to variables are authored API. + if (parent.type === 'VariableDeclarator') return false; + + // Function declarations are always authored API. + if (node.type === 'FunctionDeclaration') return false; + + // Everything else is an inline callback (call arguments, + // object-literal properties, array elements, ...) conforming + // to someone else's signature. + return true; + } + + function check(node) { + if (node.params.length <= max) return; + if (isExempt(node)) return; + const name = node.id?.name ?? node.parent?.key?.name ?? 'anonymous'; + context.report({ + node, + message: `Function \`${name}\` has too many parameters (${node.params.length}). Maximum allowed is ${max} — use an options bag.`, + }); + } + + return { + FunctionDeclaration: check, + FunctionExpression: check, + ArrowFunctionExpression: check, + }; + }, + }, + /** * Disallow `throw new Error(...)` in favor of custom error classes. * diff --git a/src/commands/lint.test.ts b/src/commands/lint.test.ts index f442245..8c8edf1 100644 --- a/src/commands/lint.test.ts +++ b/src/commands/lint.test.ts @@ -47,6 +47,42 @@ describe('lint command', () => { }); }); + describe('bombshell-dev/max-params', () => { + it('flags authored signatures but not conformance to external APIs', async () => { + fixture = await createFixture({ + src: { + 'index.ts': ` + export function fourParams(a: number, b: number, c: number, d: number): number { return a + b + c + d; } + export const arrow = (a: number, b: number, c: number): number => a + b + c; + class Point { constructor(x: number, y: number, z: number) { void x; void y; void z; } } + + interface Listener { on(event: string, data: unknown, ctx: unknown): void } + class MyListener implements Listener { + on(event: string, data: unknown, ctx: unknown): void { void event; void data; void ctx; } + } + + class Base { render(a: string, b: string, c: string): void { void a; void b; void c; } } + class Sub extends Base { + override render(a: string, b: string, c: string): void { void a; void b; void c; } + } + + const handlers = { + handle(a: string, b: string, c: string): void { void a; void b; void c; }, + }; + [1].forEach((a, b, c) => { void a; void b; void c; }); + void handlers; void Point; void MyListener; void Sub; + `, + }, + }); + process.chdir(fileURLToPath(fixture.root)); + + const violations = await runOxlint(['./src']); + const maxParams = violations.filter((v) => v.code === 'bombshell-dev(max-params)'); + + expect(maxParams.map((v) => v.line).sort((a, b) => a - b)).toEqual([2, 3, 4, 11]); + }); + }); + describe('runKnip', () => { it('detects unused exports and unused files', async () => { fixture = await createFixture({