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
package/src/errors.ts ADDED
@@ -0,0 +1,105 @@
1
+ import * as fs from 'fs';
2
+
3
+ export class DistyllError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public readonly hint?: string
7
+ ) {
8
+ super(message);
9
+ this.name = 'DistyllError';
10
+ }
11
+ }
12
+
13
+ export class NotAGitRepoError extends DistyllError {
14
+ constructor() {
15
+ super(
16
+ 'Not a git repository.',
17
+ 'Run this command from inside a git repo, or use "distyll scan" instead.'
18
+ );
19
+ this.name = 'NotAGitRepoError';
20
+ }
21
+ }
22
+
23
+ export class UnsupportedLanguageError extends DistyllError {
24
+ constructor(filePath: string) {
25
+ super(
26
+ `Unsupported language for file: ${filePath}`,
27
+ 'Distyll currently supports JavaScript, TypeScript, and Python files.'
28
+ );
29
+ this.name = 'UnsupportedLanguageError';
30
+ }
31
+ }
32
+
33
+ export class FileAccessError extends DistyllError {
34
+ constructor(filePath: string, reason: string) {
35
+ super(
36
+ `Cannot read file: ${filePath} (${reason})`,
37
+ 'Check file permissions and ensure the file exists.'
38
+ );
39
+ this.name = 'FileAccessError';
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if a buffer contains binary content (null bytes in first 1024 bytes).
45
+ */
46
+ export function isBinaryBuffer(buffer: Buffer): boolean {
47
+ const checkLength = Math.min(buffer.length, 1024);
48
+ for (let i = 0; i < checkLength; i++) {
49
+ if (buffer[i] === 0) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Safely read a source file, returning null for binary/empty/inaccessible files.
56
+ * When verbose is true, logs skip reasons to stderr.
57
+ */
58
+ export function safeReadFile(
59
+ filePath: string,
60
+ verbose = false
61
+ ): { source: string; buffer: Buffer } | null {
62
+ let buffer: Buffer;
63
+ try {
64
+ buffer = fs.readFileSync(filePath);
65
+ } catch (err) {
66
+ if (verbose) {
67
+ const code = (err as NodeJS.ErrnoException).code;
68
+ if (code === 'EACCES') {
69
+ console.error(` [skip] ${filePath}: permission denied`);
70
+ } else if (code === 'ENOENT') {
71
+ console.error(` [skip] ${filePath}: file not found`);
72
+ } else {
73
+ console.error(` [skip] ${filePath}: ${(err as Error).message}`);
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ if (buffer.length === 0) {
80
+ if (verbose) console.error(` [skip] ${filePath}: empty file`);
81
+ return null;
82
+ }
83
+
84
+ if (isBinaryBuffer(buffer)) {
85
+ if (verbose) console.error(` [skip] ${filePath}: binary file`);
86
+ return null;
87
+ }
88
+
89
+ return { source: buffer.toString('utf-8'), buffer };
90
+ }
91
+
92
+ /**
93
+ * Format an error for user-facing display.
94
+ */
95
+ export function formatError(err: unknown): string {
96
+ if (err instanceof DistyllError) {
97
+ let msg = `Error: ${err.message}`;
98
+ if (err.hint) msg += `\nHint: ${err.hint}`;
99
+ return msg;
100
+ }
101
+ if (err instanceof Error) {
102
+ return `Error: ${err.message}`;
103
+ }
104
+ return `Error: ${String(err)}`;
105
+ }
@@ -0,0 +1,214 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { parse, detectLanguage, walkTree, findNodes } from '../parser';
3
+ import { resolveFiles } from '../scanner';
4
+ import type { StyleProfile, Language } from '../types';
5
+ import * as fs from 'fs';
6
+
7
+ interface FileMetrics {
8
+ functionLengths: number[];
9
+ identifierNames: string[];
10
+ commentLines: number;
11
+ codeLines: number;
12
+ nestingDepths: number[];
13
+ tryCatchCount: number;
14
+ importCount: number;
15
+ loc: number;
16
+ }
17
+
18
+ function countLines(text: string): number {
19
+ if (text.length === 0) return 0;
20
+ let count = 1;
21
+ for (let i = 0; i < text.length; i++) {
22
+ if (text[i] === '\n') count++;
23
+ }
24
+ return count;
25
+ }
26
+
27
+ function getNestingDepth(node: Parser.SyntaxNode): number {
28
+ let depth = 0;
29
+ let current: Parser.SyntaxNode | null = node.parent;
30
+ const blockTypes = new Set([
31
+ 'statement_block', 'block', 'if_statement', 'for_statement',
32
+ 'while_statement', 'for_in_statement', 'switch_statement',
33
+ 'try_statement', 'with_statement',
34
+ ]);
35
+ while (current) {
36
+ if (blockTypes.has(current.type)) depth++;
37
+ current = current.parent;
38
+ }
39
+ return depth;
40
+ }
41
+
42
+ const FUNCTION_TYPES = new Set([
43
+ 'function_declaration', 'method_definition', 'arrow_function',
44
+ 'function', 'function_definition',
45
+ ]);
46
+
47
+ const IMPORT_TYPES = new Set([
48
+ 'import_statement', 'import_declaration',
49
+ 'import_from_statement',
50
+ ]);
51
+
52
+ function classifyName(name: string): 'camelCase' | 'snake_case' | 'PascalCase' | 'other' {
53
+ if (/^[A-Z][a-zA-Z0-9]*$/.test(name)) return 'PascalCase';
54
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)) return 'camelCase';
55
+ if (/^[a-z][a-z0-9_]*$/.test(name) && name.includes('_')) return 'snake_case';
56
+ if (/^[a-z][a-z0-9]*$/.test(name)) return 'camelCase'; // single-word lowercase defaults to camelCase
57
+ return 'other';
58
+ }
59
+
60
+ function analyzeFileAST(tree: Parser.Tree, source: string): FileMetrics {
61
+ const metrics: FileMetrics = {
62
+ functionLengths: [],
63
+ identifierNames: [],
64
+ commentLines: 0,
65
+ codeLines: 0,
66
+ nestingDepths: [],
67
+ tryCatchCount: 0,
68
+ importCount: 0,
69
+ loc: countLines(source),
70
+ };
71
+
72
+ walkTree(tree.rootNode, (node) => {
73
+ // Function lengths
74
+ if (FUNCTION_TYPES.has(node.type)) {
75
+ const body = node.childForFieldName('body');
76
+ if (body) {
77
+ const fnLines = countLines(body.text);
78
+ metrics.functionLengths.push(fnLines);
79
+ metrics.nestingDepths.push(getNestingDepth(body));
80
+ }
81
+ }
82
+
83
+ // Identifier names (function declarations and variable declarations)
84
+ if (node.type === 'function_declaration' || node.type === 'function_definition') {
85
+ const nameNode = node.childForFieldName('name');
86
+ if (nameNode) metrics.identifierNames.push(nameNode.text);
87
+ }
88
+ if (node.type === 'variable_declarator') {
89
+ const nameNode = node.childForFieldName('name');
90
+ if (nameNode && nameNode.type === 'identifier') {
91
+ metrics.identifierNames.push(nameNode.text);
92
+ }
93
+ }
94
+
95
+ // Comments
96
+ if (node.type === 'comment') {
97
+ metrics.commentLines += countLines(node.text);
98
+ }
99
+
100
+ // Try-catch
101
+ if (node.type === 'try_statement') {
102
+ metrics.tryCatchCount++;
103
+ }
104
+
105
+ // Imports
106
+ if (IMPORT_TYPES.has(node.type)) {
107
+ metrics.importCount++;
108
+ }
109
+ });
110
+
111
+ // Code lines = total lines minus comment lines (rough estimate)
112
+ metrics.codeLines = Math.max(1, metrics.loc - metrics.commentLines);
113
+
114
+ return metrics;
115
+ }
116
+
117
+ function median(values: number[]): number {
118
+ if (values.length === 0) return 0;
119
+ const sorted = [...values].sort((a, b) => a - b);
120
+ const mid = Math.floor(sorted.length / 2);
121
+ if (sorted.length % 2 === 0) {
122
+ return (sorted[mid - 1] + sorted[mid]) / 2;
123
+ }
124
+ return sorted[mid];
125
+ }
126
+
127
+ function average(values: number[]): number {
128
+ if (values.length === 0) return 0;
129
+ return values.reduce((a, b) => a + b, 0) / values.length;
130
+ }
131
+
132
+ export async function analyzeCodebase(paths: string[]): Promise<StyleProfile> {
133
+ const files = await resolveFiles(paths);
134
+ const allMetrics: FileMetrics[] = [];
135
+
136
+ for (const filePath of files) {
137
+ const language = detectLanguage(filePath);
138
+ if (!language) continue;
139
+
140
+ let source: string;
141
+ try {
142
+ const buffer = fs.readFileSync(filePath);
143
+ if (buffer.length === 0) continue;
144
+ // Skip binary files
145
+ const checkLength = Math.min(buffer.length, 1024);
146
+ let isBinary = false;
147
+ for (let i = 0; i < checkLength; i++) {
148
+ if (buffer[i] === 0) { isBinary = true; break; }
149
+ }
150
+ if (isBinary) continue;
151
+ source = buffer.toString('utf-8');
152
+ } catch {
153
+ continue;
154
+ }
155
+
156
+ let tree;
157
+ try {
158
+ tree = parse(source, language);
159
+ } catch {
160
+ continue;
161
+ }
162
+
163
+ allMetrics.push(analyzeFileAST(tree, source));
164
+ }
165
+
166
+ return buildProfile(allMetrics, files.length);
167
+ }
168
+
169
+ function buildProfile(allMetrics: FileMetrics[], fileCount: number): StyleProfile {
170
+ const allFunctionLengths = allMetrics.flatMap((m) => m.functionLengths);
171
+ const allIdentifiers = allMetrics.flatMap((m) => m.identifierNames);
172
+ const allNestingDepths = allMetrics.flatMap((m) => m.nestingDepths);
173
+
174
+ const totalCommentLines = allMetrics.reduce((s, m) => s + m.commentLines, 0);
175
+ const totalCodeLines = allMetrics.reduce((s, m) => s + m.codeLines, 0);
176
+ const totalTryCatch = allMetrics.reduce((s, m) => s + m.tryCatchCount, 0);
177
+ const totalLoc = allMetrics.reduce((s, m) => s + m.loc, 0);
178
+ const totalImports = allMetrics.reduce((s, m) => s + m.importCount, 0);
179
+
180
+ const namingConventions = { camelCase: 0, snake_case: 0, PascalCase: 0, other: 0 };
181
+ for (const name of allIdentifiers) {
182
+ const category = classifyName(name);
183
+ namingConventions[category]++;
184
+ }
185
+
186
+ // Normalize naming conventions to percentages
187
+ const totalNames = Math.max(1, allIdentifiers.length);
188
+ namingConventions.camelCase = Math.round((namingConventions.camelCase / totalNames) * 100);
189
+ namingConventions.snake_case = Math.round((namingConventions.snake_case / totalNames) * 100);
190
+ namingConventions.PascalCase = Math.round((namingConventions.PascalCase / totalNames) * 100);
191
+ namingConventions.other = Math.round((namingConventions.other / totalNames) * 100);
192
+
193
+ return {
194
+ generatedAt: new Date().toISOString(),
195
+ fileCount,
196
+ totalLoc,
197
+ metrics: {
198
+ medianFunctionLength: Math.round(median(allFunctionLengths) * 10) / 10,
199
+ averageFunctionLength: Math.round(average(allFunctionLengths) * 10) / 10,
200
+ maxFunctionLength: allFunctionLengths.length > 0 ? Math.max(...allFunctionLengths) : 0,
201
+ namingConventions,
202
+ commentToCodeRatio: totalCodeLines > 0
203
+ ? Math.round((totalCommentLines / totalCodeLines) * 100) / 100
204
+ : 0,
205
+ averageNestingDepth: Math.round(average(allNestingDepths) * 10) / 10,
206
+ tryCatchDensity: totalLoc > 0
207
+ ? Math.round((totalTryCatch / (totalLoc / 100)) * 100) / 100
208
+ : 0,
209
+ averageImportsPerFile: fileCount > 0
210
+ ? Math.round((totalImports / fileCount) * 10) / 10
211
+ : 0,
212
+ },
213
+ };
214
+ }
@@ -0,0 +1,93 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { parse, detectLanguage, walkTree } from '../parser';
3
+ import type { Finding, StyleProfile, Language } from '../types';
4
+ import * as fs from 'fs';
5
+
6
+ const FUNCTION_TYPES = new Set([
7
+ 'function_declaration', 'method_definition', 'arrow_function',
8
+ 'function', 'function_definition',
9
+ ]);
10
+
11
+ function countLines(text: string): number {
12
+ if (text.length === 0) return 0;
13
+ let count = 1;
14
+ for (let i = 0; i < text.length; i++) {
15
+ if (text[i] === '\n') count++;
16
+ }
17
+ return count;
18
+ }
19
+
20
+ export function compareFileToProfile(
21
+ filePath: string,
22
+ source: string,
23
+ tree: Parser.Tree,
24
+ profile: StyleProfile,
25
+ ): Finding[] {
26
+ const findings: Finding[] = [];
27
+ const { metrics } = profile;
28
+
29
+ // Check function lengths against profile
30
+ const maxAcceptable = Math.max(
31
+ metrics.averageFunctionLength * 4,
32
+ metrics.medianFunctionLength * 5,
33
+ 30, // absolute minimum threshold
34
+ );
35
+
36
+ walkTree(tree.rootNode, (node) => {
37
+ if (!FUNCTION_TYPES.has(node.type)) return;
38
+
39
+ const body = node.childForFieldName('body');
40
+ if (!body) return;
41
+
42
+ const fnLines = countLines(body.text);
43
+
44
+ if (fnLines > maxAcceptable) {
45
+ const nameNode = node.childForFieldName('name');
46
+ let parentName: string | undefined;
47
+ if (!nameNode && node.parent?.type === 'variable_declarator') {
48
+ const pn = node.parent.childForFieldName('name');
49
+ if (pn) parentName = pn.text;
50
+ }
51
+ const name = nameNode?.text ?? parentName ?? 'anonymous';
52
+
53
+ findings.push({
54
+ file: filePath,
55
+ line: node.startPosition.row + 1,
56
+ column: node.startPosition.column + 1,
57
+ endLine: node.endPosition.row + 1,
58
+ endColumn: node.endPosition.column + 1,
59
+ rule: 'style-function-length',
60
+ severity: 'warning',
61
+ message: `Function '${name}' is ${fnLines} lines — project median is ${metrics.medianFunctionLength}, average is ${metrics.averageFunctionLength}`,
62
+ });
63
+ }
64
+ });
65
+
66
+ // Check comment density against profile
67
+ let commentLines = 0;
68
+ let codeLines = 0;
69
+ walkTree(tree.rootNode, (node) => {
70
+ if (node.type === 'comment') {
71
+ commentLines += countLines(node.text);
72
+ }
73
+ });
74
+ const totalLines = countLines(source);
75
+ codeLines = Math.max(1, totalLines - commentLines);
76
+ const fileRatio = commentLines / codeLines;
77
+
78
+ // Flag if comment density is 3x+ the project average (overly commented, likely AI slop)
79
+ if (metrics.commentToCodeRatio > 0 && fileRatio > metrics.commentToCodeRatio * 3 && commentLines > 5) {
80
+ findings.push({
81
+ file: filePath,
82
+ line: 1,
83
+ column: 1,
84
+ endLine: 1,
85
+ endColumn: 1,
86
+ rule: 'style-comment-density',
87
+ severity: 'info',
88
+ message: `Comment density (${Math.round(fileRatio * 100)}%) is much higher than project average (${Math.round(metrics.commentToCodeRatio * 100)}%)`,
89
+ });
90
+ }
91
+
92
+ return findings;
93
+ }
@@ -0,0 +1,32 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type { StyleProfile } from '../types';
4
+
5
+ const PROFILE_DIR = '.distyll';
6
+ const PROFILE_FILE = 'profile.json';
7
+
8
+ export function getProfilePath(baseDir: string): string {
9
+ return path.join(baseDir, PROFILE_DIR, PROFILE_FILE);
10
+ }
11
+
12
+ export function saveProfile(profile: StyleProfile, baseDir: string): string {
13
+ const dir = path.join(baseDir, PROFILE_DIR);
14
+ if (!fs.existsSync(dir)) {
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ }
17
+ const filePath = getProfilePath(baseDir);
18
+ fs.writeFileSync(filePath, JSON.stringify(profile, null, 2) + '\n', 'utf-8');
19
+ return filePath;
20
+ }
21
+
22
+ export function loadProfile(baseDir: string): StyleProfile | null {
23
+ const filePath = getProfilePath(baseDir);
24
+ try {
25
+ const raw = fs.readFileSync(filePath, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (!parsed || !parsed.metrics) return null;
28
+ return parsed as StyleProfile;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
@@ -0,0 +1,58 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { Finding, FixSuggestion } from '../types';
3
+ import { fixUnnecessaryTryCatch } from './unnecessary-try-catch';
4
+ import { fixSingleUseWrapper } from './single-use-wrapper';
5
+ import { fixVerboseComment } from './verbose-comments';
6
+ import { fixUnusedImport } from './unused-imports';
7
+
8
+ type FixGenerator = (
9
+ tree: Parser.Tree,
10
+ source: string,
11
+ line: number,
12
+ context?: string,
13
+ ) => FixSuggestion | null;
14
+
15
+ const fixGenerators: Record<string, FixGenerator> = {
16
+ 'unnecessary-try-catch': fixUnnecessaryTryCatch,
17
+ 'single-use-wrapper': fixSingleUseWrapper,
18
+ 'verbose-comments': fixVerboseComment,
19
+ 'unused-imports': (tree, source, line, context) =>
20
+ fixUnusedImport(tree, source, line, context ?? ''),
21
+ };
22
+
23
+ /**
24
+ * Attempt to generate a fix suggestion for a finding.
25
+ * Returns the finding with the fix attached if successful.
26
+ */
27
+ export function attachFix(
28
+ finding: Finding,
29
+ tree: Parser.Tree,
30
+ source: string,
31
+ ): Finding {
32
+ const generator = fixGenerators[finding.rule];
33
+ if (!generator) return finding;
34
+
35
+ // Extract context from the finding message for unused-imports
36
+ let context: string | undefined;
37
+ if (finding.rule === 'unused-imports') {
38
+ const match = finding.message.match(/'([^']+)'/);
39
+ if (match) context = match[1];
40
+ }
41
+
42
+ const fix = generator(tree, source, finding.line, context);
43
+ if (fix) {
44
+ return { ...finding, fix };
45
+ }
46
+ return finding;
47
+ }
48
+
49
+ /**
50
+ * Attach fix suggestions to all findings for a file.
51
+ */
52
+ export function attachFixes(
53
+ findings: Finding[],
54
+ tree: Parser.Tree,
55
+ source: string,
56
+ ): Finding[] {
57
+ return findings.map((f) => attachFix(f, tree, source));
58
+ }
@@ -0,0 +1,60 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodesMulti, walkTree } from '../parser';
3
+ import type { FixSuggestion } from '../types';
4
+
5
+ /**
6
+ * Generates a fix for single-use wrapper functions: suggests inlining
7
+ * by showing the delegated call directly.
8
+ */
9
+ export function fixSingleUseWrapper(
10
+ tree: Parser.Tree,
11
+ source: string,
12
+ line: number,
13
+ ): FixSuggestion | null {
14
+ const functionNodes = findNodesMulti(tree.rootNode, [
15
+ 'function_declaration',
16
+ 'arrow_function',
17
+ 'function',
18
+ ]);
19
+
20
+ for (const fn of functionNodes) {
21
+ if (fn.startPosition.row + 1 !== line) continue;
22
+
23
+ const body = fn.childForFieldName('body');
24
+ if (!body) return null;
25
+
26
+ // For arrow functions with expression body (no braces)
27
+ if (body.type === 'call_expression') {
28
+ return {
29
+ description: `Inline the delegated call: ${body.text}`,
30
+ replacement: body.text,
31
+ };
32
+ }
33
+
34
+ // For block body with single return/expression statement
35
+ const stmts = body.type === 'statement_block' ? body.namedChildren : [];
36
+ if (stmts.length !== 1) return null;
37
+
38
+ const stmt = stmts[0];
39
+ if (stmt.type === 'return_statement') {
40
+ const returnValue = stmt.namedChildren[0];
41
+ if (returnValue) {
42
+ return {
43
+ description: `Inline the delegated call: ${returnValue.text}`,
44
+ replacement: returnValue.text,
45
+ };
46
+ }
47
+ }
48
+ if (stmt.type === 'expression_statement') {
49
+ const expr = stmt.namedChildren[0];
50
+ if (expr) {
51
+ return {
52
+ description: `Inline the delegated call: ${expr.text}`,
53
+ replacement: expr.text,
54
+ };
55
+ }
56
+ }
57
+ }
58
+
59
+ return null;
60
+ }
@@ -0,0 +1,43 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodes } from '../parser';
3
+ import type { FixSuggestion } from '../types';
4
+
5
+ /**
6
+ * Generates a fix for unnecessary try-catch: extracts the try body
7
+ * and removes the try-catch wrapper.
8
+ */
9
+ export function fixUnnecessaryTryCatch(
10
+ tree: Parser.Tree,
11
+ source: string,
12
+ line: number,
13
+ ): FixSuggestion | null {
14
+ const tryStatements = findNodes(tree.rootNode, 'try_statement');
15
+
16
+ for (const tryNode of tryStatements) {
17
+ // Match by line (1-indexed)
18
+ if (tryNode.startPosition.row + 1 !== line) continue;
19
+
20
+ const body = tryNode.childForFieldName('body');
21
+ if (!body) return null;
22
+
23
+ // Extract the body statements (strip the outer braces)
24
+ const bodyText = body.text;
25
+ // Remove leading { and trailing }
26
+ const inner = bodyText.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
27
+ // Dedent one level
28
+ const lines = inner.split('\n');
29
+ const dedented = lines
30
+ .map((l) => l.replace(/^ /, ''))
31
+ .join('\n')
32
+ .trim();
33
+
34
+ if (!dedented) return null;
35
+
36
+ return {
37
+ description: 'Remove try-catch wrapper and keep the body',
38
+ replacement: dedented,
39
+ };
40
+ }
41
+
42
+ return null;
43
+ }
@@ -0,0 +1,53 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodes } from '../parser';
3
+ import type { FixSuggestion } from '../types';
4
+
5
+ /**
6
+ * Generates a fix for unused imports: suggests removing the import line.
7
+ */
8
+ export function fixUnusedImport(
9
+ tree: Parser.Tree,
10
+ source: string,
11
+ line: number,
12
+ importName: string,
13
+ ): FixSuggestion | null {
14
+ const importStatements = findNodes(tree.rootNode, 'import_statement');
15
+
16
+ for (const importStmt of importStatements) {
17
+ // Check if this import statement contains the line
18
+ const stmtStartLine = importStmt.startPosition.row + 1;
19
+ const stmtEndLine = importStmt.endPosition.row + 1;
20
+ if (line < stmtStartLine || line > stmtEndLine) continue;
21
+
22
+ // Count how many named imports are in this statement
23
+ const importClause = importStmt.children.find((c) => c.type === 'import_clause');
24
+ if (!importClause) continue;
25
+
26
+ const namedImports = importClause.descendantsOfType('import_specifier');
27
+ const defaultImport = importClause.children.find(
28
+ (c) => c.type === 'identifier'
29
+ );
30
+ const namespaceImport = importClause.descendantsOfType('namespace_import');
31
+
32
+ const totalImportedNames =
33
+ namedImports.length +
34
+ (defaultImport ? 1 : 0) +
35
+ namespaceImport.length;
36
+
37
+ if (totalImportedNames <= 1) {
38
+ // Only one import in this statement — remove the entire line
39
+ return {
40
+ description: `Remove unused import '${importName}'`,
41
+ replacement: '', // delete entire import statement
42
+ };
43
+ }
44
+
45
+ // Multiple imports — suggest removing just this specifier
46
+ return {
47
+ description: `Remove unused import '${importName}' from this import statement`,
48
+ replacement: `Remove '${importName}' from the import list`,
49
+ };
50
+ }
51
+
52
+ return null;
53
+ }
@@ -0,0 +1,35 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { findNodes } from '../parser';
3
+ import type { FixSuggestion } from '../types';
4
+
5
+ /**
6
+ * Generates a fix for verbose comments: suggests removing the comment entirely.
7
+ */
8
+ export function fixVerboseComment(
9
+ tree: Parser.Tree,
10
+ source: string,
11
+ line: number,
12
+ ): FixSuggestion | null {
13
+ const comments = findNodes(tree.rootNode, 'comment');
14
+
15
+ for (const comment of comments) {
16
+ if (comment.startPosition.row + 1 !== line) continue;
17
+
18
+ // Provide the code without the comment
19
+ const lines = source.split('\n');
20
+ const result = lines
21
+ .filter((_, i) => {
22
+ const lineNum = i + 1;
23
+ return lineNum < comment.startPosition.row + 1 ||
24
+ lineNum > comment.endPosition.row + 1;
25
+ });
26
+
27
+ // Just suggest removal
28
+ return {
29
+ description: 'Remove this comment — it restates the code',
30
+ replacement: '', // empty = delete
31
+ };
32
+ }
33
+
34
+ return null;
35
+ }