diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-29 00:37:56 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-05-02 03:27:23 +0200 |
| commit | a9b9cf6bf50f08723896659634f2e6afc17bcd42 (patch) | |
| tree | 9175b49ff21f4326872ac9e541d2765cc7994ebc /eslint/rules | |
| parent | d6622dc6b3523a5658cd512b44f15f7a66fd540c (diff) | |
| download | opencut-a9b9cf6bf50f08723896659634f2e6afc17bcd42.tar.gz | |
chore: switch from biome to eslint + prettier; fix ton of lint issues
Diffstat (limited to 'eslint/rules')
| -rw-r--r-- | eslint/rules/__tests__/prefer-object-params.test.mjs | 112 | ||||
| -rw-r--r-- | eslint/rules/prefer-object-params.mjs | 107 |
2 files changed, 219 insertions, 0 deletions
diff --git a/eslint/rules/__tests__/prefer-object-params.test.mjs b/eslint/rules/__tests__/prefer-object-params.test.mjs new file mode 100644 index 00000000..7f4bc947 --- /dev/null +++ b/eslint/rules/__tests__/prefer-object-params.test.mjs @@ -0,0 +1,112 @@ +import { afterAll, describe, it } from "bun:test"; +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import rule from "../prefer-object-params.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; +RuleTester.afterAll = afterAll; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, +}); + +const error = { messageId: "preferObjectParams" }; + +ruleTester.run("prefer-object-params", rule, { + valid: [ + { code: "function f() {}" }, + { code: "function f(a) {}" }, + { code: "const f = (a) => a;" }, + { code: "const f = function (a) { return a; };" }, + { code: "function f({ a, b }) { return a + b; }" }, + { code: "function f({ a, b }: { a: number; b: number }) { return a + b; }" }, + + // Direct callbacks: positional args are dictated by the caller. + { code: "[1, 2, 3].reduce((acc, cur) => acc + cur, 0);" }, + { code: "arr.map((value, index) => value + index);" }, + { code: "new Promise((resolve, reject) => {});" }, + { code: "maybeFn?.((a, b) => a + b);" }, + + // Inline option-object callbacks: caller dictates the signature + // (e.g. zustand's `persist`, tanstack-query's `onSuccess`). + { code: "someAPI({ on: (a, b) => a + b });" }, + { + code: "persist((set, get) => ({}), { migrate: (state, version) => state });", + }, + { code: "new Foo({ handler: (a, b) => a + b });" }, + { code: "maybeFn?.({ handler: (a, b) => a + b });" }, + + // Deeply-nested inline option callbacks still qualify: the entire + // option tree is supplied at the call site, so the caller dictates + // every signature inside it. Real example: better-auth's + // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`. + { code: "someAPI({ outer: { handler: (a, b) => a + b } });" }, + + // Type predicates require a positional subject. + { + code: "function isFoo(x: unknown, ctx: Ctx): x is Foo { return true; }", + }, + { + code: "const isFoo = (x: unknown, ctx: Ctx): x is Foo => true;", + }, + ], + invalid: [ + { + code: "function f(a, b) { return a + b; }", + errors: [error], + }, + { + code: "const f = (a, b) => a + b;", + errors: [error], + }, + { + code: "const f = function (a, b) { return a + b; };", + errors: [error], + }, + { + code: "function f(a, b, c) { return a + b + c; }", + errors: [error], + }, + { + code: "function formatTime(seconds: number, fps: number) { return seconds * fps; }", + errors: [error], + }, + // A callback assigned to a name and passed indirectly is still a + // signature the author controls; the carve-out is intentionally narrow + // to direct callbacks. + { + code: "const cb = (a, b) => a + b; arr.reduce(cb, 0);", + errors: [error], + }, + // Pre-declared option object: once the object is named, its callback + // signatures are the author's to evolve. + { + code: "const opts = { handler: (a, b) => a + b }; someAPI(opts);", + errors: [error], + }, + // The error must point at the function's declaration line, so a + // `// eslint-disable-next-line` placed above the declaration (the + // conventional spot) suppresses the violation even when the + // signature spans multiple lines. Reporting at a parameter row + // instead leaves the directive inert and `reportUnusedDisableDirectives` + // then flags every suppressed call site. + { + code: [ + "function f(", + "\ta: number,", + "\tb: number,", + ") { return a + b; }", + ].join("\n"), + errors: [{ messageId: "preferObjectParams", line: 1 }], + }, + ], +}); diff --git a/eslint/rules/prefer-object-params.mjs b/eslint/rules/prefer-object-params.mjs new file mode 100644 index 00000000..a5a8caae --- /dev/null +++ b/eslint/rules/prefer-object-params.mjs @@ -0,0 +1,107 @@ +export default { + meta: { + type: "suggestion", + docs: { + description: + "Prefer a single destructured object parameter over multiple positional parameters", + }, + schema: [], + messages: { + preferObjectParams: + "Use a single destructured object parameter instead of multiple positional parameters. Example: function someFunction({ name, age }: { name: string, age: number }) {}", + }, + }, + create(context) { + function isCallArgument(node) { + return ( + node && + (node.type === "CallExpression" || + node.type === "NewExpression" || + node.type === "OptionalCallExpression") + ); + } + + function isDirectCallback(node) { + const parent = node.parent; + return ( + isCallArgument(parent) && parent.arguments.includes(node) + ); + } + + // Some APIs receive callbacks via a property somewhere inside an inline + // option object (e.g. `persist(state, { migrate: (s, v) => ... })` or + // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`). + // The caller still dictates the signature, so the same carve-out as + // `isDirectCallback` applies. Pre-declared option objects don't qualify — + // once you name the object, the signature is yours to evolve. + function isInlineOptionCallback(node) { + let current = node; + let property = current.parent; + if ( + !property || + property.type !== "Property" || + property.value !== current + ) { + return false; + } + + let objectExpression = property.parent; + while (objectExpression?.type === "ObjectExpression") { + const parent = objectExpression.parent; + if (isCallArgument(parent) && parent.arguments.includes(objectExpression)) { + return true; + } + + if ( + parent?.type !== "Property" || + parent.value !== objectExpression + ) { + return false; + } + + property = parent; + objectExpression = property.parent; + } + + return false; + } + + // Type predicates (`x is Foo`) require a positional subject by language + // rule, so the readability/evolution argument doesn't apply. AGENTS.md + // carves these out explicitly. + function isTypePredicate(node) { + return node.returnType?.typeAnnotation?.type === "TSTypePredicate"; + } + + function reportIfNeeded(node) { + if ( + node.params.length <= 1 || + isDirectCallback(node) || + isInlineOptionCallback(node) || + isTypePredicate(node) + ) { + return; + } + + // Report from the function's first line through params[1] so a + // conventional `// eslint-disable-next-line` placed above the + // declaration suppresses the violation. The directive matches + // against `loc.start.line`, which must land on the function + // declaration line — not on a later parameter row. + context.report({ + node, + loc: { + start: node.loc.start, + end: node.params[1].loc.end, + }, + messageId: "preferObjectParams", + }); + } + + return { + ArrowFunctionExpression: reportIfNeeded, + FunctionDeclaration: reportIfNeeded, + FunctionExpression: reportIfNeeded, + }; + }, +}; |
