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