oxclippy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/oxclippy.js +2428 -0
- package/package.json +53 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +134 -0
- package/src/rules/almost-swapped.ts +52 -0
- package/src/rules/bool-comparison.ts +38 -0
- package/src/rules/bool-to-int-with-if.ts +30 -0
- package/src/rules/cognitive-complexity.ts +137 -0
- package/src/rules/collapsible-if.ts +38 -0
- package/src/rules/enum-variant-names.ts +45 -0
- package/src/rules/excessive-nesting.ts +84 -0
- package/src/rules/explicit-counter-loop.ts +66 -0
- package/src/rules/filter-then-first.ts +26 -0
- package/src/rules/float-comparison.ts +52 -0
- package/src/rules/float-equality-without-abs.ts +43 -0
- package/src/rules/fn-params-excessive-bools.ts +49 -0
- package/src/rules/identity-op.ts +46 -0
- package/src/rules/if-same-then-else.ts +34 -0
- package/src/rules/int-plus-one.ts +74 -0
- package/src/rules/let-and-return.ts +43 -0
- package/src/rules/manual-clamp.ts +63 -0
- package/src/rules/manual-every.ts +47 -0
- package/src/rules/manual-find.ts +59 -0
- package/src/rules/manual-includes.ts +65 -0
- package/src/rules/manual-is-finite.ts +57 -0
- package/src/rules/manual-some.ts +46 -0
- package/src/rules/manual-strip.ts +96 -0
- package/src/rules/manual-swap.ts +73 -0
- package/src/rules/map-identity.ts +67 -0
- package/src/rules/map-void-return.ts +23 -0
- package/src/rules/match-same-arms.ts +46 -0
- package/src/rules/needless-bool.ts +67 -0
- package/src/rules/needless-continue.ts +47 -0
- package/src/rules/needless-late-init.ts +44 -0
- package/src/rules/needless-range-loop.ts +127 -0
- package/src/rules/neg-multiply.ts +36 -0
- package/src/rules/never-loop.ts +49 -0
- package/src/rules/object-keys-values.ts +67 -0
- package/src/rules/prefer-structured-clone.ts +30 -0
- package/src/rules/promise-new-resolve.ts +59 -0
- package/src/rules/redundant-closure-call.ts +61 -0
- package/src/rules/redundant-closure.ts +81 -0
- package/src/rules/search-is-some.ts +45 -0
- package/src/rules/similar-names.ts +155 -0
- package/src/rules/single-case-switch.ts +27 -0
- package/src/rules/single-element-loop.ts +21 -0
- package/src/rules/struct-field-names.ts +108 -0
- package/src/rules/too-many-arguments.ts +37 -0
- package/src/rules/too-many-lines.ts +45 -0
- package/src/rules/unnecessary-fold.ts +65 -0
- package/src/rules/unnecessary-reduce-collect.ts +109 -0
- package/src/rules/unreadable-literal.ts +59 -0
- package/src/rules/used-underscore-binding.ts +41 -0
- package/src/rules/useless-conversion.ts +115 -0
- package/src/rules/xor-used-as-pow.ts +34 -0
- package/src/rules/zero-divided-by-zero.ts +24 -0
- package/src/types.ts +75 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oxclippy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An oxlint JS plugin that mirrors Rust Clippy rules for TypeScript/JavaScript",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"clippy",
|
|
7
|
+
"javascript",
|
|
8
|
+
"lint",
|
|
9
|
+
"linter",
|
|
10
|
+
"oxc",
|
|
11
|
+
"oxlint",
|
|
12
|
+
"plugin",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/rayhanadev/oxclippy#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/rayhanadev/oxclippy/issues"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/rayhanadev/oxclippy"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "dist/oxclippy.js",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./dist/oxclippy.js"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun build src/plugin.ts --outfile dist/oxclippy.js --format esm",
|
|
35
|
+
"lint": "oxlint",
|
|
36
|
+
"format": "oxfmt",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"prepublishOnly": "bun run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"oxc-parser": "^0.123.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/bun": "latest",
|
|
46
|
+
"oxfmt": "^0.43.0",
|
|
47
|
+
"oxlint": "^1.58.0",
|
|
48
|
+
"oxlint-tsgolint": "^0.19.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^6"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./plugin";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// oxclippy: An oxlint JS plugin that mirrors Rust Clippy rules for TypeScript/JavaScript
|
|
2
|
+
//
|
|
3
|
+
// Each rule is named after its Clippy counterpart. Rules that already exist in
|
|
4
|
+
// oxlint's built-in plugins (eslint, typescript, unicorn, oxc) are intentionally
|
|
5
|
+
// omitted to avoid duplication.
|
|
6
|
+
|
|
7
|
+
import needlessBool from "./rules/needless-bool";
|
|
8
|
+
import collapsibleIf from "./rules/collapsible-if";
|
|
9
|
+
import negMultiply from "./rules/neg-multiply";
|
|
10
|
+
import boolComparison from "./rules/bool-comparison";
|
|
11
|
+
import identityOp from "./rules/identity-op";
|
|
12
|
+
import singleCaseSwitch from "./rules/single-case-switch";
|
|
13
|
+
import tooManyArguments from "./rules/too-many-arguments";
|
|
14
|
+
import tooManyLines from "./rules/too-many-lines";
|
|
15
|
+
import filterThenFirst from "./rules/filter-then-first";
|
|
16
|
+
import mapVoidReturn from "./rules/map-void-return";
|
|
17
|
+
import uselessConversion from "./rules/useless-conversion";
|
|
18
|
+
import manualClamp from "./rules/manual-clamp";
|
|
19
|
+
import manualStrip from "./rules/manual-strip";
|
|
20
|
+
import manualFind from "./rules/manual-find";
|
|
21
|
+
import manualSome from "./rules/manual-some";
|
|
22
|
+
import manualEvery from "./rules/manual-every";
|
|
23
|
+
import manualIncludes from "./rules/manual-includes";
|
|
24
|
+
import cognitiveComplexity from "./rules/cognitive-complexity";
|
|
25
|
+
import floatComparison from "./rules/float-comparison";
|
|
26
|
+
import needlessRangeLoop from "./rules/needless-range-loop";
|
|
27
|
+
import manualSwap from "./rules/manual-swap";
|
|
28
|
+
import searchIsSome from "./rules/search-is-some";
|
|
29
|
+
import letAndReturn from "./rules/let-and-return";
|
|
30
|
+
import xorUsedAsPow from "./rules/xor-used-as-pow";
|
|
31
|
+
import mapIdentity from "./rules/map-identity";
|
|
32
|
+
import redundantClosureCall from "./rules/redundant-closure-call";
|
|
33
|
+
import almostSwapped from "./rules/almost-swapped";
|
|
34
|
+
import ifSameThenElse from "./rules/if-same-then-else";
|
|
35
|
+
import neverLoop from "./rules/never-loop";
|
|
36
|
+
import explicitCounterLoop from "./rules/explicit-counter-loop";
|
|
37
|
+
import excessiveNesting from "./rules/excessive-nesting";
|
|
38
|
+
import fnParamsExcessiveBools from "./rules/fn-params-excessive-bools";
|
|
39
|
+
import floatEqualityWithoutAbs from "./rules/float-equality-without-abs";
|
|
40
|
+
import manualIsFinite from "./rules/manual-is-finite";
|
|
41
|
+
import unnecessaryFold from "./rules/unnecessary-fold";
|
|
42
|
+
import needlessLateInit from "./rules/needless-late-init";
|
|
43
|
+
import singleElementLoop from "./rules/single-element-loop";
|
|
44
|
+
import intPlusOne from "./rules/int-plus-one";
|
|
45
|
+
import zeroDividedByZero from "./rules/zero-divided-by-zero";
|
|
46
|
+
import redundantClosure from "./rules/redundant-closure";
|
|
47
|
+
import unnecessaryReduceCollect from "./rules/unnecessary-reduce-collect";
|
|
48
|
+
import preferStructuredClone from "./rules/prefer-structured-clone";
|
|
49
|
+
import objectKeysValues from "./rules/object-keys-values";
|
|
50
|
+
import promiseNewResolve from "./rules/promise-new-resolve";
|
|
51
|
+
import similarNames from "./rules/similar-names";
|
|
52
|
+
import matchSameArms from "./rules/match-same-arms";
|
|
53
|
+
import usedUnderscoreBinding from "./rules/used-underscore-binding";
|
|
54
|
+
import needlessContinue from "./rules/needless-continue";
|
|
55
|
+
import enumVariantNames from "./rules/enum-variant-names";
|
|
56
|
+
import structFieldNames from "./rules/struct-field-names";
|
|
57
|
+
import unreadableLiteral from "./rules/unreadable-literal";
|
|
58
|
+
import boolToIntWithIf from "./rules/bool-to-int-with-if";
|
|
59
|
+
|
|
60
|
+
const plugin = {
|
|
61
|
+
meta: {
|
|
62
|
+
name: "oxclippy",
|
|
63
|
+
version: "0.1.0",
|
|
64
|
+
},
|
|
65
|
+
rules: {
|
|
66
|
+
// Style — code clarity and readability
|
|
67
|
+
"needless-bool": needlessBool,
|
|
68
|
+
"collapsible-if": collapsibleIf,
|
|
69
|
+
"neg-multiply": negMultiply,
|
|
70
|
+
"bool-comparison": boolComparison,
|
|
71
|
+
"single-case-switch": singleCaseSwitch,
|
|
72
|
+
"let-and-return": letAndReturn,
|
|
73
|
+
"int-plus-one": intPlusOne,
|
|
74
|
+
"needless-late-init": needlessLateInit,
|
|
75
|
+
|
|
76
|
+
// Complexity — simplifiable patterns
|
|
77
|
+
"identity-op": identityOp,
|
|
78
|
+
"manual-clamp": manualClamp,
|
|
79
|
+
"manual-strip": manualStrip,
|
|
80
|
+
"useless-conversion": uselessConversion,
|
|
81
|
+
"manual-swap": manualSwap,
|
|
82
|
+
"manual-is-finite": manualIsFinite,
|
|
83
|
+
|
|
84
|
+
// Correctness — likely bugs
|
|
85
|
+
"float-comparison": floatComparison,
|
|
86
|
+
"xor-used-as-pow": xorUsedAsPow,
|
|
87
|
+
"almost-swapped": almostSwapped,
|
|
88
|
+
"if-same-then-else": ifSameThenElse,
|
|
89
|
+
"never-loop": neverLoop,
|
|
90
|
+
"float-equality-without-abs": floatEqualityWithoutAbs,
|
|
91
|
+
"zero-divided-by-zero": zeroDividedByZero,
|
|
92
|
+
|
|
93
|
+
// Iterator — loops replaceable with array methods
|
|
94
|
+
"filter-then-first": filterThenFirst,
|
|
95
|
+
"map-void-return": mapVoidReturn,
|
|
96
|
+
"map-identity": mapIdentity,
|
|
97
|
+
"manual-find": manualFind,
|
|
98
|
+
"manual-some": manualSome,
|
|
99
|
+
"manual-every": manualEvery,
|
|
100
|
+
"manual-includes": manualIncludes,
|
|
101
|
+
"search-is-some": searchIsSome,
|
|
102
|
+
"needless-range-loop": needlessRangeLoop,
|
|
103
|
+
"redundant-closure-call": redundantClosureCall,
|
|
104
|
+
"explicit-counter-loop": explicitCounterLoop,
|
|
105
|
+
"unnecessary-fold": unnecessaryFold,
|
|
106
|
+
"single-element-loop": singleElementLoop,
|
|
107
|
+
|
|
108
|
+
// Functions — function-level quality
|
|
109
|
+
"too-many-arguments": tooManyArguments,
|
|
110
|
+
"too-many-lines": tooManyLines,
|
|
111
|
+
"cognitive-complexity": cognitiveComplexity,
|
|
112
|
+
"excessive-nesting": excessiveNesting,
|
|
113
|
+
"fn-params-excessive-bools": fnParamsExcessiveBools,
|
|
114
|
+
|
|
115
|
+
// Principles — idiomatic JS/TS from Clippy philosophy
|
|
116
|
+
"redundant-closure": redundantClosure,
|
|
117
|
+
"unnecessary-reduce-collect": unnecessaryReduceCollect,
|
|
118
|
+
"prefer-structured-clone": preferStructuredClone,
|
|
119
|
+
"object-keys-values": objectKeysValues,
|
|
120
|
+
"promise-new-resolve": promiseNewResolve,
|
|
121
|
+
|
|
122
|
+
// Pedantic — naming, style, readability
|
|
123
|
+
"similar-names": similarNames,
|
|
124
|
+
"match-same-arms": matchSameArms,
|
|
125
|
+
"used-underscore-binding": usedUnderscoreBinding,
|
|
126
|
+
"needless-continue": needlessContinue,
|
|
127
|
+
"enum-variant-names": enumVariantNames,
|
|
128
|
+
"struct-field-names": structFieldNames,
|
|
129
|
+
"unreadable-literal": unreadableLiteral,
|
|
130
|
+
"bool-to-int-with-if": boolToIntWithIf,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export default plugin;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// clippy::almost_swapped — broken swap: a = b; b = a; (original a is lost)
|
|
2
|
+
// Detects: consecutive assignments that look like a swap but forgot the temp variable.
|
|
3
|
+
|
|
4
|
+
import type { Context, Node } from "../types";
|
|
5
|
+
|
|
6
|
+
function assignTarget(node: Node): string | null {
|
|
7
|
+
if (node.type !== "ExpressionStatement") return null;
|
|
8
|
+
const expr = node.expression;
|
|
9
|
+
if (expr.type !== "AssignmentExpression" || expr.operator !== "=") return null;
|
|
10
|
+
if (expr.left.type === "Identifier") return expr.left.name;
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assignSource(node: Node): string | null {
|
|
15
|
+
if (node.type !== "ExpressionStatement") return null;
|
|
16
|
+
const expr = node.expression;
|
|
17
|
+
if (expr.type !== "AssignmentExpression" || expr.operator !== "=") return null;
|
|
18
|
+
if (expr.right.type === "Identifier") return expr.right.name;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkBody(body: Node[], context: Context) {
|
|
23
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
24
|
+
const s1 = body[i]!;
|
|
25
|
+
const s2 = body[i + 1]!;
|
|
26
|
+
|
|
27
|
+
const t1 = assignTarget(s1);
|
|
28
|
+
const src1 = assignSource(s1);
|
|
29
|
+
const t2 = assignTarget(s2);
|
|
30
|
+
const src2 = assignSource(s2);
|
|
31
|
+
|
|
32
|
+
if (t1 && src1 && t2 && src2 && t1 === src2 && t2 === src1 && t1 !== t2) {
|
|
33
|
+
context.report({
|
|
34
|
+
message: `Almost swapped: \`${t1} = ${src1}; ${t2} = ${src2};\` overwrites \`${t1}\` before saving it. Use \`[${t1}, ${t2}] = [${t2}, ${t1}]\`. (clippy::almost_swapped)`,
|
|
35
|
+
node: s1,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
create(context: Context) {
|
|
43
|
+
return {
|
|
44
|
+
BlockStatement(node: Node) {
|
|
45
|
+
if (node.body) checkBody(node.body, context);
|
|
46
|
+
},
|
|
47
|
+
Program(node: Node) {
|
|
48
|
+
if (node.body) checkBody(node.body, context);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// clippy::bool_comparison — explicit comparison to boolean literals
|
|
2
|
+
// Detects: x === true → x, x === false → !x, x !== true → !x, x !== false → x
|
|
3
|
+
// NOT covered by oxlint's built-in rules (no-unnecessary-boolean-literal-compare is not in oxlint)
|
|
4
|
+
|
|
5
|
+
import type { Context, Node } from "../types";
|
|
6
|
+
import { isBoolLiteral } from "../types";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
create(context: Context) {
|
|
10
|
+
return {
|
|
11
|
+
BinaryExpression(node: Node) {
|
|
12
|
+
if (node.operator !== "===" && node.operator !== "!==") return;
|
|
13
|
+
|
|
14
|
+
const leftBool = isBoolLiteral(node.left);
|
|
15
|
+
const rightBool = isBoolLiteral(node.right);
|
|
16
|
+
|
|
17
|
+
if (!leftBool && !rightBool) return;
|
|
18
|
+
// Skip if both sides are bool literals (that's a different issue)
|
|
19
|
+
if (leftBool && rightBool) return;
|
|
20
|
+
|
|
21
|
+
const boolVal = leftBool ? node.left.value : node.right.value;
|
|
22
|
+
const isEqual = node.operator === "===";
|
|
23
|
+
|
|
24
|
+
let suggestion: string;
|
|
25
|
+
if ((boolVal && isEqual) || (!boolVal && !isEqual)) {
|
|
26
|
+
suggestion = "use the expression directly";
|
|
27
|
+
} else {
|
|
28
|
+
suggestion = "negate the expression with `!`";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
context.report({
|
|
32
|
+
message: `Bool comparison: comparison to \`${boolVal}\` is needless. Instead, ${suggestion}. (clippy::bool_comparison)`,
|
|
33
|
+
node,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// clippy::bool_to_int_with_if — converting a boolean to 0/1 with a ternary or if/else
|
|
2
|
+
// Detects: cond ? 1 : 0 → +cond or Number(cond)
|
|
3
|
+
// Detects: cond ? 0 : 1 → +!cond or Number(!cond)
|
|
4
|
+
|
|
5
|
+
import type { Context, Node } from "../types";
|
|
6
|
+
import { isLiteral } from "../types";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
create(context: Context) {
|
|
10
|
+
return {
|
|
11
|
+
ConditionalExpression(node: Node) {
|
|
12
|
+
const { consequent, alternate } = node;
|
|
13
|
+
|
|
14
|
+
if (isLiteral(consequent, 1) && isLiteral(alternate, 0)) {
|
|
15
|
+
context.report({
|
|
16
|
+
message:
|
|
17
|
+
"Bool to int with if: `cond ? 1 : 0` can be simplified to `Number(cond)` or `+cond`. (clippy::bool_to_int_with_if)",
|
|
18
|
+
node,
|
|
19
|
+
});
|
|
20
|
+
} else if (isLiteral(consequent, 0) && isLiteral(alternate, 1)) {
|
|
21
|
+
context.report({
|
|
22
|
+
message:
|
|
23
|
+
"Bool to int with if: `cond ? 0 : 1` can be simplified to `Number(!cond)` or `+!cond`. (clippy::bool_to_int_with_if)",
|
|
24
|
+
node,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// clippy::cognitive_complexity — function exceeds complexity threshold
|
|
2
|
+
// Uses a simplified cognitive complexity model:
|
|
3
|
+
// - +1 for each: if, else if, else, for, for-in, for-of, while, do-while, catch, ternary (?:)
|
|
4
|
+
// - +1 for each: &&, || (logical operators)
|
|
5
|
+
// - +1 nesting bonus per level of nesting for control flow
|
|
6
|
+
// Default threshold: 25 (matches Clippy)
|
|
7
|
+
|
|
8
|
+
import type { Context, Node } from "../types";
|
|
9
|
+
|
|
10
|
+
const THRESHOLD = 25;
|
|
11
|
+
|
|
12
|
+
function calculateComplexity(node: Node): number {
|
|
13
|
+
let complexity = 0;
|
|
14
|
+
|
|
15
|
+
function walk(n: Node, nesting: number) {
|
|
16
|
+
if (!n || typeof n !== "object") return;
|
|
17
|
+
|
|
18
|
+
switch (n.type) {
|
|
19
|
+
case "IfStatement":
|
|
20
|
+
complexity += 1 + nesting;
|
|
21
|
+
walk(n.test, nesting);
|
|
22
|
+
walk(n.consequent, nesting + 1);
|
|
23
|
+
if (n.alternate) {
|
|
24
|
+
if (n.alternate.type === "IfStatement") {
|
|
25
|
+
// else if: +1 but no nesting increase
|
|
26
|
+
complexity += 1;
|
|
27
|
+
walk(n.alternate.test, nesting);
|
|
28
|
+
walk(n.alternate.consequent, nesting + 1);
|
|
29
|
+
if (n.alternate.alternate) {
|
|
30
|
+
walk(n.alternate.alternate, nesting);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// else: +1
|
|
34
|
+
complexity += 1;
|
|
35
|
+
walk(n.alternate, nesting + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return; // handled children manually
|
|
39
|
+
|
|
40
|
+
case "ForStatement":
|
|
41
|
+
case "ForInStatement":
|
|
42
|
+
case "ForOfStatement":
|
|
43
|
+
case "WhileStatement":
|
|
44
|
+
case "DoWhileStatement":
|
|
45
|
+
complexity += 1 + nesting;
|
|
46
|
+
walkChildren(n, nesting + 1);
|
|
47
|
+
return;
|
|
48
|
+
|
|
49
|
+
case "CatchClause":
|
|
50
|
+
complexity += 1 + nesting;
|
|
51
|
+
walkChildren(n, nesting + 1);
|
|
52
|
+
return;
|
|
53
|
+
|
|
54
|
+
case "SwitchStatement":
|
|
55
|
+
complexity += 1 + nesting;
|
|
56
|
+
walkChildren(n, nesting + 1);
|
|
57
|
+
return;
|
|
58
|
+
|
|
59
|
+
case "ConditionalExpression":
|
|
60
|
+
complexity += 1 + nesting;
|
|
61
|
+
walk(n.test, nesting);
|
|
62
|
+
walk(n.consequent, nesting + 1);
|
|
63
|
+
walk(n.alternate, nesting + 1);
|
|
64
|
+
return;
|
|
65
|
+
|
|
66
|
+
case "LogicalExpression":
|
|
67
|
+
// Each && or || adds 1 (no nesting bonus for these)
|
|
68
|
+
complexity += 1;
|
|
69
|
+
walk(n.left, nesting);
|
|
70
|
+
walk(n.right, nesting);
|
|
71
|
+
return;
|
|
72
|
+
|
|
73
|
+
// Don't recurse into nested function declarations/expressions
|
|
74
|
+
case "FunctionDeclaration":
|
|
75
|
+
case "FunctionExpression":
|
|
76
|
+
case "ArrowFunctionExpression":
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
walkChildren(n, nesting);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkChildren(n: Node, nesting: number) {
|
|
84
|
+
for (const key of Object.keys(n)) {
|
|
85
|
+
if (key === "type" || key === "loc" || key === "range" || key === "parent") continue;
|
|
86
|
+
const val = n[key];
|
|
87
|
+
if (Array.isArray(val)) {
|
|
88
|
+
for (const child of val) {
|
|
89
|
+
if (child && typeof child === "object" && child.type) {
|
|
90
|
+
walk(child, nesting);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
94
|
+
walk(val, nesting);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Start walking from the function body
|
|
100
|
+
if (node.body) {
|
|
101
|
+
if (node.body.type === "BlockStatement") {
|
|
102
|
+
for (const stmt of node.body.body) {
|
|
103
|
+
walk(stmt, 0);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Arrow function with expression body
|
|
107
|
+
walk(node.body, 0);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return complexity;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
create(context: Context) {
|
|
116
|
+
function checkFunction(node: Node) {
|
|
117
|
+
const complexity = calculateComplexity(node);
|
|
118
|
+
if (complexity <= THRESHOLD) return;
|
|
119
|
+
|
|
120
|
+
const name =
|
|
121
|
+
node.id?.name ??
|
|
122
|
+
(node.parent?.type === "VariableDeclarator" ? node.parent.id?.name : null) ??
|
|
123
|
+
"<anonymous>";
|
|
124
|
+
|
|
125
|
+
context.report({
|
|
126
|
+
message: `Cognitive complexity: \`${name}\` has a complexity of ${complexity} (max ${THRESHOLD}). Consider refactoring into smaller functions. (clippy::cognitive_complexity)`,
|
|
127
|
+
node,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
FunctionDeclaration: checkFunction,
|
|
133
|
+
FunctionExpression: checkFunction,
|
|
134
|
+
ArrowFunctionExpression: checkFunction,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// clippy::collapsible_if — nested ifs that can be merged with &&
|
|
2
|
+
// Detects: if (a) { if (b) { ... } } → if (a && b) { ... }
|
|
3
|
+
// NOT covered by eslint/no-lonely-if (which only catches else { if })
|
|
4
|
+
|
|
5
|
+
import type { Context, Node } from "../types";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
create(context: Context) {
|
|
9
|
+
return {
|
|
10
|
+
IfStatement(node: Node) {
|
|
11
|
+
// Only applies when outer if has no else
|
|
12
|
+
if (node.alternate) return;
|
|
13
|
+
|
|
14
|
+
const body = node.consequent;
|
|
15
|
+
if (!body) return;
|
|
16
|
+
|
|
17
|
+
// Get the single statement inside the consequent block
|
|
18
|
+
let inner: Node | null = null;
|
|
19
|
+
if (body.type === "BlockStatement" && body.body.length === 1) {
|
|
20
|
+
inner = body.body[0];
|
|
21
|
+
} else if (body.type === "IfStatement") {
|
|
22
|
+
inner = body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!inner || inner.type !== "IfStatement") return;
|
|
26
|
+
|
|
27
|
+
// Inner if must also have no else
|
|
28
|
+
if (inner.alternate) return;
|
|
29
|
+
|
|
30
|
+
context.report({
|
|
31
|
+
message:
|
|
32
|
+
"Collapsible if: these nested ifs can be combined with `&&`. (clippy::collapsible_if)",
|
|
33
|
+
node,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// clippy::enum_variant_names — TS enum members with redundant prefix or suffix
|
|
2
|
+
// Detects: enum Color { ColorRed, ColorGreen, ColorBlue } — "Color" prefix is redundant
|
|
3
|
+
// Also: enum Status { StatusActive, StatusInactive } — all share the enum name as prefix
|
|
4
|
+
|
|
5
|
+
import type { Context, Node } from "../types";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
create(context: Context) {
|
|
9
|
+
return {
|
|
10
|
+
TSEnumDeclaration(node: Node) {
|
|
11
|
+
const enumName = node.id?.name;
|
|
12
|
+
if (!enumName) return;
|
|
13
|
+
const members: Node[] = node.members ?? node.body?.members;
|
|
14
|
+
if (!members || members.length < 2) return;
|
|
15
|
+
|
|
16
|
+
const names: string[] = members
|
|
17
|
+
.map((m: Node) => (m.id?.type === "Identifier" ? m.id.name : null))
|
|
18
|
+
.filter((n: string | null): n is string => n !== null);
|
|
19
|
+
|
|
20
|
+
if (names.length < 2) return;
|
|
21
|
+
|
|
22
|
+
const lowerEnum = enumName.toLowerCase();
|
|
23
|
+
|
|
24
|
+
// Check prefix: all members start with the enum name
|
|
25
|
+
const allPrefixed = names.every((n) => n.toLowerCase().startsWith(lowerEnum));
|
|
26
|
+
if (allPrefixed) {
|
|
27
|
+
context.report({
|
|
28
|
+
message: `Enum variant names: all members of \`${enumName}\` are prefixed with \`${enumName}\`. Remove the redundant prefix. (clippy::enum_variant_names)`,
|
|
29
|
+
node,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check suffix: all members end with the enum name
|
|
35
|
+
const allSuffixed = names.every((n) => n.toLowerCase().endsWith(lowerEnum));
|
|
36
|
+
if (allSuffixed) {
|
|
37
|
+
context.report({
|
|
38
|
+
message: `Enum variant names: all members of \`${enumName}\` are suffixed with \`${enumName}\`. Remove the redundant suffix. (clippy::enum_variant_names)`,
|
|
39
|
+
node,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// clippy::excessive_nesting — code nested beyond a threshold
|
|
2
|
+
// Default threshold: 5 levels
|
|
3
|
+
|
|
4
|
+
import type { Context, Node } from "../types";
|
|
5
|
+
|
|
6
|
+
const THRESHOLD = 5;
|
|
7
|
+
|
|
8
|
+
const NESTING_NODES = new Set([
|
|
9
|
+
"IfStatement",
|
|
10
|
+
"ForStatement",
|
|
11
|
+
"ForInStatement",
|
|
12
|
+
"ForOfStatement",
|
|
13
|
+
"WhileStatement",
|
|
14
|
+
"DoWhileStatement",
|
|
15
|
+
"SwitchStatement",
|
|
16
|
+
"TryStatement",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
create(context: Context) {
|
|
21
|
+
let reported = false;
|
|
22
|
+
|
|
23
|
+
function walk(node: Node, depth: number) {
|
|
24
|
+
if (!node || typeof node !== "object" || !node.type) return;
|
|
25
|
+
if (reported) return;
|
|
26
|
+
|
|
27
|
+
const isNesting = NESTING_NODES.has(node.type);
|
|
28
|
+
const newDepth = isNesting ? depth + 1 : depth;
|
|
29
|
+
|
|
30
|
+
if (newDepth > THRESHOLD && isNesting) {
|
|
31
|
+
reported = true;
|
|
32
|
+
context.report({
|
|
33
|
+
message: `Excessive nesting: this code is nested ${newDepth} levels deep (max ${THRESHOLD}). Consider extracting into helper functions. (clippy::excessive_nesting)`,
|
|
34
|
+
node,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Don't recurse into nested functions
|
|
40
|
+
if (
|
|
41
|
+
node.type === "FunctionDeclaration" ||
|
|
42
|
+
node.type === "FunctionExpression" ||
|
|
43
|
+
node.type === "ArrowFunctionExpression"
|
|
44
|
+
) {
|
|
45
|
+
if (depth > 0) return; // only skip nested functions, not the top-level one
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const key of Object.keys(node)) {
|
|
49
|
+
if (
|
|
50
|
+
key === "type" ||
|
|
51
|
+
key === "loc" ||
|
|
52
|
+
key === "range" ||
|
|
53
|
+
key === "parent" ||
|
|
54
|
+
key === "start" ||
|
|
55
|
+
key === "end"
|
|
56
|
+
)
|
|
57
|
+
continue;
|
|
58
|
+
const val = node[key];
|
|
59
|
+
if (Array.isArray(val)) {
|
|
60
|
+
for (const child of val) {
|
|
61
|
+
if (child && typeof child === "object" && child.type) walk(child, newDepth);
|
|
62
|
+
}
|
|
63
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
64
|
+
walk(val, newDepth);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
FunctionDeclaration(node: Node) {
|
|
71
|
+
reported = false;
|
|
72
|
+
walk(node.body, 0);
|
|
73
|
+
},
|
|
74
|
+
FunctionExpression(node: Node) {
|
|
75
|
+
reported = false;
|
|
76
|
+
walk(node.body, 0);
|
|
77
|
+
},
|
|
78
|
+
ArrowFunctionExpression(node: Node) {
|
|
79
|
+
reported = false;
|
|
80
|
+
walk(node.body, 0);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// clippy::explicit_counter_loop — manual counter variable with for-of
|
|
2
|
+
// Detects: let i = 0; for (const x of arr) { ...i...; i++; }
|
|
3
|
+
// Suggests: for (const [i, x] of arr.entries())
|
|
4
|
+
|
|
5
|
+
import type { Context, Node } from "../types";
|
|
6
|
+
import { getFunctionBody, getForOfVar } from "../types";
|
|
7
|
+
|
|
8
|
+
function checkBody(body: Node[], context: Context) {
|
|
9
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
10
|
+
const decl = body[i]!;
|
|
11
|
+
const loop = body[i + 1]!;
|
|
12
|
+
|
|
13
|
+
// decl: let counter = 0;
|
|
14
|
+
if (decl.type !== "VariableDeclaration" || decl.declarations.length !== 1) continue;
|
|
15
|
+
const d = decl.declarations[0]!;
|
|
16
|
+
if (d.id?.type !== "Identifier" || d.init?.type !== "Literal" || d.init.value !== 0) continue;
|
|
17
|
+
const counterName = d.id.name;
|
|
18
|
+
|
|
19
|
+
// loop: for (const x of arr) { ... counter++; }
|
|
20
|
+
if (loop.type !== "ForOfStatement") continue;
|
|
21
|
+
if (loop.body?.type !== "BlockStatement" || loop.body.body.length === 0) continue;
|
|
22
|
+
|
|
23
|
+
const stmts: Node[] = loop.body.body;
|
|
24
|
+
const last = stmts[stmts.length - 1]!;
|
|
25
|
+
|
|
26
|
+
// Check last statement is counter++ or counter += 1
|
|
27
|
+
const isIncrement =
|
|
28
|
+
(last.type === "ExpressionStatement" &&
|
|
29
|
+
last.expression?.type === "UpdateExpression" &&
|
|
30
|
+
last.expression.operator === "++" &&
|
|
31
|
+
last.expression.argument?.type === "Identifier" &&
|
|
32
|
+
last.expression.argument.name === counterName) ||
|
|
33
|
+
(last.type === "ExpressionStatement" &&
|
|
34
|
+
last.expression?.type === "AssignmentExpression" &&
|
|
35
|
+
last.expression.operator === "+=" &&
|
|
36
|
+
last.expression.left?.type === "Identifier" &&
|
|
37
|
+
last.expression.left.name === counterName &&
|
|
38
|
+
last.expression.right?.type === "Literal" &&
|
|
39
|
+
last.expression.right.value === 1);
|
|
40
|
+
|
|
41
|
+
if (isIncrement) {
|
|
42
|
+
context.report({
|
|
43
|
+
message: `Explicit counter loop: manual counter \`${counterName}\` can be replaced with \`for (const [${counterName}, item] of arr.entries())\`. (clippy::explicit_counter_loop)`,
|
|
44
|
+
node: loop,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default {
|
|
51
|
+
create(context: Context) {
|
|
52
|
+
function checkFunction(node: Node) {
|
|
53
|
+
const body = getFunctionBody(node);
|
|
54
|
+
if (body) checkBody(body, context);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
FunctionDeclaration: checkFunction,
|
|
59
|
+
FunctionExpression: checkFunction,
|
|
60
|
+
ArrowFunctionExpression: checkFunction,
|
|
61
|
+
Program(node: Node) {
|
|
62
|
+
if (node.body) checkBody(node.body, context);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|