@eduardbar/drift 0.9.0 → 1.0.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/.github/workflows/publish-vscode.yml +76 -0
- package/AGENTS.md +30 -12
- package/CHANGELOG.md +9 -0
- package/README.md +273 -168
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +4 -38
- package/dist/analyzer.js +85 -1510
- package/dist/cli.js +47 -4
- package/dist/config.js +1 -1
- package/dist/fix.d.ts +13 -0
- package/dist/fix.js +120 -0
- package/dist/git/blame.d.ts +22 -0
- package/dist/git/blame.js +227 -0
- package/dist/git/helpers.d.ts +36 -0
- package/dist/git/helpers.js +152 -0
- package/dist/git/trend.d.ts +21 -0
- package/dist/git/trend.js +80 -0
- package/dist/git.d.ts +0 -4
- package/dist/git.js +2 -2
- package/dist/report.js +620 -293
- package/dist/rules/phase0-basic.d.ts +11 -0
- package/dist/rules/phase0-basic.js +176 -0
- package/dist/rules/phase1-complexity.d.ts +31 -0
- package/dist/rules/phase1-complexity.js +277 -0
- package/dist/rules/phase2-crossfile.d.ts +27 -0
- package/dist/rules/phase2-crossfile.js +122 -0
- package/dist/rules/phase3-arch.d.ts +31 -0
- package/dist/rules/phase3-arch.js +148 -0
- package/dist/rules/phase5-ai.d.ts +8 -0
- package/dist/rules/phase5-ai.js +262 -0
- package/dist/rules/phase8-semantic.d.ts +22 -0
- package/dist/rules/phase8-semantic.js +109 -0
- package/dist/rules/shared.d.ts +7 -0
- package/dist/rules/shared.js +27 -0
- package/package.json +8 -3
- package/packages/vscode-drift/.vscodeignore +9 -0
- package/packages/vscode-drift/LICENSE +21 -0
- package/packages/vscode-drift/README.md +64 -0
- package/packages/vscode-drift/images/icon.png +0 -0
- package/packages/vscode-drift/images/icon.svg +30 -0
- package/packages/vscode-drift/package-lock.json +485 -0
- package/packages/vscode-drift/package.json +119 -0
- package/packages/vscode-drift/src/analyzer.ts +38 -0
- package/packages/vscode-drift/src/diagnostics.ts +55 -0
- package/packages/vscode-drift/src/extension.ts +111 -0
- package/packages/vscode-drift/src/statusbar.ts +47 -0
- package/packages/vscode-drift/src/treeview.ts +108 -0
- package/packages/vscode-drift/tsconfig.json +18 -0
- package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
- package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
- package/src/analyzer.ts +124 -1726
- package/src/cli.ts +53 -4
- package/src/config.ts +1 -1
- package/src/fix.ts +154 -0
- package/src/git/blame.ts +279 -0
- package/src/git/helpers.ts +198 -0
- package/src/git/trend.ts +116 -0
- package/src/git.ts +2 -2
- package/src/report.ts +631 -296
- package/src/rules/phase0-basic.ts +187 -0
- package/src/rules/phase1-complexity.ts +302 -0
- package/src/rules/phase2-crossfile.ts +149 -0
- package/src/rules/phase3-arch.ts +179 -0
- package/src/rules/phase5-ai.ts +292 -0
- package/src/rules/phase8-semantic.ts +132 -0
- package/src/rules/shared.ts +39 -0
- package/tests/helpers.ts +45 -0
- package/tests/rules.test.ts +1269 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph';
|
|
2
|
+
import type { DriftIssue } from '../types.js';
|
|
3
|
+
export declare function detectLargeFile(file: SourceFile): DriftIssue[];
|
|
4
|
+
export declare function detectLargeFunctions(file: SourceFile): DriftIssue[];
|
|
5
|
+
export declare function detectDebugLeftovers(file: SourceFile): DriftIssue[];
|
|
6
|
+
export declare function detectDeadCode(file: SourceFile): DriftIssue[];
|
|
7
|
+
export declare function detectDuplicateFunctionNames(file: SourceFile): DriftIssue[];
|
|
8
|
+
export declare function detectAnyAbuse(file: SourceFile): DriftIssue[];
|
|
9
|
+
export declare function detectCatchSwallow(file: SourceFile): DriftIssue[];
|
|
10
|
+
export declare function detectMissingReturnTypes(file: SourceFile): DriftIssue[];
|
|
11
|
+
//# sourceMappingURL=phase0-basic.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { hasIgnoreComment, getSnippet, getFunctionLikeLines } from './shared.js';
|
|
3
|
+
export function detectLargeFile(file) {
|
|
4
|
+
const lineCount = file.getEndLineNumber();
|
|
5
|
+
if (lineCount > 300) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
rule: 'large-file',
|
|
9
|
+
severity: 'error',
|
|
10
|
+
message: `File has ${lineCount} lines (threshold: 300). Large files are the #1 sign of AI-generated structural drift.`,
|
|
11
|
+
line: 1,
|
|
12
|
+
column: 1,
|
|
13
|
+
snippet: `// ${lineCount} lines total`,
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
export function detectLargeFunctions(file) {
|
|
20
|
+
const issues = [];
|
|
21
|
+
const fns = [
|
|
22
|
+
...file.getFunctions(),
|
|
23
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
24
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
25
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
26
|
+
];
|
|
27
|
+
for (const fn of fns) {
|
|
28
|
+
const lines = getFunctionLikeLines(fn);
|
|
29
|
+
const startLine = fn.getStartLineNumber();
|
|
30
|
+
if (lines > 50) {
|
|
31
|
+
if (hasIgnoreComment(file, startLine))
|
|
32
|
+
continue;
|
|
33
|
+
issues.push({
|
|
34
|
+
rule: 'large-function',
|
|
35
|
+
severity: 'error',
|
|
36
|
+
message: `Function spans ${lines} lines (threshold: 50). AI tends to dump logic into single functions.`,
|
|
37
|
+
line: startLine,
|
|
38
|
+
column: fn.getStartLinePos(),
|
|
39
|
+
snippet: getSnippet(fn, file),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return issues;
|
|
44
|
+
}
|
|
45
|
+
export function detectDebugLeftovers(file) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
48
|
+
const expr = call.getExpression().getText();
|
|
49
|
+
const line = call.getStartLineNumber();
|
|
50
|
+
if (/^console\.(log|warn|error|debug|info)\b/.test(expr)) {
|
|
51
|
+
if (hasIgnoreComment(file, line))
|
|
52
|
+
continue;
|
|
53
|
+
issues.push({
|
|
54
|
+
rule: 'debug-leftover',
|
|
55
|
+
severity: 'warning',
|
|
56
|
+
message: `console.${expr.split('.')[1]} left in production code.`,
|
|
57
|
+
line,
|
|
58
|
+
column: call.getStartLinePos(),
|
|
59
|
+
snippet: getSnippet(call, file),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const lines = file.getFullText().split('\n');
|
|
64
|
+
lines.forEach((lineContent, i) => {
|
|
65
|
+
if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
|
|
66
|
+
if (hasIgnoreComment(file, i + 1))
|
|
67
|
+
return;
|
|
68
|
+
issues.push({
|
|
69
|
+
rule: 'debug-leftover',
|
|
70
|
+
severity: 'warning',
|
|
71
|
+
message: `Unresolved marker found: ${lineContent.trim().slice(0, 60)}`,
|
|
72
|
+
line: i + 1,
|
|
73
|
+
column: 1,
|
|
74
|
+
snippet: lineContent.trim().slice(0, 120),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return issues;
|
|
79
|
+
}
|
|
80
|
+
export function detectDeadCode(file) {
|
|
81
|
+
const issues = [];
|
|
82
|
+
for (const imp of file.getImportDeclarations()) {
|
|
83
|
+
for (const named of imp.getNamedImports()) {
|
|
84
|
+
const name = named.getName();
|
|
85
|
+
const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter((id) => id.getText() === name && id !== named.getNameNode());
|
|
86
|
+
if (refs.length === 0) {
|
|
87
|
+
issues.push({
|
|
88
|
+
rule: 'dead-code',
|
|
89
|
+
severity: 'warning',
|
|
90
|
+
message: `Unused import '${name}'. AI often imports more than it uses.`,
|
|
91
|
+
line: imp.getStartLineNumber(),
|
|
92
|
+
column: imp.getStartLinePos(),
|
|
93
|
+
snippet: getSnippet(imp, file),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
export function detectDuplicateFunctionNames(file) {
|
|
101
|
+
const issues = [];
|
|
102
|
+
const seen = new Map();
|
|
103
|
+
const fns = file.getFunctions();
|
|
104
|
+
for (const fn of fns) {
|
|
105
|
+
const name = fn.getName();
|
|
106
|
+
if (!name)
|
|
107
|
+
continue;
|
|
108
|
+
const normalized = name.toLowerCase().replace(/[_-]/g, '');
|
|
109
|
+
if (seen.has(normalized)) {
|
|
110
|
+
issues.push({
|
|
111
|
+
rule: 'duplicate-function-name',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
message: `Function '${name}' looks like a duplicate of a previously defined function. AI often generates near-identical helpers.`,
|
|
114
|
+
line: fn.getStartLineNumber(),
|
|
115
|
+
column: fn.getStartLinePos(),
|
|
116
|
+
snippet: getSnippet(fn, file),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
seen.set(normalized, fn.getStartLineNumber());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return issues;
|
|
124
|
+
}
|
|
125
|
+
export function detectAnyAbuse(file) {
|
|
126
|
+
const issues = [];
|
|
127
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.AnyKeyword)) {
|
|
128
|
+
issues.push({
|
|
129
|
+
rule: 'any-abuse',
|
|
130
|
+
severity: 'warning',
|
|
131
|
+
message: `Explicit 'any' type detected. AI defaults to 'any' when it can't infer types properly.`,
|
|
132
|
+
line: node.getStartLineNumber(),
|
|
133
|
+
column: node.getStartLinePos(),
|
|
134
|
+
snippet: getSnippet(node, file),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return issues;
|
|
138
|
+
}
|
|
139
|
+
export function detectCatchSwallow(file) {
|
|
140
|
+
const issues = [];
|
|
141
|
+
for (const tryCatch of file.getDescendantsOfKind(SyntaxKind.TryStatement)) {
|
|
142
|
+
const catchClause = tryCatch.getCatchClause();
|
|
143
|
+
if (!catchClause)
|
|
144
|
+
continue;
|
|
145
|
+
const block = catchClause.getBlock();
|
|
146
|
+
const stmts = block.getStatements();
|
|
147
|
+
if (stmts.length === 0) {
|
|
148
|
+
issues.push({
|
|
149
|
+
rule: 'catch-swallow',
|
|
150
|
+
severity: 'warning',
|
|
151
|
+
message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
|
|
152
|
+
line: catchClause.getStartLineNumber(),
|
|
153
|
+
column: catchClause.getStartLinePos(),
|
|
154
|
+
snippet: getSnippet(catchClause, file),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
export function detectMissingReturnTypes(file) {
|
|
161
|
+
const issues = [];
|
|
162
|
+
for (const fn of file.getFunctions()) {
|
|
163
|
+
if (!fn.getReturnTypeNode()) {
|
|
164
|
+
issues.push({
|
|
165
|
+
rule: 'no-return-type',
|
|
166
|
+
severity: 'info',
|
|
167
|
+
message: `Function '${fn.getName() ?? 'anonymous'}' has no explicit return type.`,
|
|
168
|
+
line: fn.getStartLineNumber(),
|
|
169
|
+
column: fn.getStartLinePos(),
|
|
170
|
+
snippet: getSnippet(fn, file),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=phase0-basic.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph';
|
|
2
|
+
import type { DriftIssue } from '../types.js';
|
|
3
|
+
export declare function detectHighComplexity(file: SourceFile): DriftIssue[];
|
|
4
|
+
export declare function detectDeepNesting(file: SourceFile): DriftIssue[];
|
|
5
|
+
/**
|
|
6
|
+
* Too many parameters: functions with more than 4 parameters.
|
|
7
|
+
* AI avoids refactoring parameters into objects/options bags.
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectTooManyParams(file: SourceFile): DriftIssue[];
|
|
10
|
+
/**
|
|
11
|
+
* High coupling: files with more than 10 distinct import sources.
|
|
12
|
+
* AI imports broadly without considering module cohesion.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectHighCoupling(file: SourceFile): DriftIssue[];
|
|
15
|
+
/**
|
|
16
|
+
* Promise style mix: async/await and .then()/.catch() used in the same file.
|
|
17
|
+
* AI generates both styles without consistency.
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectPromiseStyleMix(file: SourceFile): DriftIssue[];
|
|
20
|
+
/**
|
|
21
|
+
* Magic numbers: numeric literals used directly in logic outside of named constants.
|
|
22
|
+
* Excludes 0, 1, -1 (universally understood) and array indices in obvious patterns.
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectMagicNumbers(file: SourceFile): DriftIssue[];
|
|
25
|
+
/**
|
|
26
|
+
* Comment contradiction: comments that restate exactly what the code does.
|
|
27
|
+
* Classic AI pattern — documents the obvious instead of the why.
|
|
28
|
+
* Detects: "// increment counter" above counter++, "// return x" above return x, etc.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectCommentContradiction(file: SourceFile): DriftIssue[];
|
|
31
|
+
//# sourceMappingURL=phase1-complexity.d.ts.map
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { hasIgnoreComment, getSnippet } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Cyclomatic complexity: count decision points in a function.
|
|
5
|
+
* Each if/else if/ternary/?:/for/while/do/case/catch/&&/|| adds 1.
|
|
6
|
+
* Threshold: > 10 is considered high complexity.
|
|
7
|
+
*/
|
|
8
|
+
function getCyclomaticComplexity(fn) {
|
|
9
|
+
let complexity = 1; // base path
|
|
10
|
+
const incrementKinds = [
|
|
11
|
+
SyntaxKind.IfStatement,
|
|
12
|
+
SyntaxKind.ForStatement,
|
|
13
|
+
SyntaxKind.ForInStatement,
|
|
14
|
+
SyntaxKind.ForOfStatement,
|
|
15
|
+
SyntaxKind.WhileStatement,
|
|
16
|
+
SyntaxKind.DoStatement,
|
|
17
|
+
SyntaxKind.CaseClause,
|
|
18
|
+
SyntaxKind.CatchClause,
|
|
19
|
+
SyntaxKind.ConditionalExpression, // ternary
|
|
20
|
+
SyntaxKind.AmpersandAmpersandToken,
|
|
21
|
+
SyntaxKind.BarBarToken,
|
|
22
|
+
SyntaxKind.QuestionQuestionToken, // ??
|
|
23
|
+
];
|
|
24
|
+
for (const kind of incrementKinds) {
|
|
25
|
+
complexity += fn.getDescendantsOfKind(kind).length;
|
|
26
|
+
}
|
|
27
|
+
return complexity;
|
|
28
|
+
}
|
|
29
|
+
export function detectHighComplexity(file) {
|
|
30
|
+
const issues = [];
|
|
31
|
+
const fns = [
|
|
32
|
+
...file.getFunctions(),
|
|
33
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
34
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
35
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
36
|
+
];
|
|
37
|
+
for (const fn of fns) {
|
|
38
|
+
const complexity = getCyclomaticComplexity(fn);
|
|
39
|
+
if (complexity > 10) {
|
|
40
|
+
const startLine = fn.getStartLineNumber();
|
|
41
|
+
if (hasIgnoreComment(file, startLine))
|
|
42
|
+
continue;
|
|
43
|
+
issues.push({
|
|
44
|
+
rule: 'high-complexity',
|
|
45
|
+
severity: 'error',
|
|
46
|
+
message: `Cyclomatic complexity is ${complexity} (threshold: 10). AI generates correct code, not simple code.`,
|
|
47
|
+
line: startLine,
|
|
48
|
+
column: fn.getStartLinePos(),
|
|
49
|
+
snippet: getSnippet(fn, file),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Deep nesting: count the maximum nesting depth of control flow inside a function.
|
|
57
|
+
* Counts: if, for, while, do, try, switch.
|
|
58
|
+
* Threshold: > 3 levels.
|
|
59
|
+
*/
|
|
60
|
+
function getMaxNestingDepth(fn) {
|
|
61
|
+
const nestingKinds = new Set([
|
|
62
|
+
SyntaxKind.IfStatement,
|
|
63
|
+
SyntaxKind.ForStatement,
|
|
64
|
+
SyntaxKind.ForInStatement,
|
|
65
|
+
SyntaxKind.ForOfStatement,
|
|
66
|
+
SyntaxKind.WhileStatement,
|
|
67
|
+
SyntaxKind.DoStatement,
|
|
68
|
+
SyntaxKind.TryStatement,
|
|
69
|
+
SyntaxKind.SwitchStatement,
|
|
70
|
+
]);
|
|
71
|
+
let maxDepth = 0;
|
|
72
|
+
function walk(node, depth) {
|
|
73
|
+
if (nestingKinds.has(node.getKind())) {
|
|
74
|
+
depth++;
|
|
75
|
+
if (depth > maxDepth)
|
|
76
|
+
maxDepth = depth;
|
|
77
|
+
}
|
|
78
|
+
for (const child of node.getChildren()) {
|
|
79
|
+
walk(child, depth);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
walk(fn, 0);
|
|
83
|
+
return maxDepth;
|
|
84
|
+
}
|
|
85
|
+
export function detectDeepNesting(file) {
|
|
86
|
+
const issues = [];
|
|
87
|
+
const fns = [
|
|
88
|
+
...file.getFunctions(),
|
|
89
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
90
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
91
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
92
|
+
];
|
|
93
|
+
for (const fn of fns) {
|
|
94
|
+
const depth = getMaxNestingDepth(fn);
|
|
95
|
+
if (depth > 3) {
|
|
96
|
+
const startLine = fn.getStartLineNumber();
|
|
97
|
+
if (hasIgnoreComment(file, startLine))
|
|
98
|
+
continue;
|
|
99
|
+
issues.push({
|
|
100
|
+
rule: 'deep-nesting',
|
|
101
|
+
severity: 'warning',
|
|
102
|
+
message: `Maximum nesting depth is ${depth} (threshold: 3). Deep nesting is the #1 readability killer.`,
|
|
103
|
+
line: startLine,
|
|
104
|
+
column: fn.getStartLinePos(),
|
|
105
|
+
snippet: getSnippet(fn, file),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return issues;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Too many parameters: functions with more than 4 parameters.
|
|
113
|
+
* AI avoids refactoring parameters into objects/options bags.
|
|
114
|
+
*/
|
|
115
|
+
export function detectTooManyParams(file) {
|
|
116
|
+
const issues = [];
|
|
117
|
+
const fns = [
|
|
118
|
+
...file.getFunctions(),
|
|
119
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
120
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
121
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
122
|
+
];
|
|
123
|
+
for (const fn of fns) {
|
|
124
|
+
const paramCount = fn.getParameters().length;
|
|
125
|
+
if (paramCount > 4) {
|
|
126
|
+
const startLine = fn.getStartLineNumber();
|
|
127
|
+
if (hasIgnoreComment(file, startLine))
|
|
128
|
+
continue;
|
|
129
|
+
issues.push({
|
|
130
|
+
rule: 'too-many-params',
|
|
131
|
+
severity: 'warning',
|
|
132
|
+
message: `Function has ${paramCount} parameters (threshold: 4). AI avoids refactoring into options objects.`,
|
|
133
|
+
line: startLine,
|
|
134
|
+
column: fn.getStartLinePos(),
|
|
135
|
+
snippet: getSnippet(fn, file),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return issues;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* High coupling: files with more than 10 distinct import sources.
|
|
143
|
+
* AI imports broadly without considering module cohesion.
|
|
144
|
+
*/
|
|
145
|
+
export function detectHighCoupling(file) {
|
|
146
|
+
const imports = file.getImportDeclarations();
|
|
147
|
+
const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()));
|
|
148
|
+
if (sources.size > 10) {
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
rule: 'high-coupling',
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
message: `File imports from ${sources.size} distinct modules (threshold: 10). High coupling makes refactoring dangerous.`,
|
|
154
|
+
line: 1,
|
|
155
|
+
column: 1,
|
|
156
|
+
snippet: `// ${sources.size} import sources`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Promise style mix: async/await and .then()/.catch() used in the same file.
|
|
164
|
+
* AI generates both styles without consistency.
|
|
165
|
+
*/
|
|
166
|
+
export function detectPromiseStyleMix(file) {
|
|
167
|
+
const text = file.getFullText();
|
|
168
|
+
// detect .then( or .catch( calls (property access on a promise)
|
|
169
|
+
const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
|
|
170
|
+
const name = node.getName();
|
|
171
|
+
return name === 'then' || name === 'catch';
|
|
172
|
+
});
|
|
173
|
+
// detect async keyword usage
|
|
174
|
+
const hasAsync = file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
|
|
175
|
+
/\bawait\b/.test(text);
|
|
176
|
+
if (hasThen && hasAsync) {
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
rule: 'promise-style-mix',
|
|
180
|
+
severity: 'warning',
|
|
181
|
+
message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
|
|
182
|
+
line: 1,
|
|
183
|
+
column: 1,
|
|
184
|
+
snippet: `// mixed promise styles detected`,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Magic numbers: numeric literals used directly in logic outside of named constants.
|
|
192
|
+
* Excludes 0, 1, -1 (universally understood) and array indices in obvious patterns.
|
|
193
|
+
*/
|
|
194
|
+
export function detectMagicNumbers(file) {
|
|
195
|
+
const issues = [];
|
|
196
|
+
const ALLOWED = new Set([0, 1, -1, 2, 100]);
|
|
197
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
|
|
198
|
+
const value = Number(node.getLiteralValue());
|
|
199
|
+
if (ALLOWED.has(value))
|
|
200
|
+
continue;
|
|
201
|
+
// Skip: variable/const initializers at top level (those ARE the named constants)
|
|
202
|
+
const parent = node.getParent();
|
|
203
|
+
if (!parent)
|
|
204
|
+
continue;
|
|
205
|
+
const parentKind = parent.getKind();
|
|
206
|
+
if (parentKind === SyntaxKind.VariableDeclaration ||
|
|
207
|
+
parentKind === SyntaxKind.PropertyAssignment ||
|
|
208
|
+
parentKind === SyntaxKind.EnumMember ||
|
|
209
|
+
parentKind === SyntaxKind.Parameter)
|
|
210
|
+
continue;
|
|
211
|
+
const line = node.getStartLineNumber();
|
|
212
|
+
if (hasIgnoreComment(file, line))
|
|
213
|
+
continue;
|
|
214
|
+
issues.push({
|
|
215
|
+
rule: 'magic-number',
|
|
216
|
+
severity: 'info',
|
|
217
|
+
message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
|
|
218
|
+
line,
|
|
219
|
+
column: node.getStartLinePos(),
|
|
220
|
+
snippet: getSnippet(node, file),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return issues;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Comment contradiction: comments that restate exactly what the code does.
|
|
227
|
+
* Classic AI pattern — documents the obvious instead of the why.
|
|
228
|
+
* Detects: "// increment counter" above counter++, "// return x" above return x, etc.
|
|
229
|
+
*/
|
|
230
|
+
export function detectCommentContradiction(file) {
|
|
231
|
+
const issues = [];
|
|
232
|
+
const lines = file.getFullText().split('\n');
|
|
233
|
+
// Patterns: comment that is a near-literal restatement of the next line
|
|
234
|
+
const trivialCommentPatterns = [
|
|
235
|
+
// "// return ..." above a return statement
|
|
236
|
+
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
237
|
+
// "// increment ..." or "// increase ..." above x++ or x += 1
|
|
238
|
+
{ comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
|
|
239
|
+
// "// decrement ..." above x-- or x -= 1
|
|
240
|
+
{ comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
|
|
241
|
+
// "// log ..." above console.log
|
|
242
|
+
{ comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
|
|
243
|
+
// "// set ... to ..." or "// assign ..." above assignment
|
|
244
|
+
{ comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
|
|
245
|
+
// "// call ..." above a function call
|
|
246
|
+
{ comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
|
|
247
|
+
// "// declare ..." or "// define ..." or "// create ..." above const/let/var
|
|
248
|
+
{ comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
|
|
249
|
+
// "// check if ..." above an if statement
|
|
250
|
+
{ comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
|
|
251
|
+
// "// loop ..." or "// iterate ..." above for/while
|
|
252
|
+
{ comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
|
|
253
|
+
// "// import ..." above an import
|
|
254
|
+
{ comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
|
|
255
|
+
];
|
|
256
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
257
|
+
const commentLine = lines[i].trim();
|
|
258
|
+
const nextLine = lines[i + 1];
|
|
259
|
+
for (const { comment, code } of trivialCommentPatterns) {
|
|
260
|
+
if (comment.test(commentLine) && code.test(nextLine)) {
|
|
261
|
+
if (hasIgnoreComment(file, i + 1))
|
|
262
|
+
continue;
|
|
263
|
+
issues.push({
|
|
264
|
+
rule: 'comment-contradiction',
|
|
265
|
+
severity: 'warning',
|
|
266
|
+
message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
|
|
267
|
+
line: i + 1,
|
|
268
|
+
column: 1,
|
|
269
|
+
snippet: `${commentLine.slice(0, 60)}\n${nextLine.trim().slice(0, 60)}`,
|
|
270
|
+
});
|
|
271
|
+
break; // one issue per comment line max
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return issues;
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=phase1-complexity.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph';
|
|
2
|
+
import type { DriftIssue } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detect files that are never imported by any other file in the project.
|
|
5
|
+
* Entry-point files (index, main, cli, app, bin/) are excluded.
|
|
6
|
+
*/
|
|
7
|
+
export declare function detectDeadFiles(sourceFiles: SourceFile[], allImportedPaths: Set<string>, ruleWeights: Record<string, {
|
|
8
|
+
severity: DriftIssue['severity'];
|
|
9
|
+
weight: number;
|
|
10
|
+
}>): Map<string, DriftIssue>;
|
|
11
|
+
/**
|
|
12
|
+
* Detect named exports that are never imported by any other file.
|
|
13
|
+
* Barrel files (index.*) are excluded since their entire surface is the public API.
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectUnusedExports(sourceFiles: SourceFile[], allImportedNames: Map<string, Set<string>>, ruleWeights: Record<string, {
|
|
16
|
+
severity: DriftIssue['severity'];
|
|
17
|
+
weight: number;
|
|
18
|
+
}>): Map<string, DriftIssue[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Detect packages in package.json that are never imported in any source file.
|
|
21
|
+
* @type-only packages (@types/*) are excluded.
|
|
22
|
+
*/
|
|
23
|
+
export declare function detectUnusedDependencies(targetPath: string, allLiteralImports: Set<string>, ruleWeights: Record<string, {
|
|
24
|
+
severity: DriftIssue['severity'];
|
|
25
|
+
weight: number;
|
|
26
|
+
}>): DriftIssue[];
|
|
27
|
+
//# sourceMappingURL=phase2-crossfile.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Detect files that are never imported by any other file in the project.
|
|
5
|
+
* Entry-point files (index, main, cli, app, bin/) are excluded.
|
|
6
|
+
*/
|
|
7
|
+
export function detectDeadFiles(sourceFiles, allImportedPaths, ruleWeights) {
|
|
8
|
+
const issues = new Map();
|
|
9
|
+
for (const sf of sourceFiles) {
|
|
10
|
+
const sfPath = sf.getFilePath();
|
|
11
|
+
const basename = path.basename(sfPath);
|
|
12
|
+
const isBinFile = sfPath.replace(/\\/g, '/').includes('/bin/');
|
|
13
|
+
const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile;
|
|
14
|
+
if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
|
|
15
|
+
issues.set(sfPath, {
|
|
16
|
+
rule: 'dead-file',
|
|
17
|
+
severity: ruleWeights['dead-file'].severity,
|
|
18
|
+
message: 'File is never imported — may be dead code',
|
|
19
|
+
line: 1,
|
|
20
|
+
column: 1,
|
|
21
|
+
snippet: basename,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return issues;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Detect named exports that are never imported by any other file.
|
|
29
|
+
* Barrel files (index.*) are excluded since their entire surface is the public API.
|
|
30
|
+
*/
|
|
31
|
+
export function detectUnusedExports(sourceFiles, allImportedNames, ruleWeights) {
|
|
32
|
+
const result = new Map();
|
|
33
|
+
for (const sf of sourceFiles) {
|
|
34
|
+
const sfPath = sf.getFilePath();
|
|
35
|
+
const basename = path.basename(sfPath);
|
|
36
|
+
const isBarrel = /^index\.(ts|tsx|js|jsx)$/.test(basename);
|
|
37
|
+
const importedNamesForFile = allImportedNames.get(sfPath);
|
|
38
|
+
const hasNamespaceImport = importedNamesForFile?.has('*') ?? false;
|
|
39
|
+
if (isBarrel || hasNamespaceImport)
|
|
40
|
+
continue;
|
|
41
|
+
const issues = [];
|
|
42
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
43
|
+
for (const namedExport of exportDecl.getNamedExports()) {
|
|
44
|
+
const name = namedExport.getName();
|
|
45
|
+
if (!importedNamesForFile?.has(name)) {
|
|
46
|
+
issues.push({
|
|
47
|
+
rule: 'unused-export',
|
|
48
|
+
severity: ruleWeights['unused-export'].severity,
|
|
49
|
+
message: `'${name}' is exported but never imported`,
|
|
50
|
+
line: namedExport.getStartLineNumber(),
|
|
51
|
+
column: 1,
|
|
52
|
+
snippet: namedExport.getText().slice(0, 80),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Also check inline export declarations (export function foo, export const bar)
|
|
58
|
+
for (const exportSymbol of sf.getExportedDeclarations()) {
|
|
59
|
+
const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]];
|
|
60
|
+
if (exportName === 'default')
|
|
61
|
+
continue;
|
|
62
|
+
if (importedNamesForFile?.has(exportName))
|
|
63
|
+
continue;
|
|
64
|
+
for (const decl of declarations) {
|
|
65
|
+
// Skip if this is a re-export from another file
|
|
66
|
+
if (decl.getSourceFile().getFilePath() !== sfPath)
|
|
67
|
+
continue;
|
|
68
|
+
issues.push({
|
|
69
|
+
rule: 'unused-export',
|
|
70
|
+
severity: ruleWeights['unused-export'].severity,
|
|
71
|
+
message: `'${exportName}' is exported but never imported`,
|
|
72
|
+
line: decl.getStartLineNumber(),
|
|
73
|
+
column: 1,
|
|
74
|
+
snippet: decl.getText().split('\n')[0].slice(0, 80),
|
|
75
|
+
});
|
|
76
|
+
break; // one issue per export name is enough
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (issues.length > 0) {
|
|
80
|
+
result.set(sfPath, issues);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Detect packages in package.json that are never imported in any source file.
|
|
87
|
+
* @type-only packages (@types/*) are excluded.
|
|
88
|
+
*/
|
|
89
|
+
export function detectUnusedDependencies(targetPath, allLiteralImports, ruleWeights) {
|
|
90
|
+
const pkgPath = path.join(targetPath, 'package.json'); // drift-ignore
|
|
91
|
+
if (!fs.existsSync(pkgPath))
|
|
92
|
+
return [];
|
|
93
|
+
let pkg;
|
|
94
|
+
try {
|
|
95
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
pkg = {};
|
|
99
|
+
}
|
|
100
|
+
const deps = {
|
|
101
|
+
...(pkg.dependencies ?? {}),
|
|
102
|
+
};
|
|
103
|
+
const unusedDeps = [];
|
|
104
|
+
for (const depName of Object.keys(deps)) {
|
|
105
|
+
// Skip type-only packages (@types/*)
|
|
106
|
+
if (depName.startsWith('@types/'))
|
|
107
|
+
continue;
|
|
108
|
+
// A dependency is "used" if any import specifier starts with the package name
|
|
109
|
+
const isUsed = [...allLiteralImports].some(imp => imp === depName || imp.startsWith(depName + '/'));
|
|
110
|
+
if (!isUsed)
|
|
111
|
+
unusedDeps.push(depName);
|
|
112
|
+
}
|
|
113
|
+
return unusedDeps.map(dep => ({
|
|
114
|
+
rule: 'unused-dependency',
|
|
115
|
+
severity: ruleWeights['unused-dependency'].severity,
|
|
116
|
+
message: `'${dep}' is in package.json but never imported`,
|
|
117
|
+
line: 1,
|
|
118
|
+
column: 1,
|
|
119
|
+
snippet: `"${dep}"`,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=phase2-crossfile.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DriftIssue, LayerDefinition, ModuleBoundary } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* DFS cycle detection in a directed import graph.
|
|
4
|
+
* Returns arrays of file paths that form cycles.
|
|
5
|
+
*/
|
|
6
|
+
export declare function findCycles(graph: Map<string, Set<string>>): Array<string[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Detect circular dependencies from the import graph.
|
|
9
|
+
* Returns a map of filePath → issue (one per unique cycle).
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectCircularDependencies(importGraph: Map<string, Set<string>>, ruleWeights: Record<string, {
|
|
12
|
+
severity: DriftIssue['severity'];
|
|
13
|
+
weight: number;
|
|
14
|
+
}>): Map<string, DriftIssue>;
|
|
15
|
+
/**
|
|
16
|
+
* Detect layer violations based on user-defined layer configuration.
|
|
17
|
+
* Returns a map of filePath → issues[].
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectLayerViolations(importGraph: Map<string, Set<string>>, layers: LayerDefinition[], targetPath: string, ruleWeights: Record<string, {
|
|
20
|
+
severity: DriftIssue['severity'];
|
|
21
|
+
weight: number;
|
|
22
|
+
}>): Map<string, DriftIssue[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Detect cross-boundary imports based on user-defined module boundary configuration.
|
|
25
|
+
* Returns a map of filePath → issues[].
|
|
26
|
+
*/
|
|
27
|
+
export declare function detectCrossBoundaryImports(importGraph: Map<string, Set<string>>, modules: ModuleBoundary[], targetPath: string, ruleWeights: Record<string, {
|
|
28
|
+
severity: DriftIssue['severity'];
|
|
29
|
+
weight: number;
|
|
30
|
+
}>): Map<string, DriftIssue[]>;
|
|
31
|
+
//# sourceMappingURL=phase3-arch.d.ts.map
|