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/cli.ts ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { scanPaths } from './scanner';
4
+ import { formatText, formatJson } from './formatter';
5
+ import { runDiff } from './commands/diff';
6
+ import { installHook, uninstallHook } from './commands/hook';
7
+ import { runCI } from './commands/ci';
8
+ import { runInit } from './commands/init';
9
+ import { runFingerprint } from './commands/fingerprint';
10
+ import { formatError } from './errors';
11
+
12
+ const program = new Command();
13
+
14
+ program
15
+ .name('distyll')
16
+ .description('Catch AI-generated code slop before it ships')
17
+ .version('0.1.0');
18
+
19
+ program
20
+ .command('scan')
21
+ .description('Scan files or directories for AI code anti-patterns')
22
+ .argument('<paths...>', 'Files or directories to scan')
23
+ .option('-f, --format <format>', 'Output format: text or json', 'text')
24
+ .option('-t, --threshold <number>', 'Exit with code 1 if slop score exceeds this value', parseInt)
25
+ .option('-s, --style', 'Compare against project style profile (run "distyll fingerprint" first)')
26
+ .option('-v, --verbose', 'Show detailed parsing and rule execution info')
27
+ .option('-q, --quiet', 'Only output the slop score number (useful for scripting)')
28
+ .action(async (paths: string[], options: {
29
+ format: string;
30
+ threshold?: number;
31
+ style?: boolean;
32
+ verbose?: boolean;
33
+ quiet?: boolean;
34
+ }) => {
35
+ try {
36
+ const summary = await scanPaths(paths, {
37
+ style: options.style,
38
+ verbose: options.verbose,
39
+ });
40
+
41
+ if (options.quiet) {
42
+ console.log(summary.score);
43
+ } else if (options.format === 'json') {
44
+ console.log(formatJson(summary));
45
+ } else {
46
+ console.log(formatText(summary));
47
+ }
48
+
49
+ if (options.threshold !== undefined && summary.score > options.threshold) {
50
+ if (!options.quiet) {
51
+ console.error(
52
+ `\nSlop score ${summary.score} exceeds threshold of ${options.threshold}`
53
+ );
54
+ }
55
+ process.exit(1);
56
+ }
57
+ } catch (err) {
58
+ console.error(formatError(err));
59
+ process.exit(1);
60
+ }
61
+ });
62
+
63
+ program
64
+ .command('diff')
65
+ .description('Scan only changed lines in a git diff')
66
+ .option('-s, --staged', 'Scan staged changes only')
67
+ .option('-r, --ref <ref>', 'Git ref to diff against (e.g., HEAD~1, main)')
68
+ .option('-f, --format <format>', 'Output format: text or json', 'text')
69
+ .option('-t, --threshold <number>', 'Exit with code 1 if slop score exceeds this value', parseInt)
70
+ .option('-v, --verbose', 'Show detailed parsing and rule execution info')
71
+ .option('-q, --quiet', 'Only output the slop score number')
72
+ .action(async (options: {
73
+ staged?: boolean;
74
+ ref?: string;
75
+ format?: string;
76
+ threshold?: number;
77
+ verbose?: boolean;
78
+ quiet?: boolean;
79
+ }) => {
80
+ try {
81
+ await runDiff({
82
+ staged: options.staged,
83
+ ref: options.ref,
84
+ format: options.format as 'text' | 'json',
85
+ threshold: options.threshold,
86
+ verbose: options.verbose,
87
+ quiet: options.quiet,
88
+ });
89
+ } catch (err) {
90
+ console.error(formatError(err));
91
+ process.exit(1);
92
+ }
93
+ });
94
+
95
+ const hookCmd = program
96
+ .command('hook')
97
+ .description('Manage git hooks');
98
+
99
+ hookCmd
100
+ .command('install')
101
+ .description('Install a pre-commit hook that runs distyll on staged changes')
102
+ .option('-t, --threshold <number>', 'Slop score threshold for blocking commits', parseInt)
103
+ .action(async (options: { threshold?: number }) => {
104
+ try {
105
+ await installHook(options.threshold);
106
+ } catch (err) {
107
+ console.error(formatError(err));
108
+ process.exit(1);
109
+ }
110
+ });
111
+
112
+ hookCmd
113
+ .command('uninstall')
114
+ .description('Remove the distyll pre-commit hook')
115
+ .action(async () => {
116
+ try {
117
+ await uninstallHook();
118
+ } catch (err) {
119
+ console.error(formatError(err));
120
+ process.exit(1);
121
+ }
122
+ });
123
+
124
+ program
125
+ .command('ci')
126
+ .description('Run in CI mode with GitHub Actions annotations')
127
+ .option('-r, --ref <ref>', 'Git ref to diff against (default: HEAD~1)')
128
+ .option('-f, --format <format>', 'Output format: annotations, json, or summary', 'annotations')
129
+ .option('-t, --threshold <number>', 'Exit with code 1 if slop score exceeds this value', parseInt)
130
+ .action(async (options: { ref?: string; format?: string; threshold?: number }) => {
131
+ try {
132
+ await runCI({
133
+ ref: options.ref,
134
+ format: options.format as 'annotations' | 'json' | 'summary',
135
+ threshold: options.threshold,
136
+ });
137
+ } catch (err) {
138
+ console.error(formatError(err));
139
+ process.exit(1);
140
+ }
141
+ });
142
+
143
+ program
144
+ .command('init')
145
+ .description('Initialize distyll configuration in the current directory')
146
+ .argument('[dir]', 'Directory to initialize (defaults to current directory)')
147
+ .action((dir?: string) => {
148
+ try {
149
+ runInit(dir);
150
+ } catch (err) {
151
+ console.error(formatError(err));
152
+ process.exit(1);
153
+ }
154
+ });
155
+
156
+ program
157
+ .command('fingerprint')
158
+ .description('Analyze codebase to build a style profile for comparison')
159
+ .argument('[dir]', 'Directory to analyze (defaults to current directory)', '.')
160
+ .action(async (dir: string) => {
161
+ try {
162
+ await runFingerprint(dir);
163
+ } catch (err) {
164
+ console.error(formatError(err));
165
+ process.exit(1);
166
+ }
167
+ });
168
+
169
+ program.parse();
@@ -0,0 +1,111 @@
1
+ import { getGitContext, getDiffFiles, getCurrentBranch, getHeadRef } from '../git';
2
+ import { scanFile } from '../scanner';
3
+ import { computeScore } from '../scorer';
4
+ import { loadConfig } from '../config';
5
+ import { allRules } from '../rules';
6
+ import { saveScore } from '../cache';
7
+ import { formatGitHubAnnotations, formatGitHubSummary } from '../reporters/github';
8
+ import { formatJson } from '../formatter';
9
+ import { detectLanguage } from '../parser';
10
+ import type { ScanResult, Rule } from '../types';
11
+
12
+ export interface CIOptions {
13
+ ref?: string;
14
+ format?: 'annotations' | 'json' | 'summary';
15
+ threshold?: number;
16
+ }
17
+
18
+ export async function runCI(options: CIOptions): Promise<void> {
19
+ const ctx = await getGitContext();
20
+ const config = loadConfig(ctx.root);
21
+
22
+ let activeRules: Rule[] = [...allRules];
23
+ if (config.rules) {
24
+ activeRules = activeRules.filter((rule) => config.rules?.[rule.name] !== 'off');
25
+ activeRules = activeRules.map((rule) => {
26
+ const setting = config.rules?.[rule.name];
27
+ if (setting === 'warn' || setting === 'error') {
28
+ return { ...rule, severity: setting === 'warn' ? 'warning' : 'error' };
29
+ }
30
+ return rule;
31
+ });
32
+ }
33
+
34
+ // Determine diff ref: use provided ref, or diff against default branch
35
+ const diffRef = options.ref ?? 'HEAD~1';
36
+ const hunks = await getDiffFiles(ctx, { ref: diffRef });
37
+
38
+ if (hunks.length === 0) {
39
+ if (options.format === 'json') {
40
+ console.log(JSON.stringify({ score: 0, totalFindings: 0, files: [] }, null, 2));
41
+ } else if (options.format === 'summary') {
42
+ console.log('## Distyll Slop Report\n\n**Slop Score: 🟢 0/100**\n\nNo changes to analyze.');
43
+ } else {
44
+ console.log('No changes detected.');
45
+ }
46
+ return;
47
+ }
48
+
49
+ const results: ScanResult[] = [];
50
+
51
+ for (const hunk of hunks) {
52
+ const language = detectLanguage(hunk.file);
53
+ if (!language) continue;
54
+
55
+ const result = scanFile(hunk.file, activeRules);
56
+ if (!result) continue;
57
+
58
+ const addedLineSet = new Set(hunk.addedLines);
59
+ const filteredFindings = result.findings.filter((f) => addedLineSet.has(f.line));
60
+
61
+ if (filteredFindings.length > 0) {
62
+ results.push({
63
+ file: result.file,
64
+ findings: filteredFindings,
65
+ loc: result.loc,
66
+ });
67
+ }
68
+ }
69
+
70
+ const summary = computeScore(results);
71
+
72
+ // Cache score
73
+ try {
74
+ const branch = await getCurrentBranch(ctx);
75
+ const ref = await getHeadRef(ctx);
76
+ saveScore(ctx.root, summary.score, summary.totalFindings, results.length, ref ?? undefined, branch);
77
+ } catch {
78
+ // Best-effort
79
+ }
80
+
81
+ // Output based on format
82
+ const format = options.format ?? 'annotations';
83
+
84
+ if (format === 'json') {
85
+ console.log(formatJson(summary));
86
+ } else if (format === 'summary') {
87
+ console.log(formatGitHubSummary(summary));
88
+ } else {
89
+ // annotations (default for CI)
90
+ const annotations = formatGitHubAnnotations(summary);
91
+ if (annotations) console.log(annotations);
92
+
93
+ // Also write a step summary if GITHUB_STEP_SUMMARY is set
94
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY;
95
+ if (summaryFile) {
96
+ const fs = require('fs');
97
+ try {
98
+ fs.appendFileSync(summaryFile, formatGitHubSummary(summary) + '\n');
99
+ } catch {
100
+ // Best-effort
101
+ }
102
+ }
103
+
104
+ console.log(`\nSlop Score: ${summary.score}/100 (${summary.totalFindings} findings)`);
105
+ }
106
+
107
+ if (options.threshold !== undefined && summary.score > options.threshold) {
108
+ console.error(`\nSlop score ${summary.score} exceeds threshold of ${options.threshold}`);
109
+ process.exit(1);
110
+ }
111
+ }
@@ -0,0 +1,108 @@
1
+ import { getGitContext, getDiffFiles, getCurrentBranch, getHeadRef } from '../git';
2
+ import { scanFile } from '../scanner';
3
+ import { computeScore } from '../scorer';
4
+ import { loadConfig } from '../config';
5
+ import { allRules } from '../rules';
6
+ import { saveScore, getTrend } from '../cache';
7
+ import { formatTerminalReport } from '../reporters/terminal';
8
+ import { formatJson } from '../formatter';
9
+ import { detectLanguage } from '../parser';
10
+ import type { ScanResult, Rule } from '../types';
11
+
12
+ export interface DiffOptions {
13
+ staged?: boolean;
14
+ ref?: string;
15
+ format?: 'text' | 'json';
16
+ threshold?: number;
17
+ verbose?: boolean;
18
+ quiet?: boolean;
19
+ }
20
+
21
+ export async function runDiff(options: DiffOptions): Promise<void> {
22
+ const ctx = await getGitContext();
23
+ const config = loadConfig(ctx.root);
24
+
25
+ let activeRules: Rule[] = [...allRules];
26
+ if (config.rules) {
27
+ activeRules = activeRules.filter((rule) => config.rules?.[rule.name] !== 'off');
28
+ activeRules = activeRules.map((rule) => {
29
+ const setting = config.rules?.[rule.name];
30
+ if (setting === 'warn' || setting === 'error') {
31
+ return { ...rule, severity: setting === 'warn' ? 'warning' : 'error' };
32
+ }
33
+ return rule;
34
+ });
35
+ }
36
+
37
+ const hunks = await getDiffFiles(ctx, { staged: options.staged, ref: options.ref });
38
+
39
+ if (hunks.length === 0) {
40
+ if (options.quiet) {
41
+ console.log(0);
42
+ } else if (options.format === 'json') {
43
+ console.log(JSON.stringify({ score: 0, totalFindings: 0, files: [] }, null, 2));
44
+ } else {
45
+ console.log('No changes detected.');
46
+ }
47
+ return;
48
+ }
49
+
50
+ if (options.verbose) {
51
+ console.error(`Scanning ${hunks.length} changed file(s)...`);
52
+ }
53
+
54
+ const results: ScanResult[] = [];
55
+
56
+ for (const hunk of hunks) {
57
+ const language = detectLanguage(hunk.file);
58
+ if (!language) continue;
59
+
60
+ const result = scanFile(hunk.file, activeRules, null, options.verbose);
61
+ if (!result) continue;
62
+
63
+ const addedLineSet = new Set(hunk.addedLines);
64
+ const filteredFindings = result.findings.filter((f) => addedLineSet.has(f.line));
65
+
66
+ if (filteredFindings.length > 0) {
67
+ results.push({
68
+ file: result.file,
69
+ findings: filteredFindings,
70
+ loc: result.loc,
71
+ });
72
+ }
73
+ }
74
+
75
+ const summary = computeScore(results);
76
+
77
+ // Cache the score
78
+ try {
79
+ const branch = await getCurrentBranch(ctx);
80
+ const ref = await getHeadRef(ctx);
81
+ saveScore(ctx.root, summary.score, summary.totalFindings, results.length, ref ?? undefined, branch);
82
+ } catch {
83
+ // Caching is best-effort
84
+ }
85
+
86
+ // Get trend info
87
+ let trend;
88
+ try {
89
+ trend = getTrend(ctx.root, summary.score);
90
+ } catch {
91
+ // Trend is best-effort
92
+ }
93
+
94
+ if (options.quiet) {
95
+ console.log(summary.score);
96
+ } else if (options.format === 'json') {
97
+ console.log(formatJson(summary));
98
+ } else {
99
+ console.log(formatTerminalReport(summary, trend));
100
+ }
101
+
102
+ if (options.threshold !== undefined && summary.score > options.threshold) {
103
+ if (!options.quiet) {
104
+ console.error(`\nSlop score ${summary.score} exceeds threshold of ${options.threshold}`);
105
+ }
106
+ process.exit(1);
107
+ }
108
+ }
@@ -0,0 +1,47 @@
1
+ import chalk from 'chalk';
2
+ import * as path from 'path';
3
+ import { analyzeCodebase } from '../fingerprint/analyzer';
4
+ import { saveProfile } from '../fingerprint/profile';
5
+
6
+ export async function runFingerprint(dir: string): Promise<void> {
7
+ const targetDir = path.resolve(dir);
8
+ console.log(chalk.gray(`Analyzing codebase in ${targetDir}...`));
9
+
10
+ const profile = await analyzeCodebase([targetDir]);
11
+
12
+ if (profile.fileCount === 0) {
13
+ console.error(
14
+ 'No supported files found to analyze. Ensure the directory contains .js, .ts, .tsx, or .py files.'
15
+ );
16
+ process.exit(1);
17
+ }
18
+
19
+ const savedPath = saveProfile(profile, targetDir);
20
+
21
+ console.log('');
22
+ console.log(chalk.green('Style profile generated successfully.'));
23
+ console.log(chalk.gray(`Saved to: ${savedPath}`));
24
+ console.log('');
25
+ console.log(chalk.bold('Project Style Summary:'));
26
+ console.log(` Files analyzed: ${profile.fileCount}`);
27
+ console.log(` Total LOC: ${profile.totalLoc}`);
28
+ console.log(` Median function length: ${profile.metrics.medianFunctionLength} lines`);
29
+ console.log(` Avg function length: ${profile.metrics.averageFunctionLength} lines`);
30
+ console.log(` Max function length: ${profile.metrics.maxFunctionLength} lines`);
31
+ console.log(` Comment/code ratio: ${Math.round(profile.metrics.commentToCodeRatio * 100)}%`);
32
+ console.log(` Avg nesting depth: ${profile.metrics.averageNestingDepth}`);
33
+ console.log(` Try-catch density: ${profile.metrics.tryCatchDensity} per 100 LOC`);
34
+ console.log(` Avg imports/file: ${profile.metrics.averageImportsPerFile}`);
35
+ console.log('');
36
+
37
+ const nc = profile.metrics.namingConventions;
38
+ console.log(chalk.bold('Naming Conventions:'));
39
+ console.log(` camelCase: ${nc.camelCase}%`);
40
+ console.log(` snake_case: ${nc.snake_case}%`);
41
+ console.log(` PascalCase: ${nc.PascalCase}%`);
42
+ console.log(` other: ${nc.other}%`);
43
+ console.log('');
44
+ console.log(
45
+ chalk.gray('Run "distyll scan --style <path>" to compare new code against this profile.')
46
+ );
47
+ }
@@ -0,0 +1,85 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { getGitContext } from '../git';
4
+
5
+ const HOOK_SCRIPT = `#!/bin/sh
6
+ # Distyll pre-commit hook — blocks commits with high slop scores
7
+ # Installed by: distyll hook install
8
+ # Remove by: distyll hook uninstall
9
+
10
+ npx distyll diff --staged --threshold 70
11
+ `;
12
+
13
+ export async function installHook(threshold?: number): Promise<void> {
14
+ const ctx = await getGitContext();
15
+ const hooksDir = path.join(ctx.root, '.git', 'hooks');
16
+
17
+ if (!fs.existsSync(hooksDir)) {
18
+ fs.mkdirSync(hooksDir, { recursive: true });
19
+ }
20
+
21
+ const hookPath = path.join(hooksDir, 'pre-commit');
22
+ const actualThreshold = threshold ?? 70;
23
+
24
+ const script = `#!/bin/sh
25
+ # Distyll pre-commit hook — blocks commits with high slop scores
26
+ # Installed by: distyll hook install
27
+ # Remove by: distyll hook uninstall
28
+
29
+ npx distyll diff --staged --threshold ${actualThreshold}
30
+ `;
31
+
32
+ // Check if a pre-commit hook already exists
33
+ if (fs.existsSync(hookPath)) {
34
+ const existing = fs.readFileSync(hookPath, 'utf-8');
35
+ if (existing.includes('distyll')) {
36
+ // Update existing distyll hook
37
+ fs.writeFileSync(hookPath, script, { mode: 0o755 });
38
+ console.log(`Updated pre-commit hook (threshold: ${actualThreshold}).`);
39
+ return;
40
+ }
41
+ // Non-distyll hook exists — append
42
+ const combined = existing.trimEnd() + '\n\n' + script;
43
+ fs.writeFileSync(hookPath, combined, { mode: 0o755 });
44
+ console.log(`Appended distyll to existing pre-commit hook (threshold: ${actualThreshold}).`);
45
+ return;
46
+ }
47
+
48
+ fs.writeFileSync(hookPath, script, { mode: 0o755 });
49
+ console.log(`Installed pre-commit hook (threshold: ${actualThreshold}).`);
50
+ }
51
+
52
+ export async function uninstallHook(): Promise<void> {
53
+ const ctx = await getGitContext();
54
+ const hookPath = path.join(ctx.root, '.git', 'hooks', 'pre-commit');
55
+
56
+ if (!fs.existsSync(hookPath)) {
57
+ console.log('No pre-commit hook found.');
58
+ return;
59
+ }
60
+
61
+ const content = fs.readFileSync(hookPath, 'utf-8');
62
+ if (!content.includes('distyll')) {
63
+ console.log('Pre-commit hook exists but was not installed by distyll.');
64
+ return;
65
+ }
66
+
67
+ // If the hook is entirely distyll, remove the file
68
+ const lines = content.split('\n');
69
+ const nonDistyllLines = lines.filter(
70
+ (line) => !line.includes('distyll') && !line.includes('Distyll') && line.trim() !== ''
71
+ );
72
+
73
+ // Only shebang and blank lines remain — it's all distyll
74
+ if (nonDistyllLines.length <= 1) {
75
+ fs.unlinkSync(hookPath);
76
+ console.log('Removed pre-commit hook.');
77
+ } else {
78
+ // Remove distyll section
79
+ const cleaned = lines
80
+ .filter((line) => !line.includes('distyll') && !line.includes('Distyll'))
81
+ .join('\n');
82
+ fs.writeFileSync(hookPath, cleaned, { mode: 0o755 });
83
+ console.log('Removed distyll from pre-commit hook.');
84
+ }
85
+ }
@@ -0,0 +1,42 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ const DEFAULT_CONFIG = {
5
+ rules: {},
6
+ threshold: 70,
7
+ ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/vendor/**'],
8
+ };
9
+
10
+ export function runInit(dir?: string): void {
11
+ const targetDir = dir ?? process.cwd();
12
+ const configPath = path.join(targetDir, '.distyll.json');
13
+ const gitignorePath = path.join(targetDir, '.gitignore');
14
+ const distyllDir = path.join(targetDir, '.distyll');
15
+
16
+ // Create config file
17
+ if (fs.existsSync(configPath)) {
18
+ console.log('.distyll.json already exists — skipping.');
19
+ } else {
20
+ fs.writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n', 'utf-8');
21
+ console.log('Created .distyll.json');
22
+ }
23
+
24
+ // Create .distyll/ directory
25
+ if (!fs.existsSync(distyllDir)) {
26
+ fs.mkdirSync(distyllDir, { recursive: true });
27
+ }
28
+
29
+ // Add .distyll/ to .gitignore
30
+ if (fs.existsSync(gitignorePath)) {
31
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
32
+ if (!content.includes('.distyll/')) {
33
+ fs.appendFileSync(gitignorePath, '\n# Distyll cache\n.distyll/\n');
34
+ console.log('Added .distyll/ to .gitignore');
35
+ }
36
+ } else {
37
+ fs.writeFileSync(gitignorePath, '# Distyll cache\n.distyll/\n', 'utf-8');
38
+ console.log('Created .gitignore with .distyll/ entry');
39
+ }
40
+
41
+ console.log('\nDistyll initialized. Run "distyll scan ." to check your codebase.');
42
+ }
package/src/config.ts ADDED
@@ -0,0 +1,75 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ export interface DistyllConfig {
5
+ rules?: Record<string, 'off' | 'warn' | 'error'>;
6
+ threshold?: number;
7
+ ignore?: string[];
8
+ }
9
+
10
+ const CONFIG_FILENAME = '.distyll.json';
11
+
12
+ export function loadConfig(startDir?: string): DistyllConfig {
13
+ if (!startDir) return {};
14
+
15
+ const configPath = findConfigFile(startDir);
16
+ if (!configPath) return {};
17
+
18
+ try {
19
+ const raw = fs.readFileSync(configPath, 'utf-8');
20
+ const parsed = JSON.parse(raw);
21
+ return validateConfig(parsed);
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ function findConfigFile(startDir: string): string | null {
28
+ let dir = startDir;
29
+
30
+ // If startDir is a file, use its parent directory
31
+ try {
32
+ if (fs.statSync(dir).isFile()) {
33
+ dir = path.dirname(dir);
34
+ }
35
+ } catch {
36
+ return null;
37
+ }
38
+
39
+ // Walk up the directory tree looking for .distyll.json
40
+ for (let i = 0; i < 50; i++) {
41
+ const candidate = path.join(dir, CONFIG_FILENAME);
42
+ if (fs.existsSync(candidate)) return candidate;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function validateConfig(raw: unknown): DistyllConfig {
51
+ if (typeof raw !== 'object' || raw === null) return {};
52
+
53
+ const obj = raw as Record<string, unknown>;
54
+ const config: DistyllConfig = {};
55
+
56
+ if (obj.rules && typeof obj.rules === 'object' && !Array.isArray(obj.rules)) {
57
+ const rules: Record<string, 'off' | 'warn' | 'error'> = {};
58
+ for (const [key, val] of Object.entries(obj.rules as Record<string, unknown>)) {
59
+ if (val === 'off' || val === 'warn' || val === 'error') {
60
+ rules[key] = val;
61
+ }
62
+ }
63
+ config.rules = rules;
64
+ }
65
+
66
+ if (typeof obj.threshold === 'number' && obj.threshold >= 0 && obj.threshold <= 100) {
67
+ config.threshold = obj.threshold;
68
+ }
69
+
70
+ if (Array.isArray(obj.ignore)) {
71
+ config.ignore = obj.ignore.filter((item): item is string => typeof item === 'string');
72
+ }
73
+
74
+ return config;
75
+ }