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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodes, walkTree } from '../parser';
|
|
3
|
+
import type { Finding, Rule } from '../types';
|
|
4
|
+
|
|
5
|
+
function getImportedNames(importNode: Parser.SyntaxNode): Array<{ name: string; node: Parser.SyntaxNode }> {
|
|
6
|
+
const names: Array<{ name: string; node: Parser.SyntaxNode }> = [];
|
|
7
|
+
|
|
8
|
+
walkTree(importNode, (node) => {
|
|
9
|
+
// import Foo from '...' (default import)
|
|
10
|
+
if (node.type === 'identifier' && node.parent?.type === 'import_clause') {
|
|
11
|
+
names.push({ name: node.text, node });
|
|
12
|
+
}
|
|
13
|
+
// import { Foo } from '...' or import { Foo as Bar } from '...'
|
|
14
|
+
if (node.type === 'import_specifier') {
|
|
15
|
+
const alias = node.childForFieldName('alias');
|
|
16
|
+
const nameNode = alias ?? node.childForFieldName('name');
|
|
17
|
+
if (nameNode) {
|
|
18
|
+
names.push({ name: nameNode.text, node: nameNode });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// import * as Foo from '...'
|
|
22
|
+
if (node.type === 'namespace_import') {
|
|
23
|
+
const nameNode = node.namedChildren.find((c) => c.type === 'identifier');
|
|
24
|
+
if (nameNode) {
|
|
25
|
+
names.push({ name: nameNode.text, node: nameNode });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return names;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectAllIdentifiers(root: Parser.SyntaxNode, excludeImports: Parser.SyntaxNode[]): Set<string> {
|
|
34
|
+
const excludeSet = new Set(excludeImports.map((n) => n.id));
|
|
35
|
+
const identifiers = new Set<string>();
|
|
36
|
+
|
|
37
|
+
walkTree(root, (node) => {
|
|
38
|
+
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier' ||
|
|
39
|
+
node.type === 'shorthand_property_identifier_pattern') {
|
|
40
|
+
// Skip nodes that are part of import declarations
|
|
41
|
+
let parent: Parser.SyntaxNode | null = node;
|
|
42
|
+
let isInImport = false;
|
|
43
|
+
while (parent) {
|
|
44
|
+
if (parent.type === 'import_statement' || parent.type === 'import_declaration') {
|
|
45
|
+
isInImport = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
parent = parent.parent;
|
|
49
|
+
}
|
|
50
|
+
if (!isInImport) {
|
|
51
|
+
identifiers.add(node.text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Also check type references for TypeScript
|
|
55
|
+
if (node.type === 'type_identifier') {
|
|
56
|
+
let parent: Parser.SyntaxNode | null = node;
|
|
57
|
+
let isInImport = false;
|
|
58
|
+
while (parent) {
|
|
59
|
+
if (parent.type === 'import_statement' || parent.type === 'import_declaration') {
|
|
60
|
+
isInImport = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
parent = parent.parent;
|
|
64
|
+
}
|
|
65
|
+
if (!isInImport) {
|
|
66
|
+
identifiers.add(node.text);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return identifiers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const unusedImports: Rule = {
|
|
75
|
+
name: 'unused-imports',
|
|
76
|
+
description: 'Flags imported identifiers that are never used in the file',
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
|
|
79
|
+
check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
|
|
80
|
+
const findings: Finding[] = [];
|
|
81
|
+
const importStatements = findNodes(tree.rootNode, 'import_statement');
|
|
82
|
+
|
|
83
|
+
if (importStatements.length === 0) return findings;
|
|
84
|
+
|
|
85
|
+
// Collect all identifiers used outside of imports
|
|
86
|
+
const usedIdentifiers = collectAllIdentifiers(tree.rootNode, importStatements);
|
|
87
|
+
|
|
88
|
+
for (const importStmt of importStatements) {
|
|
89
|
+
// Skip side-effect imports: import './foo' or import 'module'
|
|
90
|
+
const hasClause = importStmt.children.some(
|
|
91
|
+
(c) => c.type === 'import_clause'
|
|
92
|
+
);
|
|
93
|
+
if (!hasClause) continue;
|
|
94
|
+
|
|
95
|
+
const importedNames = getImportedNames(importStmt);
|
|
96
|
+
|
|
97
|
+
for (const { name, node } of importedNames) {
|
|
98
|
+
if (!usedIdentifiers.has(name)) {
|
|
99
|
+
findings.push({
|
|
100
|
+
file: filePath,
|
|
101
|
+
line: node.startPosition.row + 1,
|
|
102
|
+
column: node.startPosition.column + 1,
|
|
103
|
+
endLine: node.endPosition.row + 1,
|
|
104
|
+
endColumn: node.endPosition.column + 1,
|
|
105
|
+
rule: 'unused-imports',
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
message: `'${name}' is imported but never used`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return findings;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodes } from '../parser';
|
|
3
|
+
import type { Finding, Rule } from '../types';
|
|
4
|
+
|
|
5
|
+
function tokenize(text: string): string[] {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9_$]+/g, ' ')
|
|
9
|
+
.trim()
|
|
10
|
+
.split(/\s+/)
|
|
11
|
+
.filter((t) => t.length > 1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function jaccardSimilarity(a: string[], b: string[]): number {
|
|
15
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
16
|
+
const setA = new Set(a);
|
|
17
|
+
const setB = new Set(b);
|
|
18
|
+
let intersection = 0;
|
|
19
|
+
for (const item of setA) {
|
|
20
|
+
if (setB.has(item)) intersection++;
|
|
21
|
+
}
|
|
22
|
+
const union = new Set([...setA, ...setB]).size;
|
|
23
|
+
return union === 0 ? 0 : intersection / union;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripCommentSyntax(text: string): string {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/^\/\/\s*/, '')
|
|
29
|
+
.replace(/^\/\*+\s*/, '')
|
|
30
|
+
.replace(/\s*\*+\/$/, '')
|
|
31
|
+
.replace(/^\s*\*\s?/gm, '')
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getNextSiblingCode(node: Parser.SyntaxNode): string | null {
|
|
36
|
+
let sibling = node.nextNamedSibling;
|
|
37
|
+
// Skip over other comments
|
|
38
|
+
while (sibling && sibling.type === 'comment') {
|
|
39
|
+
sibling = sibling.nextNamedSibling;
|
|
40
|
+
}
|
|
41
|
+
if (!sibling) return null;
|
|
42
|
+
// Get just the first line of the sibling code for comparison
|
|
43
|
+
const text = sibling.text;
|
|
44
|
+
const firstLine = text.split('\n')[0];
|
|
45
|
+
return firstLine || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TRIVIAL_COMMENTS = new Set([
|
|
49
|
+
'todo', 'fixme', 'hack', 'note', 'bug', 'xxx',
|
|
50
|
+
'eslint-disable', 'eslint-enable', 'ts-ignore', 'ts-expect-error',
|
|
51
|
+
'prettier-ignore', 'istanbul ignore', 'c8 ignore',
|
|
52
|
+
'@ts-ignore', '@ts-expect-error', '@ts-nocheck',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function isTrivialOrDirective(commentText: string): boolean {
|
|
56
|
+
const lower = commentText.toLowerCase().trim();
|
|
57
|
+
for (const directive of TRIVIAL_COMMENTS) {
|
|
58
|
+
if (lower.startsWith(directive)) return true;
|
|
59
|
+
}
|
|
60
|
+
// Skip very short comments (likely meaningful labels)
|
|
61
|
+
if (lower.length < 10) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const verboseComments: Rule = {
|
|
66
|
+
name: 'verbose-comments',
|
|
67
|
+
description: 'Flags comments that restate the code they describe',
|
|
68
|
+
severity: 'info',
|
|
69
|
+
|
|
70
|
+
check(tree: Parser.Tree, source: string, filePath: string): Finding[] {
|
|
71
|
+
const findings: Finding[] = [];
|
|
72
|
+
const comments = findNodes(tree.rootNode, 'comment');
|
|
73
|
+
|
|
74
|
+
for (const comment of comments) {
|
|
75
|
+
const raw = stripCommentSyntax(comment.text);
|
|
76
|
+
if (isTrivialOrDirective(raw)) continue;
|
|
77
|
+
|
|
78
|
+
const nextCode = getNextSiblingCode(comment);
|
|
79
|
+
if (!nextCode) continue;
|
|
80
|
+
|
|
81
|
+
const commentTokens = tokenize(raw);
|
|
82
|
+
const codeTokens = tokenize(nextCode);
|
|
83
|
+
|
|
84
|
+
if (commentTokens.length < 3) continue;
|
|
85
|
+
|
|
86
|
+
const similarity = jaccardSimilarity(commentTokens, codeTokens);
|
|
87
|
+
|
|
88
|
+
// Only flag if >80% similar — very conservative threshold
|
|
89
|
+
if (similarity >= 0.8) {
|
|
90
|
+
findings.push({
|
|
91
|
+
file: filePath,
|
|
92
|
+
line: comment.startPosition.row + 1,
|
|
93
|
+
column: comment.startPosition.column + 1,
|
|
94
|
+
endLine: comment.endPosition.row + 1,
|
|
95
|
+
endColumn: comment.endPosition.column + 1,
|
|
96
|
+
rule: 'verbose-comments',
|
|
97
|
+
severity: 'info',
|
|
98
|
+
message: `Comment restates the code (${Math.round(similarity * 100)}% similar) — consider removing it`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return findings;
|
|
104
|
+
},
|
|
105
|
+
};
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import fg from 'fast-glob';
|
|
3
|
+
import { parse, detectLanguage } from './parser';
|
|
4
|
+
import { allRules } from './rules';
|
|
5
|
+
import { computeScore } from './scorer';
|
|
6
|
+
import { loadConfig } from './config';
|
|
7
|
+
import { getSupportedExtensions } from './languages';
|
|
8
|
+
import { attachFixes } from './fixes';
|
|
9
|
+
import { loadProfile } from './fingerprint/profile';
|
|
10
|
+
import { compareFileToProfile } from './fingerprint/comparator';
|
|
11
|
+
import { safeReadFile } from './errors';
|
|
12
|
+
import type { ScanResult, ScanSummary, Rule, StyleProfile } from './types';
|
|
13
|
+
|
|
14
|
+
function countLines(source: string): number {
|
|
15
|
+
if (source.length === 0) return 0;
|
|
16
|
+
let count = 1;
|
|
17
|
+
for (let i = 0; i < source.length; i++) {
|
|
18
|
+
if (source[i] === '\n') count++;
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScanPathsOptions {
|
|
24
|
+
rules?: Rule[];
|
|
25
|
+
configDir?: string;
|
|
26
|
+
style?: boolean;
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function scanPaths(paths: string[], options?: Rule[] | ScanPathsOptions): Promise<ScanSummary> {
|
|
31
|
+
let activeRules: Rule[] | undefined;
|
|
32
|
+
let configDir: string | undefined;
|
|
33
|
+
let useStyle = false;
|
|
34
|
+
let verbose = false;
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(options)) {
|
|
37
|
+
activeRules = options;
|
|
38
|
+
} else if (options && !Array.isArray(options)) {
|
|
39
|
+
activeRules = options.rules;
|
|
40
|
+
configDir = options.configDir;
|
|
41
|
+
useStyle = options.style ?? false;
|
|
42
|
+
verbose = options.verbose ?? false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const config = loadConfig(configDir ?? (paths.length > 0 ? paths[0] : process.cwd()));
|
|
46
|
+
let rules = activeRules ?? allRules;
|
|
47
|
+
|
|
48
|
+
if (config.rules) {
|
|
49
|
+
rules = rules.filter((rule) => {
|
|
50
|
+
const setting = config.rules?.[rule.name];
|
|
51
|
+
return setting !== 'off';
|
|
52
|
+
});
|
|
53
|
+
rules = rules.map((rule) => {
|
|
54
|
+
const setting = config.rules?.[rule.name];
|
|
55
|
+
if (setting === 'warn' || setting === 'error') {
|
|
56
|
+
return { ...rule, severity: setting === 'warn' ? 'warning' : 'error' };
|
|
57
|
+
}
|
|
58
|
+
return rule;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let profile: StyleProfile | null = null;
|
|
63
|
+
if (useStyle) {
|
|
64
|
+
const profileDir = configDir ?? (paths.length > 0 ? paths[0] : process.cwd());
|
|
65
|
+
profile = loadProfile(profileDir);
|
|
66
|
+
if (!profile) {
|
|
67
|
+
profile = loadProfile(process.cwd());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const files = await resolveFiles(paths, config.ignore);
|
|
72
|
+
|
|
73
|
+
if (verbose) {
|
|
74
|
+
console.error(`Scanning ${files.length} file(s) with ${rules.length} rule(s)...`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const results: ScanResult[] = [];
|
|
78
|
+
|
|
79
|
+
for (const filePath of files) {
|
|
80
|
+
const startTime = verbose ? performance.now() : 0;
|
|
81
|
+
const result = scanFile(filePath, rules, profile, verbose);
|
|
82
|
+
|
|
83
|
+
if (verbose && result) {
|
|
84
|
+
const elapsed = (performance.now() - startTime).toFixed(1);
|
|
85
|
+
console.error(` ${filePath}: ${result.findings.length} finding(s) in ${elapsed}ms`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result) {
|
|
89
|
+
results.push(result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return computeScore(results);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function resolveFiles(paths: string[], extraIgnore?: string[]): Promise<string[]> {
|
|
97
|
+
const files: string[] = [];
|
|
98
|
+
const supportedExts = getSupportedExtensions();
|
|
99
|
+
|
|
100
|
+
const ignorePatterns = [
|
|
101
|
+
'**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**', '**/vendor/**',
|
|
102
|
+
...(extraIgnore ?? []),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
let stat: fs.Stats;
|
|
107
|
+
try {
|
|
108
|
+
stat = fs.statSync(p);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (stat.isFile()) {
|
|
114
|
+
const lang = detectLanguage(p);
|
|
115
|
+
if (lang) files.push(p);
|
|
116
|
+
} else if (stat.isDirectory()) {
|
|
117
|
+
const pattern = `**/*.{${supportedExts.join(',')}}`;
|
|
118
|
+
const found = await fg(pattern, {
|
|
119
|
+
cwd: p,
|
|
120
|
+
absolute: true,
|
|
121
|
+
ignore: ignorePatterns,
|
|
122
|
+
});
|
|
123
|
+
files.push(...found);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return files.sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function scanFile(
|
|
131
|
+
filePath: string,
|
|
132
|
+
rules?: Rule[],
|
|
133
|
+
profile?: StyleProfile | null,
|
|
134
|
+
verbose = false
|
|
135
|
+
): ScanResult | null {
|
|
136
|
+
const activeRules = rules ?? allRules;
|
|
137
|
+
const language = detectLanguage(filePath);
|
|
138
|
+
|
|
139
|
+
if (!language) {
|
|
140
|
+
if (verbose) console.error(` [skip] ${filePath}: unsupported language`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fileData = safeReadFile(filePath, verbose);
|
|
145
|
+
if (!fileData) return null;
|
|
146
|
+
|
|
147
|
+
const { source } = fileData;
|
|
148
|
+
const loc = countLines(source);
|
|
149
|
+
|
|
150
|
+
let tree;
|
|
151
|
+
try {
|
|
152
|
+
tree = parse(source, language);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (verbose) {
|
|
155
|
+
console.error(` [skip] ${filePath}: parse error — ${(err as Error).message}`);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let findings = activeRules.flatMap((rule) => {
|
|
161
|
+
try {
|
|
162
|
+
const ruleStart = verbose ? performance.now() : 0;
|
|
163
|
+
const ruleFindings = rule.check(tree, source, filePath);
|
|
164
|
+
if (verbose) {
|
|
165
|
+
const elapsed = (performance.now() - ruleStart).toFixed(1);
|
|
166
|
+
if (ruleFindings.length > 0) {
|
|
167
|
+
console.error(` [rule] ${rule.name}: ${ruleFindings.length} finding(s) (${elapsed}ms)`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return ruleFindings;
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
findings = attachFixes(findings, tree, source);
|
|
177
|
+
|
|
178
|
+
if (profile) {
|
|
179
|
+
const styleFindings = compareFileToProfile(filePath, source, tree, profile);
|
|
180
|
+
findings.push(...styleFindings);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { file: filePath, findings, loc };
|
|
184
|
+
}
|
package/src/scorer.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ScanResult, ScanSummary, Severity } from './types';
|
|
2
|
+
|
|
3
|
+
const SEVERITY_WEIGHTS: Record<Severity, number> = {
|
|
4
|
+
info: 1,
|
|
5
|
+
warning: 3,
|
|
6
|
+
error: 5,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function computeScore(results: ScanResult[]): ScanSummary {
|
|
10
|
+
const totalLoc = results.reduce((sum, r) => sum + r.loc, 0);
|
|
11
|
+
const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
|
|
12
|
+
|
|
13
|
+
if (totalLoc === 0 || totalFindings === 0) {
|
|
14
|
+
return { results, totalFindings: 0, score: 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const weightedSum = results.reduce((sum, r) => {
|
|
18
|
+
return sum + r.findings.reduce((fSum, f) => fSum + SEVERITY_WEIGHTS[f.severity], 0);
|
|
19
|
+
}, 0);
|
|
20
|
+
|
|
21
|
+
// Normalize: weighted findings per 100 lines of code, capped at 100
|
|
22
|
+
const rawScore = (weightedSum / totalLoc) * 100;
|
|
23
|
+
const score = Math.min(100, Math.round(rawScore));
|
|
24
|
+
|
|
25
|
+
return { results, totalFindings, score };
|
|
26
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
|
|
3
|
+
export type Severity = 'info' | 'warning' | 'error';
|
|
4
|
+
|
|
5
|
+
export interface FixSuggestion {
|
|
6
|
+
description: string;
|
|
7
|
+
replacement: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Finding {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
column: number;
|
|
14
|
+
endLine: number;
|
|
15
|
+
endColumn: number;
|
|
16
|
+
rule: string;
|
|
17
|
+
severity: Severity;
|
|
18
|
+
message: string;
|
|
19
|
+
snippet?: string;
|
|
20
|
+
fix?: FixSuggestion;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Rule {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
severity: Severity;
|
|
27
|
+
check(tree: Parser.Tree, source: string, filePath: string): Finding[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScanResult {
|
|
31
|
+
file: string;
|
|
32
|
+
findings: Finding[];
|
|
33
|
+
loc: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScanSummary {
|
|
37
|
+
results: ScanResult[];
|
|
38
|
+
totalFindings: number;
|
|
39
|
+
score: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Language = 'javascript' | 'typescript' | 'tsx' | 'python';
|
|
43
|
+
|
|
44
|
+
export interface ScanOptions {
|
|
45
|
+
paths: string[];
|
|
46
|
+
format?: 'text' | 'json';
|
|
47
|
+
threshold?: number;
|
|
48
|
+
style?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StyleProfile {
|
|
52
|
+
generatedAt: string;
|
|
53
|
+
fileCount: number;
|
|
54
|
+
totalLoc: number;
|
|
55
|
+
metrics: {
|
|
56
|
+
medianFunctionLength: number;
|
|
57
|
+
averageFunctionLength: number;
|
|
58
|
+
maxFunctionLength: number;
|
|
59
|
+
namingConventions: {
|
|
60
|
+
camelCase: number;
|
|
61
|
+
snake_case: number;
|
|
62
|
+
PascalCase: number;
|
|
63
|
+
other: number;
|
|
64
|
+
};
|
|
65
|
+
commentToCodeRatio: number;
|
|
66
|
+
averageNestingDepth: number;
|
|
67
|
+
tryCatchDensity: number;
|
|
68
|
+
averageImportsPerFile: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
describe('diff command (integration)', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'distyll-diff-test-'));
|
|
12
|
+
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
|
|
13
|
+
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
|
|
14
|
+
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should detect slop in changed files via CLI', () => {
|
|
22
|
+
// Create initial commit
|
|
23
|
+
const filePath = path.join(tmpDir, 'test.js');
|
|
24
|
+
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
25
|
+
execSync('git add . && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
|
|
26
|
+
|
|
27
|
+
// Add sloppy code
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
filePath,
|
|
30
|
+
`const x = 1;
|
|
31
|
+
function wrapper(y) { return y; }
|
|
32
|
+
`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Run distyll diff (unstaged changes)
|
|
36
|
+
const distyllBin = path.resolve(__dirname, '..', '..', 'dist', 'cli.js');
|
|
37
|
+
let output: string;
|
|
38
|
+
try {
|
|
39
|
+
output = execSync(`node ${distyllBin} diff`, {
|
|
40
|
+
cwd: tmpDir,
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
});
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
// May exit with non-zero if threshold exceeded
|
|
46
|
+
output = err.stdout || '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Should produce some output (either findings or "No changes")
|
|
50
|
+
expect(typeof output).toBe('string');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle repo with no changes gracefully', () => {
|
|
54
|
+
const filePath = path.join(tmpDir, 'test.js');
|
|
55
|
+
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
56
|
+
execSync('git add . && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
|
|
57
|
+
|
|
58
|
+
const distyllBin = path.resolve(__dirname, '..', '..', 'dist', 'cli.js');
|
|
59
|
+
const output = execSync(`node ${distyllBin} diff`, {
|
|
60
|
+
cwd: tmpDir,
|
|
61
|
+
encoding: 'utf-8',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(output).toContain('No changes');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should scan staged changes with --staged flag', () => {
|
|
68
|
+
const filePath = path.join(tmpDir, 'test.js');
|
|
69
|
+
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
70
|
+
execSync('git add . && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
|
|
71
|
+
|
|
72
|
+
// Add and stage new content
|
|
73
|
+
fs.writeFileSync(filePath, 'const x = 1;\nfunction wrapper(y) { return y; }\n');
|
|
74
|
+
execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
|
|
75
|
+
|
|
76
|
+
const distyllBin = path.resolve(__dirname, '..', '..', 'dist', 'cli.js');
|
|
77
|
+
let output: string;
|
|
78
|
+
try {
|
|
79
|
+
output = execSync(`node ${distyllBin} diff --staged`, {
|
|
80
|
+
cwd: tmpDir,
|
|
81
|
+
encoding: 'utf-8',
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
output = err.stdout || '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(typeof output).toBe('string');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should support --format json', () => {
|
|
92
|
+
const filePath = path.join(tmpDir, 'test.js');
|
|
93
|
+
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
94
|
+
execSync('git add . && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
|
|
95
|
+
|
|
96
|
+
// No changes — should get empty JSON
|
|
97
|
+
const distyllBin = path.resolve(__dirname, '..', '..', 'dist', 'cli.js');
|
|
98
|
+
const output = execSync(`node ${distyllBin} diff --format json`, {
|
|
99
|
+
cwd: tmpDir,
|
|
100
|
+
encoding: 'utf-8',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const parsed = JSON.parse(output);
|
|
104
|
+
expect(parsed).toHaveProperty('score', 0);
|
|
105
|
+
expect(parsed).toHaveProperty('totalFindings', 0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { loadConfig } from '../src/config';
|
|
6
|
+
|
|
7
|
+
function createTempConfig(config: object): string {
|
|
8
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'distyll-config-'));
|
|
9
|
+
fs.writeFileSync(path.join(dir, '.distyll.json'), JSON.stringify(config));
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('config', () => {
|
|
14
|
+
it('returns empty config when no file exists', () => {
|
|
15
|
+
const config = loadConfig('/tmp/nonexistent-dir-12345');
|
|
16
|
+
expect(config).toEqual({});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('loads rules from .distyll.json', () => {
|
|
20
|
+
const dir = createTempConfig({
|
|
21
|
+
rules: {
|
|
22
|
+
'verbose-comments': 'off',
|
|
23
|
+
'unused-imports': 'error',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const config = loadConfig(dir);
|
|
27
|
+
expect(config.rules?.['verbose-comments']).toBe('off');
|
|
28
|
+
expect(config.rules?.['unused-imports']).toBe('error');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('loads threshold from config', () => {
|
|
32
|
+
const dir = createTempConfig({ threshold: 42 });
|
|
33
|
+
const config = loadConfig(dir);
|
|
34
|
+
expect(config.threshold).toBe(42);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('loads ignore patterns from config', () => {
|
|
38
|
+
const dir = createTempConfig({ ignore: ['**/generated/**', '**/*.min.js'] });
|
|
39
|
+
const config = loadConfig(dir);
|
|
40
|
+
expect(config.ignore).toEqual(['**/generated/**', '**/*.min.js']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('ignores invalid rule values', () => {
|
|
44
|
+
const dir = createTempConfig({
|
|
45
|
+
rules: {
|
|
46
|
+
'good-rule': 'warn',
|
|
47
|
+
'bad-rule': 'invalid-value',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const config = loadConfig(dir);
|
|
51
|
+
expect(config.rules?.['good-rule']).toBe('warn');
|
|
52
|
+
expect(config.rules?.['bad-rule']).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('ignores invalid JSON gracefully', () => {
|
|
56
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'distyll-config-'));
|
|
57
|
+
fs.writeFileSync(path.join(dir, '.distyll.json'), 'not json {{{');
|
|
58
|
+
const config = loadConfig(dir);
|
|
59
|
+
expect(config).toEqual({});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('walks up directory tree to find config', () => {
|
|
63
|
+
const parentDir = createTempConfig({ threshold: 75 });
|
|
64
|
+
const childDir = path.join(parentDir, 'src', 'lib');
|
|
65
|
+
fs.mkdirSync(childDir, { recursive: true });
|
|
66
|
+
const config = loadConfig(childDir);
|
|
67
|
+
expect(config.threshold).toBe(75);
|
|
68
|
+
});
|
|
69
|
+
});
|