Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/max-params-rule.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions rules/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
36 changes: 36 additions & 0 deletions src/commands/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading