distyll 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.
Files changed (243) hide show
  1. package/CONTRIBUTING.md +159 -0
  2. package/POSTMORTEM.json +60 -0
  3. package/README.md +218 -0
  4. package/SETUP.md +79 -0
  5. package/action.yml +37 -0
  6. package/dist/cache.d.ts +26 -0
  7. package/dist/cache.d.ts.map +1 -0
  8. package/dist/cache.js +115 -0
  9. package/dist/cache.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +153 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands/ci.d.ts +7 -0
  15. package/dist/commands/ci.d.ts.map +1 -0
  16. package/dist/commands/ci.js +101 -0
  17. package/dist/commands/ci.js.map +1 -0
  18. package/dist/commands/diff.d.ts +10 -0
  19. package/dist/commands/diff.d.ts.map +1 -0
  20. package/dist/commands/diff.js +95 -0
  21. package/dist/commands/diff.js.map +1 -0
  22. package/dist/commands/fingerprint.d.ts +2 -0
  23. package/dist/commands/fingerprint.d.ts.map +1 -0
  24. package/dist/commands/fingerprint.js +77 -0
  25. package/dist/commands/fingerprint.js.map +1 -0
  26. package/dist/commands/hook.d.ts +3 -0
  27. package/dist/commands/hook.d.ts.map +1 -0
  28. package/dist/commands/hook.js +110 -0
  29. package/dist/commands/hook.js.map +1 -0
  30. package/dist/commands/init.d.ts +2 -0
  31. package/dist/commands/init.d.ts.map +1 -0
  32. package/dist/commands/init.js +75 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +100 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/errors.d.ts +30 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/errors.js +133 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/fingerprint/analyzer.d.ts +3 -0
  43. package/dist/fingerprint/analyzer.d.ts.map +1 -0
  44. package/dist/fingerprint/analyzer.js +230 -0
  45. package/dist/fingerprint/analyzer.js.map +1 -0
  46. package/dist/fingerprint/comparator.d.ts +4 -0
  47. package/dist/fingerprint/comparator.d.ts.map +1 -0
  48. package/dist/fingerprint/comparator.js +78 -0
  49. package/dist/fingerprint/comparator.js.map +1 -0
  50. package/dist/fingerprint/profile.d.ts +5 -0
  51. package/dist/fingerprint/profile.d.ts.map +1 -0
  52. package/dist/fingerprint/profile.js +68 -0
  53. package/dist/fingerprint/profile.js.map +1 -0
  54. package/dist/fixes/index.d.ts +12 -0
  55. package/dist/fixes/index.d.ts.map +1 -0
  56. package/dist/fixes/index.js +42 -0
  57. package/dist/fixes/index.js.map +1 -0
  58. package/dist/fixes/single-use-wrapper.d.ts +8 -0
  59. package/dist/fixes/single-use-wrapper.d.ts.map +1 -0
  60. package/dist/fixes/single-use-wrapper.js +54 -0
  61. package/dist/fixes/single-use-wrapper.js.map +1 -0
  62. package/dist/fixes/unnecessary-try-catch.d.ts +8 -0
  63. package/dist/fixes/unnecessary-try-catch.d.ts.map +1 -0
  64. package/dist/fixes/unnecessary-try-catch.js +37 -0
  65. package/dist/fixes/unnecessary-try-catch.js.map +1 -0
  66. package/dist/fixes/unused-imports.d.ts +7 -0
  67. package/dist/fixes/unused-imports.d.ts.map +1 -0
  68. package/dist/fixes/unused-imports.js +41 -0
  69. package/dist/fixes/unused-imports.js.map +1 -0
  70. package/dist/fixes/verbose-comments.d.ts +7 -0
  71. package/dist/fixes/verbose-comments.d.ts.map +1 -0
  72. package/dist/fixes/verbose-comments.js +29 -0
  73. package/dist/fixes/verbose-comments.js.map +1 -0
  74. package/dist/formatter.d.ts +4 -0
  75. package/dist/formatter.d.ts.map +1 -0
  76. package/dist/formatter.js +72 -0
  77. package/dist/formatter.js.map +1 -0
  78. package/dist/git.d.ts +22 -0
  79. package/dist/git.d.ts.map +1 -0
  80. package/dist/git.js +130 -0
  81. package/dist/git.js.map +1 -0
  82. package/dist/index.d.ts +16 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +40 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/languages/index.d.ts +8 -0
  87. package/dist/languages/index.d.ts.map +1 -0
  88. package/dist/languages/index.js +50 -0
  89. package/dist/languages/index.js.map +1 -0
  90. package/dist/languages/javascript.d.ts +6 -0
  91. package/dist/languages/javascript.d.ts.map +1 -0
  92. package/dist/languages/javascript.js +39 -0
  93. package/dist/languages/javascript.js.map +1 -0
  94. package/dist/languages/python.d.ts +6 -0
  95. package/dist/languages/python.d.ts.map +1 -0
  96. package/dist/languages/python.js +50 -0
  97. package/dist/languages/python.js.map +1 -0
  98. package/dist/parser.d.ts +8 -0
  99. package/dist/parser.d.ts.map +1 -0
  100. package/dist/parser.js +55 -0
  101. package/dist/parser.js.map +1 -0
  102. package/dist/reporters/github.d.ts +4 -0
  103. package/dist/reporters/github.d.ts.map +1 -0
  104. package/dist/reporters/github.js +70 -0
  105. package/dist/reporters/github.js.map +1 -0
  106. package/dist/reporters/terminal.d.ts +4 -0
  107. package/dist/reporters/terminal.d.ts.map +1 -0
  108. package/dist/reporters/terminal.js +59 -0
  109. package/dist/reporters/terminal.js.map +1 -0
  110. package/dist/rules/dead-code-paths.d.ts +3 -0
  111. package/dist/rules/dead-code-paths.d.ts.map +1 -0
  112. package/dist/rules/dead-code-paths.js +57 -0
  113. package/dist/rules/dead-code-paths.js.map +1 -0
  114. package/dist/rules/excessive-comments.d.ts +3 -0
  115. package/dist/rules/excessive-comments.d.ts.map +1 -0
  116. package/dist/rules/excessive-comments.js +86 -0
  117. package/dist/rules/excessive-comments.js.map +1 -0
  118. package/dist/rules/hallucinated-imports.d.ts +3 -0
  119. package/dist/rules/hallucinated-imports.d.ts.map +1 -0
  120. package/dist/rules/hallucinated-imports.js +228 -0
  121. package/dist/rules/hallucinated-imports.js.map +1 -0
  122. package/dist/rules/index.d.ts +4 -0
  123. package/dist/rules/index.d.ts.map +1 -0
  124. package/dist/rules/index.js +34 -0
  125. package/dist/rules/index.js.map +1 -0
  126. package/dist/rules/magic-values.d.ts +3 -0
  127. package/dist/rules/magic-values.d.ts.map +1 -0
  128. package/dist/rules/magic-values.js +168 -0
  129. package/dist/rules/magic-values.js.map +1 -0
  130. package/dist/rules/near-duplicate-functions.d.ts +3 -0
  131. package/dist/rules/near-duplicate-functions.d.ts.map +1 -0
  132. package/dist/rules/near-duplicate-functions.js +78 -0
  133. package/dist/rules/near-duplicate-functions.js.map +1 -0
  134. package/dist/rules/over-defensive-nulls.d.ts +3 -0
  135. package/dist/rules/over-defensive-nulls.d.ts.map +1 -0
  136. package/dist/rules/over-defensive-nulls.js +129 -0
  137. package/dist/rules/over-defensive-nulls.js.map +1 -0
  138. package/dist/rules/redundant-else-return.d.ts +3 -0
  139. package/dist/rules/redundant-else-return.d.ts.map +1 -0
  140. package/dist/rules/redundant-else-return.js +57 -0
  141. package/dist/rules/redundant-else-return.js.map +1 -0
  142. package/dist/rules/single-option-object.d.ts +3 -0
  143. package/dist/rules/single-option-object.d.ts.map +1 -0
  144. package/dist/rules/single-option-object.js +88 -0
  145. package/dist/rules/single-option-object.js.map +1 -0
  146. package/dist/rules/single-use-wrapper.d.ts +3 -0
  147. package/dist/rules/single-use-wrapper.d.ts.map +1 -0
  148. package/dist/rules/single-use-wrapper.js +172 -0
  149. package/dist/rules/single-use-wrapper.js.map +1 -0
  150. package/dist/rules/unnecessary-try-catch.d.ts +3 -0
  151. package/dist/rules/unnecessary-try-catch.d.ts.map +1 -0
  152. package/dist/rules/unnecessary-try-catch.js +116 -0
  153. package/dist/rules/unnecessary-try-catch.js.map +1 -0
  154. package/dist/rules/unused-imports.d.ts +3 -0
  155. package/dist/rules/unused-imports.d.ts.map +1 -0
  156. package/dist/rules/unused-imports.js +103 -0
  157. package/dist/rules/unused-imports.js.map +1 -0
  158. package/dist/rules/verbose-comments.d.ts +3 -0
  159. package/dist/rules/verbose-comments.d.ts.map +1 -0
  160. package/dist/rules/verbose-comments.js +100 -0
  161. package/dist/rules/verbose-comments.js.map +1 -0
  162. package/dist/scanner.d.ts +11 -0
  163. package/dist/scanner.d.ts.map +1 -0
  164. package/dist/scanner.js +196 -0
  165. package/dist/scanner.js.map +1 -0
  166. package/dist/scorer.d.ts +3 -0
  167. package/dist/scorer.d.ts.map +1 -0
  168. package/dist/scorer.js +23 -0
  169. package/dist/scorer.js.map +1 -0
  170. package/dist/types.d.ts +62 -0
  171. package/dist/types.d.ts.map +1 -0
  172. package/dist/types.js +3 -0
  173. package/dist/types.js.map +1 -0
  174. package/hn_post.md +13 -0
  175. package/marketing/COMPETITIVE_ANALYSIS.md +62 -0
  176. package/marketing/EMAIL_ANNOUNCEMENT.md +91 -0
  177. package/marketing/LANDING_PAGE_COPY.md +123 -0
  178. package/marketing/LAUNCH_POST.md +68 -0
  179. package/marketing/PRODUCT_HUNT.md +39 -0
  180. package/marketing/TWITTER_THREAD.md +70 -0
  181. package/package.json +44 -0
  182. package/producthunt.md +52 -0
  183. package/reddit_post.md +39 -0
  184. package/site/favicon.svg +10 -0
  185. package/site/index.html +281 -0
  186. package/site/script.js +82 -0
  187. package/site/style.css +516 -0
  188. package/src/cache.ts +114 -0
  189. package/src/cli.ts +169 -0
  190. package/src/commands/ci.ts +111 -0
  191. package/src/commands/diff.ts +108 -0
  192. package/src/commands/fingerprint.ts +47 -0
  193. package/src/commands/hook.ts +85 -0
  194. package/src/commands/init.ts +42 -0
  195. package/src/config.ts +75 -0
  196. package/src/errors.ts +105 -0
  197. package/src/fingerprint/analyzer.ts +214 -0
  198. package/src/fingerprint/comparator.ts +93 -0
  199. package/src/fingerprint/profile.ts +32 -0
  200. package/src/fixes/index.ts +58 -0
  201. package/src/fixes/single-use-wrapper.ts +60 -0
  202. package/src/fixes/unnecessary-try-catch.ts +43 -0
  203. package/src/fixes/unused-imports.ts +53 -0
  204. package/src/fixes/verbose-comments.ts +35 -0
  205. package/src/formatter.ts +79 -0
  206. package/src/git.ts +115 -0
  207. package/src/index.ts +15 -0
  208. package/src/languages/index.ts +50 -0
  209. package/src/languages/javascript.ts +36 -0
  210. package/src/languages/python.ts +47 -0
  211. package/src/parser.ts +52 -0
  212. package/src/reporters/github.ts +75 -0
  213. package/src/reporters/terminal.ts +67 -0
  214. package/src/rules/dead-code-paths.ts +62 -0
  215. package/src/rules/excessive-comments.ts +94 -0
  216. package/src/rules/hallucinated-imports.ts +195 -0
  217. package/src/rules/index.ts +32 -0
  218. package/src/rules/magic-values.ts +167 -0
  219. package/src/rules/near-duplicate-functions.ts +89 -0
  220. package/src/rules/over-defensive-nulls.ts +137 -0
  221. package/src/rules/redundant-else-return.ts +61 -0
  222. package/src/rules/single-option-object.ts +97 -0
  223. package/src/rules/single-use-wrapper.ts +184 -0
  224. package/src/rules/unnecessary-try-catch.ts +121 -0
  225. package/src/rules/unused-imports.ts +115 -0
  226. package/src/rules/verbose-comments.ts +105 -0
  227. package/src/scanner.ts +184 -0
  228. package/src/scorer.ts +26 -0
  229. package/src/types.ts +70 -0
  230. package/tests/commands/diff.test.ts +107 -0
  231. package/tests/config.test.ts +69 -0
  232. package/tests/e2e.test.ts +163 -0
  233. package/tests/edge-cases.test.ts +167 -0
  234. package/tests/fingerprint/analyzer.test.ts +131 -0
  235. package/tests/fixes/unnecessary-try-catch.test.ts +62 -0
  236. package/tests/git.test.ts +79 -0
  237. package/tests/rules/hallucinated-imports.test.ts +59 -0
  238. package/tests/rules/near-duplicate-functions.test.ts +90 -0
  239. package/tests/rules/unnecessary-try-catch.test.ts +81 -0
  240. package/tests/scanner.test.ts +88 -0
  241. package/tsconfig.json +20 -0
  242. package/twitter_thread.md +46 -0
  243. package/vitest.config.ts +7 -0
@@ -0,0 +1,137 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { walkTree, findNodesMulti } from '../parser';
3
+ import type { Finding, Rule } from '../types';
4
+
5
+ /**
6
+ * Detects patterns where code checks for null/undefined on values that
7
+ * were just assigned or are non-nullable by context. Focuses on clearly
8
+ * unnecessary checks to minimize false positives.
9
+ *
10
+ * Patterns detected:
11
+ * - Null check immediately after non-nullable assignment: `const x = 5; if (x != null) ...`
12
+ * - Double null checks: `if (x != null) { if (x != null) ... }`
13
+ * - Typeof check on known values: `const x = []; if (typeof x !== 'undefined') ...`
14
+ */
15
+
16
+ const LITERAL_TYPES = new Set([
17
+ 'number', 'string', 'true', 'false', 'template_string',
18
+ 'array', 'object', 'regex',
19
+ // Python
20
+ 'integer', 'float', 'list', 'dictionary', 'set', 'tuple',
21
+ ]);
22
+
23
+ const NULL_CHECK_PATTERNS = new Set([
24
+ '!= null', '!== null', '!= undefined', '!== undefined',
25
+ '!= None', 'is not None',
26
+ ]);
27
+
28
+ function isNullCheck(node: Parser.SyntaxNode): boolean {
29
+ const text = node.text.replace(/\s+/g, ' ').trim();
30
+ for (const pattern of NULL_CHECK_PATTERNS) {
31
+ if (text.includes(pattern)) return true;
32
+ }
33
+ // typeof x !== 'undefined'
34
+ if (text.includes('typeof') && text.includes('undefined')) return true;
35
+ return false;
36
+ }
37
+
38
+ function isLiteralAssignment(node: Parser.SyntaxNode): boolean {
39
+ // Check if the right side of an assignment is a literal
40
+ const value = node.childForFieldName('value') ?? node.childForFieldName('right');
41
+ if (!value) return false;
42
+ return LITERAL_TYPES.has(value.type);
43
+ }
44
+
45
+ function getAssignedName(node: Parser.SyntaxNode): string | null {
46
+ const nameNode = node.childForFieldName('name') ?? node.childForFieldName('left');
47
+ if (!nameNode) return null;
48
+ return nameNode.text;
49
+ }
50
+
51
+ export const overDefensiveNulls: Rule = {
52
+ name: 'over-defensive-nulls',
53
+ description: 'Flags null/undefined checks on values that are clearly non-nullable',
54
+ severity: 'info',
55
+
56
+ check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
57
+ const findings: Finding[] = [];
58
+
59
+ // Strategy: find variable declarations with literal values, then check if
60
+ // the very next sibling is a null check on that variable
61
+ const declarations = findNodesMulti(tree.rootNode, [
62
+ 'lexical_declaration', 'variable_declaration',
63
+ // Python
64
+ 'assignment',
65
+ ]);
66
+
67
+ for (const decl of declarations) {
68
+ let assignedName: string | null = null;
69
+
70
+ // For JS/TS: const x = literal
71
+ if (decl.type === 'lexical_declaration' || decl.type === 'variable_declaration') {
72
+ for (const child of decl.namedChildren) {
73
+ if (child.type === 'variable_declarator' && isLiteralAssignment(child)) {
74
+ assignedName = getAssignedName(child);
75
+ }
76
+ }
77
+ }
78
+ // For Python: x = literal
79
+ if (decl.type === 'assignment' && isLiteralAssignment(decl)) {
80
+ assignedName = getAssignedName(decl);
81
+ }
82
+
83
+ if (!assignedName) continue;
84
+
85
+ // Check the next sibling statement for a null check on this variable
86
+ const nextSibling = decl.nextNamedSibling;
87
+ if (!nextSibling) continue;
88
+
89
+ if (nextSibling.type === 'if_statement' || nextSibling.type === 'expression_statement') {
90
+ const condition = nextSibling.childForFieldName('condition') ?? nextSibling;
91
+ if (isNullCheck(condition) && condition.text.includes(assignedName)) {
92
+ findings.push({
93
+ file: filePath,
94
+ line: nextSibling.startPosition.row + 1,
95
+ column: nextSibling.startPosition.column + 1,
96
+ endLine: nextSibling.endPosition.row + 1,
97
+ endColumn: nextSibling.endPosition.column + 1,
98
+ rule: 'over-defensive-nulls',
99
+ severity: 'info',
100
+ message: `Null check on '${assignedName}' is unnecessary — it was just assigned a non-nullable value on line ${decl.startPosition.row + 1}`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+
106
+ // Detect nested duplicate null checks
107
+ const ifStatements = findNodesMulti(tree.rootNode, ['if_statement']);
108
+ for (const ifStmt of ifStatements) {
109
+ const condition = ifStmt.childForFieldName('condition');
110
+ if (!condition || !isNullCheck(condition)) continue;
111
+
112
+ const consequence = ifStmt.childForFieldName('consequence') ?? ifStmt.childForFieldName('body');
113
+ if (!consequence) continue;
114
+
115
+ // Look for nested if with the same null check
116
+ walkTree(consequence, (inner) => {
117
+ if (inner.type === 'if_statement' && inner !== ifStmt) {
118
+ const innerCond = inner.childForFieldName('condition');
119
+ if (innerCond && isNullCheck(innerCond) && innerCond.text === condition.text) {
120
+ findings.push({
121
+ file: filePath,
122
+ line: inner.startPosition.row + 1,
123
+ column: inner.startPosition.column + 1,
124
+ endLine: inner.endPosition.row + 1,
125
+ endColumn: inner.endPosition.column + 1,
126
+ rule: 'over-defensive-nulls',
127
+ severity: 'info',
128
+ message: `Duplicate null check — '${innerCond.text}' is already checked on line ${ifStmt.startPosition.row + 1}`,
129
+ });
130
+ }
131
+ }
132
+ });
133
+ }
134
+
135
+ return findings;
136
+ },
137
+ };
@@ -0,0 +1,61 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodes } from '../parser';
3
+ import type { Finding, Rule } from '../types';
4
+
5
+ function blockEndsWithReturn(block: Parser.SyntaxNode): boolean {
6
+ const children = block.namedChildren;
7
+ if (children.length === 0) return false;
8
+ const last = children[children.length - 1];
9
+ return last.type === 'return_statement' || last.type === 'throw_statement';
10
+ }
11
+
12
+ export const redundantElseReturn: Rule = {
13
+ name: 'redundant-else-return',
14
+ description: 'Flags else blocks after if blocks that end with return/throw',
15
+ severity: 'info',
16
+
17
+ check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
18
+ const findings: Finding[] = [];
19
+ const ifStatements = findNodes(tree.rootNode, 'if_statement');
20
+
21
+ for (const ifNode of ifStatements) {
22
+ const consequence = ifNode.childForFieldName('consequence');
23
+ const alternative = ifNode.childForFieldName('alternative');
24
+
25
+ if (!consequence || !alternative) continue;
26
+
27
+ // Skip if the alternative is another if statement (else if chains are fine)
28
+ if (alternative.type === 'if_statement') continue;
29
+
30
+ // For else_clause, check the inner block
31
+ let altBlock = alternative;
32
+ if (alternative.type === 'else_clause') {
33
+ const inner = alternative.namedChildren[0];
34
+ if (!inner || inner.type === 'if_statement') continue;
35
+ altBlock = inner;
36
+ }
37
+
38
+ // Check if the if-block ends with return or throw
39
+ let ifBlock = consequence;
40
+ if (consequence.type === 'statement_block') {
41
+ ifBlock = consequence;
42
+ }
43
+
44
+ if (!blockEndsWithReturn(ifBlock)) continue;
45
+
46
+ // The else block is redundant — the code inside could be un-indented
47
+ findings.push({
48
+ file: filePath,
49
+ line: alternative.startPosition.row + 1,
50
+ column: alternative.startPosition.column + 1,
51
+ endLine: alternative.endPosition.row + 1,
52
+ endColumn: alternative.endPosition.column + 1,
53
+ rule: 'redundant-else-return',
54
+ severity: 'info',
55
+ message: 'else block is unnecessary after return/throw — un-indent and remove the else',
56
+ });
57
+ }
58
+
59
+ return findings;
60
+ },
61
+ };
@@ -0,0 +1,97 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodesMulti } from '../parser';
3
+ import type { Finding, Rule } from '../types';
4
+
5
+ function getDestructuredParams(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
6
+ const params = node.childForFieldName('parameters');
7
+ if (!params) return [];
8
+
9
+ return params.namedChildren.filter((p) => {
10
+ // Destructured object parameter: function({ foo }) {}
11
+ if (p.type === 'object_pattern') return true;
12
+ // TypeScript: function({ foo }: Options) {}
13
+ if (p.type === 'required_parameter' || p.type === 'optional_parameter') {
14
+ const pattern = p.childForFieldName('pattern');
15
+ if (pattern?.type === 'object_pattern') return true;
16
+ }
17
+ return false;
18
+ });
19
+ }
20
+
21
+ function getObjectPatternPropertyCount(node: Parser.SyntaxNode): number {
22
+ let objectPattern = node;
23
+ if (node.type === 'required_parameter' || node.type === 'optional_parameter') {
24
+ const pattern = node.childForFieldName('pattern');
25
+ if (pattern?.type === 'object_pattern') objectPattern = pattern;
26
+ else return 0;
27
+ }
28
+ if (objectPattern.type !== 'object_pattern') return 0;
29
+
30
+ // Count actual properties (not rest elements)
31
+ return objectPattern.namedChildren.filter(
32
+ (c) =>
33
+ c.type === 'shorthand_property_identifier_pattern' ||
34
+ c.type === 'pair_pattern' ||
35
+ c.type === 'object_assignment_pattern' ||
36
+ c.type === 'assignment_pattern'
37
+ ).length;
38
+ }
39
+
40
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
41
+ if (node.type === 'function_declaration' || node.type === 'method_definition') {
42
+ return node.childForFieldName('name')?.text ?? null;
43
+ }
44
+ if (node.parent?.type === 'variable_declarator') {
45
+ return node.parent.childForFieldName('name')?.text ?? null;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export const singleOptionObject: Rule = {
51
+ name: 'single-option-object',
52
+ description: 'Flags function parameters that destructure an object with only one property',
53
+ severity: 'info',
54
+
55
+ check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
56
+ const findings: Finding[] = [];
57
+
58
+ const functions = findNodesMulti(tree.rootNode, [
59
+ 'function_declaration',
60
+ 'arrow_function',
61
+ 'function',
62
+ 'method_definition',
63
+ ]);
64
+
65
+ for (const fn of functions) {
66
+ const destructuredParams = getDestructuredParams(fn);
67
+
68
+ for (const param of destructuredParams) {
69
+ const propCount = getObjectPatternPropertyCount(param);
70
+
71
+ if (propCount === 1) {
72
+ // Skip if function has only this one parameter and it's a callback
73
+ const parent = fn.parent;
74
+ if (parent?.type === 'arguments' || parent?.type === 'call_expression') {
75
+ continue;
76
+ }
77
+
78
+ const name = getFunctionName(fn);
79
+ const label = name ? `Function '${name}'` : 'Function';
80
+
81
+ findings.push({
82
+ file: filePath,
83
+ line: param.startPosition.row + 1,
84
+ column: param.startPosition.column + 1,
85
+ endLine: param.endPosition.row + 1,
86
+ endColumn: param.endPosition.column + 1,
87
+ rule: 'single-option-object',
88
+ severity: 'info',
89
+ message: `${label} destructures an options object with only 1 property — use a plain parameter instead`,
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ return findings;
96
+ },
97
+ };
@@ -0,0 +1,184 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodesMulti } from '../parser';
3
+ import type { Finding, Rule } from '../types';
4
+
5
+ function getFunctionBody(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
6
+ return node.childForFieldName('body');
7
+ }
8
+
9
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
10
+ if (node.type === 'function_declaration' || node.type === 'method_definition') {
11
+ const nameNode = node.childForFieldName('name');
12
+ return nameNode?.text ?? null;
13
+ }
14
+ if (node.type === 'variable_declarator') {
15
+ const nameNode = node.childForFieldName('name');
16
+ return nameNode?.text ?? null;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function getStatements(body: Parser.SyntaxNode): Parser.SyntaxNode[] {
22
+ if (body.type === 'statement_block') {
23
+ return body.namedChildren;
24
+ }
25
+ return [];
26
+ }
27
+
28
+ const COMPLEX_ARG_TYPES = new Set([
29
+ 'arrow_function', 'function', 'object', 'array',
30
+ 'template_string', 'binary_expression', 'new_expression',
31
+ 'ternary_expression', 'conditional_expression',
32
+ 'unary_expression', 'update_expression', 'assignment_expression',
33
+ 'augmented_assignment_expression', 'yield_expression',
34
+ 'spread_element', 'regex', 'class',
35
+ ]);
36
+
37
+ function isSimpleNode(node: Parser.SyntaxNode): boolean {
38
+ if (node.type === 'identifier' || node.type === 'this' ||
39
+ node.type === 'string' || node.type === 'number' ||
40
+ node.type === 'true' || node.type === 'false' ||
41
+ node.type === 'null' || node.type === 'undefined') {
42
+ return true;
43
+ }
44
+ // Simple member_expression like a.b (not computed a[b])
45
+ if (node.type === 'member_expression') {
46
+ const computed = node.children.some(c => c.type === '[');
47
+ return !computed;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function hasOnlySimpleArguments(callExpr: Parser.SyntaxNode): boolean {
53
+ // Check if callee involves complex expressions (e.g. new Foo().method())
54
+ const callee = callExpr.childForFieldName('function');
55
+ if (callee?.type === 'member_expression') {
56
+ const object = callee.childForFieldName('object');
57
+ if (object && COMPLEX_ARG_TYPES.has(object.type)) return false;
58
+ // Also check for chained calls with complex args: foo(complex).bar(simple)
59
+ if (object?.type === 'call_expression') {
60
+ if (!hasOnlySimpleArguments(object)) return false;
61
+ }
62
+ }
63
+
64
+ const args = callExpr.childForFieldName('arguments');
65
+ if (!args) return true;
66
+
67
+ for (const arg of args.namedChildren) {
68
+ if (isSimpleNode(arg)) continue;
69
+ // Known complex types
70
+ if (COMPLEX_ARG_TYPES.has(arg.type)) return false;
71
+ // Anything else unexpected — be conservative, treat as complex
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ function extractCallExpression(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
78
+ if (node.type === 'call_expression') return node;
79
+ // Parenthesized expression wrapping a call
80
+ if (node.type === 'parenthesized_expression' && node.namedChildren.length === 1) {
81
+ return extractCallExpression(node.namedChildren[0]);
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function isSimpleDelegatingFunction(body: Parser.SyntaxNode): boolean {
87
+ // For arrow functions with expression body (no braces)
88
+ if (body.type === 'call_expression') {
89
+ return hasOnlySimpleArguments(body);
90
+ }
91
+
92
+ const stmts = getStatements(body);
93
+ if (stmts.length !== 1) return false;
94
+
95
+ const stmt = stmts[0];
96
+
97
+ // Single return statement with a function call
98
+ if (stmt.type === 'return_statement') {
99
+ const returnValue = stmt.namedChildren[0];
100
+ if (!returnValue) return false;
101
+ const call = extractCallExpression(returnValue);
102
+ if (!call) return false;
103
+ return hasOnlySimpleArguments(call);
104
+ }
105
+
106
+ // Single expression statement that is a function call (void wrapper)
107
+ if (stmt.type === 'expression_statement') {
108
+ const expr = stmt.namedChildren[0];
109
+ if (!expr) return false;
110
+ const call = extractCallExpression(expr);
111
+ if (!call) return false;
112
+ return hasOnlySimpleArguments(call);
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ function getParameterCount(node: Parser.SyntaxNode): number {
119
+ const params = node.childForFieldName('parameters');
120
+ if (!params) return 0;
121
+ return params.namedChildren.filter(c =>
122
+ c.type === 'identifier' ||
123
+ c.type === 'required_parameter' ||
124
+ c.type === 'optional_parameter' ||
125
+ c.type === 'rest_parameter' ||
126
+ c.type === 'assignment_pattern'
127
+ ).length;
128
+ }
129
+
130
+ export const singleUseWrapper: Rule = {
131
+ name: 'single-use-wrapper',
132
+ description: 'Flags functions that merely delegate to another function without adding logic',
133
+ severity: 'warning',
134
+
135
+ check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
136
+ const findings: Finding[] = [];
137
+
138
+ const functionNodes = findNodesMulti(tree.rootNode, [
139
+ 'function_declaration',
140
+ 'arrow_function',
141
+ 'function',
142
+ ]);
143
+
144
+ for (const fn of functionNodes) {
145
+ const body = getFunctionBody(fn);
146
+ if (!body) continue;
147
+
148
+ if (!isSimpleDelegatingFunction(body)) continue;
149
+
150
+ // Skip if function has more than 3 parameters (likely an intentional adapter)
151
+ if (getParameterCount(fn) > 3) continue;
152
+
153
+ // Skip anonymous functions passed as callbacks — those are idiomatic
154
+ if (fn.type === 'arrow_function' || fn.type === 'function') {
155
+ const parent = fn.parent;
156
+ if (parent?.type === 'arguments' || parent?.type === 'call_expression' ||
157
+ parent?.type === 'array' || parent?.type === 'pair') {
158
+ continue;
159
+ }
160
+ }
161
+
162
+ // Try to get the function name for the message
163
+ let name = getFunctionName(fn);
164
+ if (!name && fn.parent?.type === 'variable_declarator') {
165
+ name = getFunctionName(fn.parent);
166
+ }
167
+
168
+ const label = name ? `Function '${name}'` : 'Function';
169
+
170
+ findings.push({
171
+ file: filePath,
172
+ line: fn.startPosition.row + 1,
173
+ column: fn.startPosition.column + 1,
174
+ endLine: fn.endPosition.row + 1,
175
+ endColumn: fn.endPosition.column + 1,
176
+ rule: 'single-use-wrapper',
177
+ severity: 'warning',
178
+ message: `${label} only delegates to another function — consider inlining it`,
179
+ });
180
+ }
181
+
182
+ return findings;
183
+ },
184
+ };
@@ -0,0 +1,121 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodes, walkTree } from '../parser';
3
+ import type { Finding, Rule } from '../types';
4
+
5
+ const THROWING_CALL_INDICATORS = new Set([
6
+ 'fetch', 'require', 'import', 'eval',
7
+ 'JSON.parse', 'JSON.stringify',
8
+ 'parseInt', 'parseFloat',
9
+ 'decodeURI', 'decodeURIComponent',
10
+ 'atob', 'btoa',
11
+ ]);
12
+
13
+ function isAwaitExpression(node: Parser.SyntaxNode): boolean {
14
+ let found = false;
15
+ walkTree(node, (n) => {
16
+ if (n.type === 'await_expression') found = true;
17
+ });
18
+ return found;
19
+ }
20
+
21
+ function containsThrowStatement(node: Parser.SyntaxNode): boolean {
22
+ let found = false;
23
+ walkTree(node, (n) => {
24
+ if (n.type === 'throw_statement') found = true;
25
+ });
26
+ return found;
27
+ }
28
+
29
+ function containsFunctionCall(node: Parser.SyntaxNode): boolean {
30
+ let found = false;
31
+ walkTree(node, (n) => {
32
+ if (n.type === 'call_expression') found = true;
33
+ });
34
+ return found;
35
+ }
36
+
37
+ function isPropertyAccess(node: Parser.SyntaxNode): boolean {
38
+ let found = false;
39
+ walkTree(node, (n) => {
40
+ if (n.type === 'member_expression' || n.type === 'subscript_expression') found = true;
41
+ });
42
+ return found;
43
+ }
44
+
45
+ function catchBlockOnlyRethrows(catchClause: Parser.SyntaxNode): boolean {
46
+ const body = catchClause.childForFieldName('body');
47
+ if (!body) return false;
48
+
49
+ const statements = body.namedChildren;
50
+ if (statements.length !== 1) return false;
51
+
52
+ const stmt = statements[0];
53
+ if (stmt.type !== 'throw_statement') return false;
54
+
55
+ const thrown = stmt.namedChildren[0];
56
+ if (!thrown) return false;
57
+
58
+ const catchParam = catchClause.childForFieldName('parameter');
59
+ if (!catchParam) return false;
60
+
61
+ return thrown.text === catchParam.text;
62
+ }
63
+
64
+ export const unnecessaryTryCatch: Rule = {
65
+ name: 'unnecessary-try-catch',
66
+ description: 'Flags try-catch blocks that wrap code unlikely to throw',
67
+ severity: 'warning',
68
+
69
+ check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
70
+ const findings: Finding[] = [];
71
+ const tryStatements = findNodes(tree.rootNode, 'try_statement');
72
+
73
+ for (const tryNode of tryStatements) {
74
+ const body = tryNode.childForFieldName('body');
75
+ const handler = tryNode.childForFieldName('handler');
76
+ if (!body || !handler) continue;
77
+
78
+ // If catch block only rethrows, that's always useless
79
+ if (catchBlockOnlyRethrows(handler)) {
80
+ findings.push({
81
+ file: filePath,
82
+ line: tryNode.startPosition.row + 1,
83
+ column: tryNode.startPosition.column + 1,
84
+ endLine: tryNode.endPosition.row + 1,
85
+ endColumn: tryNode.endPosition.column + 1,
86
+ rule: 'unnecessary-try-catch',
87
+ severity: 'warning',
88
+ message: 'try-catch block catches an error only to rethrow it unchanged',
89
+ });
90
+ continue;
91
+ }
92
+
93
+ // Skip if the try body contains await (async operations can throw)
94
+ if (isAwaitExpression(body)) continue;
95
+
96
+ // Skip if try body contains throw statements
97
+ if (containsThrowStatement(body)) continue;
98
+
99
+ // Skip if try body contains function calls (they might throw)
100
+ if (containsFunctionCall(body)) continue;
101
+
102
+ // Skip if try body contains property access (might throw on null/undefined)
103
+ if (isPropertyAccess(body)) continue;
104
+
105
+ // If we get here, the try body only contains assignments, returns of
106
+ // literals/variables, or other purely synchronous non-throwing ops
107
+ findings.push({
108
+ file: filePath,
109
+ line: tryNode.startPosition.row + 1,
110
+ column: tryNode.startPosition.column + 1,
111
+ endLine: tryNode.endPosition.row + 1,
112
+ endColumn: tryNode.endPosition.column + 1,
113
+ rule: 'unnecessary-try-catch',
114
+ severity: 'warning',
115
+ message: 'try-catch wraps code that is unlikely to throw — consider removing the try-catch',
116
+ });
117
+ }
118
+
119
+ return findings;
120
+ },
121
+ };