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.
- package/CONTRIBUTING.md +159 -0
- package/POSTMORTEM.json +60 -0
- package/README.md +218 -0
- package/SETUP.md +79 -0
- package/action.yml +37 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +115 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +153 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/ci.d.ts +7 -0
- package/dist/commands/ci.d.ts.map +1 -0
- package/dist/commands/ci.js +101 -0
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/diff.d.ts +10 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +95 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/fingerprint.d.ts +2 -0
- package/dist/commands/fingerprint.d.ts.map +1 -0
- package/dist/commands/fingerprint.js +77 -0
- package/dist/commands/fingerprint.js.map +1 -0
- package/dist/commands/hook.d.ts +3 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +110 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +75 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +100 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +133 -0
- package/dist/errors.js.map +1 -0
- package/dist/fingerprint/analyzer.d.ts +3 -0
- package/dist/fingerprint/analyzer.d.ts.map +1 -0
- package/dist/fingerprint/analyzer.js +230 -0
- package/dist/fingerprint/analyzer.js.map +1 -0
- package/dist/fingerprint/comparator.d.ts +4 -0
- package/dist/fingerprint/comparator.d.ts.map +1 -0
- package/dist/fingerprint/comparator.js +78 -0
- package/dist/fingerprint/comparator.js.map +1 -0
- package/dist/fingerprint/profile.d.ts +5 -0
- package/dist/fingerprint/profile.d.ts.map +1 -0
- package/dist/fingerprint/profile.js +68 -0
- package/dist/fingerprint/profile.js.map +1 -0
- package/dist/fixes/index.d.ts +12 -0
- package/dist/fixes/index.d.ts.map +1 -0
- package/dist/fixes/index.js +42 -0
- package/dist/fixes/index.js.map +1 -0
- package/dist/fixes/single-use-wrapper.d.ts +8 -0
- package/dist/fixes/single-use-wrapper.d.ts.map +1 -0
- package/dist/fixes/single-use-wrapper.js +54 -0
- package/dist/fixes/single-use-wrapper.js.map +1 -0
- package/dist/fixes/unnecessary-try-catch.d.ts +8 -0
- package/dist/fixes/unnecessary-try-catch.d.ts.map +1 -0
- package/dist/fixes/unnecessary-try-catch.js +37 -0
- package/dist/fixes/unnecessary-try-catch.js.map +1 -0
- package/dist/fixes/unused-imports.d.ts +7 -0
- package/dist/fixes/unused-imports.d.ts.map +1 -0
- package/dist/fixes/unused-imports.js +41 -0
- package/dist/fixes/unused-imports.js.map +1 -0
- package/dist/fixes/verbose-comments.d.ts +7 -0
- package/dist/fixes/verbose-comments.d.ts.map +1 -0
- package/dist/fixes/verbose-comments.js +29 -0
- package/dist/fixes/verbose-comments.js.map +1 -0
- package/dist/formatter.d.ts +4 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +72 -0
- package/dist/formatter.js.map +1 -0
- package/dist/git.d.ts +22 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +130 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/languages/index.d.ts +8 -0
- package/dist/languages/index.d.ts.map +1 -0
- package/dist/languages/index.js +50 -0
- package/dist/languages/index.js.map +1 -0
- package/dist/languages/javascript.d.ts +6 -0
- package/dist/languages/javascript.d.ts.map +1 -0
- package/dist/languages/javascript.js +39 -0
- package/dist/languages/javascript.js.map +1 -0
- package/dist/languages/python.d.ts +6 -0
- package/dist/languages/python.d.ts.map +1 -0
- package/dist/languages/python.js +50 -0
- package/dist/languages/python.js.map +1 -0
- package/dist/parser.d.ts +8 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +55 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporters/github.d.ts +4 -0
- package/dist/reporters/github.d.ts.map +1 -0
- package/dist/reporters/github.js +70 -0
- package/dist/reporters/github.js.map +1 -0
- package/dist/reporters/terminal.d.ts +4 -0
- package/dist/reporters/terminal.d.ts.map +1 -0
- package/dist/reporters/terminal.js +59 -0
- package/dist/reporters/terminal.js.map +1 -0
- package/dist/rules/dead-code-paths.d.ts +3 -0
- package/dist/rules/dead-code-paths.d.ts.map +1 -0
- package/dist/rules/dead-code-paths.js +57 -0
- package/dist/rules/dead-code-paths.js.map +1 -0
- package/dist/rules/excessive-comments.d.ts +3 -0
- package/dist/rules/excessive-comments.d.ts.map +1 -0
- package/dist/rules/excessive-comments.js +86 -0
- package/dist/rules/excessive-comments.js.map +1 -0
- package/dist/rules/hallucinated-imports.d.ts +3 -0
- package/dist/rules/hallucinated-imports.d.ts.map +1 -0
- package/dist/rules/hallucinated-imports.js +228 -0
- package/dist/rules/hallucinated-imports.js.map +1 -0
- package/dist/rules/index.d.ts +4 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +34 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/magic-values.d.ts +3 -0
- package/dist/rules/magic-values.d.ts.map +1 -0
- package/dist/rules/magic-values.js +168 -0
- package/dist/rules/magic-values.js.map +1 -0
- package/dist/rules/near-duplicate-functions.d.ts +3 -0
- package/dist/rules/near-duplicate-functions.d.ts.map +1 -0
- package/dist/rules/near-duplicate-functions.js +78 -0
- package/dist/rules/near-duplicate-functions.js.map +1 -0
- package/dist/rules/over-defensive-nulls.d.ts +3 -0
- package/dist/rules/over-defensive-nulls.d.ts.map +1 -0
- package/dist/rules/over-defensive-nulls.js +129 -0
- package/dist/rules/over-defensive-nulls.js.map +1 -0
- package/dist/rules/redundant-else-return.d.ts +3 -0
- package/dist/rules/redundant-else-return.d.ts.map +1 -0
- package/dist/rules/redundant-else-return.js +57 -0
- package/dist/rules/redundant-else-return.js.map +1 -0
- package/dist/rules/single-option-object.d.ts +3 -0
- package/dist/rules/single-option-object.d.ts.map +1 -0
- package/dist/rules/single-option-object.js +88 -0
- package/dist/rules/single-option-object.js.map +1 -0
- package/dist/rules/single-use-wrapper.d.ts +3 -0
- package/dist/rules/single-use-wrapper.d.ts.map +1 -0
- package/dist/rules/single-use-wrapper.js +172 -0
- package/dist/rules/single-use-wrapper.js.map +1 -0
- package/dist/rules/unnecessary-try-catch.d.ts +3 -0
- package/dist/rules/unnecessary-try-catch.d.ts.map +1 -0
- package/dist/rules/unnecessary-try-catch.js +116 -0
- package/dist/rules/unnecessary-try-catch.js.map +1 -0
- package/dist/rules/unused-imports.d.ts +3 -0
- package/dist/rules/unused-imports.d.ts.map +1 -0
- package/dist/rules/unused-imports.js +103 -0
- package/dist/rules/unused-imports.js.map +1 -0
- package/dist/rules/verbose-comments.d.ts +3 -0
- package/dist/rules/verbose-comments.d.ts.map +1 -0
- package/dist/rules/verbose-comments.js +100 -0
- package/dist/rules/verbose-comments.js.map +1 -0
- package/dist/scanner.d.ts +11 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +196 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scorer.d.ts +3 -0
- package/dist/scorer.d.ts.map +1 -0
- package/dist/scorer.js +23 -0
- package/dist/scorer.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/hn_post.md +13 -0
- package/marketing/COMPETITIVE_ANALYSIS.md +62 -0
- package/marketing/EMAIL_ANNOUNCEMENT.md +91 -0
- package/marketing/LANDING_PAGE_COPY.md +123 -0
- package/marketing/LAUNCH_POST.md +68 -0
- package/marketing/PRODUCT_HUNT.md +39 -0
- package/marketing/TWITTER_THREAD.md +70 -0
- package/package.json +44 -0
- package/producthunt.md +52 -0
- package/reddit_post.md +39 -0
- package/site/favicon.svg +10 -0
- package/site/index.html +281 -0
- package/site/script.js +82 -0
- package/site/style.css +516 -0
- package/src/cache.ts +114 -0
- package/src/cli.ts +169 -0
- package/src/commands/ci.ts +111 -0
- package/src/commands/diff.ts +108 -0
- package/src/commands/fingerprint.ts +47 -0
- package/src/commands/hook.ts +85 -0
- package/src/commands/init.ts +42 -0
- package/src/config.ts +75 -0
- package/src/errors.ts +105 -0
- package/src/fingerprint/analyzer.ts +214 -0
- package/src/fingerprint/comparator.ts +93 -0
- package/src/fingerprint/profile.ts +32 -0
- package/src/fixes/index.ts +58 -0
- package/src/fixes/single-use-wrapper.ts +60 -0
- package/src/fixes/unnecessary-try-catch.ts +43 -0
- package/src/fixes/unused-imports.ts +53 -0
- package/src/fixes/verbose-comments.ts +35 -0
- package/src/formatter.ts +79 -0
- package/src/git.ts +115 -0
- package/src/index.ts +15 -0
- package/src/languages/index.ts +50 -0
- package/src/languages/javascript.ts +36 -0
- package/src/languages/python.ts +47 -0
- package/src/parser.ts +52 -0
- package/src/reporters/github.ts +75 -0
- package/src/reporters/terminal.ts +67 -0
- package/src/rules/dead-code-paths.ts +62 -0
- package/src/rules/excessive-comments.ts +94 -0
- package/src/rules/hallucinated-imports.ts +195 -0
- package/src/rules/index.ts +32 -0
- package/src/rules/magic-values.ts +167 -0
- package/src/rules/near-duplicate-functions.ts +89 -0
- package/src/rules/over-defensive-nulls.ts +137 -0
- package/src/rules/redundant-else-return.ts +61 -0
- package/src/rules/single-option-object.ts +97 -0
- package/src/rules/single-use-wrapper.ts +184 -0
- package/src/rules/unnecessary-try-catch.ts +121 -0
- package/src/rules/unused-imports.ts +115 -0
- package/src/rules/verbose-comments.ts +105 -0
- package/src/scanner.ts +184 -0
- package/src/scorer.ts +26 -0
- package/src/types.ts +70 -0
- package/tests/commands/diff.test.ts +107 -0
- package/tests/config.test.ts +69 -0
- package/tests/e2e.test.ts +163 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/fingerprint/analyzer.test.ts +131 -0
- package/tests/fixes/unnecessary-try-catch.test.ts +62 -0
- package/tests/git.test.ts +79 -0
- package/tests/rules/hallucinated-imports.test.ts +59 -0
- package/tests/rules/near-duplicate-functions.test.ts +90 -0
- package/tests/rules/unnecessary-try-catch.test.ts +81 -0
- package/tests/scanner.test.ts +88 -0
- package/tsconfig.json +20 -0
- package/twitter_thread.md +46 -0
- 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
|
+
}
|