@eduardbar/drift 0.9.1 → 1.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/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +78 -0
- package/AGENTS.md +83 -23
- package/README.md +69 -2
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +8 -38
- package/dist/analyzer.js +181 -1526
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +125 -4
- package/dist/config.js +1 -1
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +17 -0
- package/dist/fix.js +132 -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 +81 -0
- package/dist/git.d.ts +0 -13
- package/dist/git.js +27 -21
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.js +103 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +654 -293
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.d.ts +11 -0
- package/dist/rules/phase0-basic.js +183 -0
- package/dist/rules/phase1-complexity.d.ts +7 -0
- package/dist/rules/phase1-complexity.js +8 -0
- package/dist/rules/phase2-crossfile.d.ts +23 -0
- package/dist/rules/phase2-crossfile.js +135 -0
- package/dist/rules/phase3-arch.d.ts +23 -0
- package/dist/rules/phase3-arch.js +151 -0
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -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 +17 -0
- package/dist/rules/phase8-semantic.js +110 -0
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/rules/shared.d.ts +7 -0
- package/dist/rules/shared.js +27 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +69 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +208 -0
- package/package.json +8 -3
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- 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 +40 -0
- package/packages/vscode-drift/src/diagnostics.ts +55 -0
- package/packages/vscode-drift/src/extension.ts +135 -0
- package/packages/vscode-drift/src/statusbar.ts +55 -0
- package/packages/vscode-drift/src/treeview.ts +110 -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 +248 -1765
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +143 -4
- package/src/config.ts +1 -1
- package/src/diff.ts +36 -30
- package/src/fix.ts +178 -0
- package/src/git/blame.ts +279 -0
- package/src/git/helpers.ts +198 -0
- package/src/git/trend.ts +117 -0
- package/src/git.ts +33 -24
- package/src/index.ts +16 -1
- package/src/map.ts +117 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +666 -296
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +194 -0
- package/src/rules/phase1-complexity.ts +8 -0
- package/src/rules/phase2-crossfile.ts +177 -0
- package/src/rules/phase3-arch.ts +183 -0
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase5-ai.ts +292 -0
- package/src/rules/phase8-semantic.ts +136 -0
- package/src/rules/promise.ts +29 -0
- package/src/rules/shared.ts +39 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +75 -1
- package/src/utils.ts +3 -1
- package/tests/helpers.ts +45 -0
- package/tests/new-features.test.ts +153 -0
- package/tests/rules.test.ts +1269 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
const LARGE_FILE_THRESHOLD = 300
|
|
6
|
+
const LARGE_FUNCTION_THRESHOLD = 50
|
|
7
|
+
const SNIPPET_TRUNCATE_SHORT = 60
|
|
8
|
+
const SNIPPET_TRUNCATE_LONG = 120
|
|
9
|
+
|
|
10
|
+
export function detectLargeFile(file: SourceFile): DriftIssue[] {
|
|
11
|
+
const lineCount = file.getEndLineNumber()
|
|
12
|
+
if (lineCount > LARGE_FILE_THRESHOLD) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
rule: 'large-file',
|
|
16
|
+
severity: 'error',
|
|
17
|
+
message: `File has ${lineCount} lines (threshold: ${LARGE_FILE_THRESHOLD}). Large files are the #1 sign of AI-generated structural drift.`,
|
|
18
|
+
line: 1,
|
|
19
|
+
column: 1,
|
|
20
|
+
snippet: `// ${lineCount} lines total`,
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
|
|
28
|
+
const issues: DriftIssue[] = []
|
|
29
|
+
const fns: FunctionLike[] = [
|
|
30
|
+
...file.getFunctions(),
|
|
31
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
32
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
33
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
for (const fn of fns) {
|
|
37
|
+
const lines = getFunctionLikeLines(fn)
|
|
38
|
+
const startLine = fn.getStartLineNumber()
|
|
39
|
+
if (lines > LARGE_FUNCTION_THRESHOLD) {
|
|
40
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
41
|
+
issues.push({
|
|
42
|
+
rule: 'large-function',
|
|
43
|
+
severity: 'error',
|
|
44
|
+
message: `Function spans ${lines} lines (threshold: ${LARGE_FUNCTION_THRESHOLD}). AI tends to dump logic into single functions.`,
|
|
45
|
+
line: startLine,
|
|
46
|
+
column: fn.getStartLinePos(),
|
|
47
|
+
snippet: getSnippet(fn, file),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return issues
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
|
|
55
|
+
const issues: DriftIssue[] = []
|
|
56
|
+
|
|
57
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
58
|
+
const expr = call.getExpression().getText()
|
|
59
|
+
const line = call.getStartLineNumber()
|
|
60
|
+
if (/^console\.(log|warn|error|debug|info)\b/.test(expr)) {
|
|
61
|
+
if (hasIgnoreComment(file, line)) continue
|
|
62
|
+
issues.push({
|
|
63
|
+
rule: 'debug-leftover',
|
|
64
|
+
severity: 'warning',
|
|
65
|
+
message: `console.${expr.split('.')[1]} left in production code.`,
|
|
66
|
+
line,
|
|
67
|
+
column: call.getStartLinePos(),
|
|
68
|
+
snippet: getSnippet(call, file),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lines = file.getFullText().split('\n')
|
|
74
|
+
lines.forEach((lineContent, i) => {
|
|
75
|
+
if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
|
|
76
|
+
if (hasIgnoreComment(file, i + 1)) return
|
|
77
|
+
issues.push({
|
|
78
|
+
rule: 'debug-leftover',
|
|
79
|
+
severity: 'warning',
|
|
80
|
+
message: `Unresolved marker found: ${lineContent.trim().slice(0, SNIPPET_TRUNCATE_SHORT)}`,
|
|
81
|
+
line: i + 1,
|
|
82
|
+
column: 1,
|
|
83
|
+
snippet: lineContent.trim().slice(0, SNIPPET_TRUNCATE_LONG),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return issues
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function detectDeadCode(file: SourceFile): DriftIssue[] {
|
|
92
|
+
const issues: DriftIssue[] = []
|
|
93
|
+
|
|
94
|
+
for (const imp of file.getImportDeclarations()) {
|
|
95
|
+
for (const named of imp.getNamedImports()) {
|
|
96
|
+
const name = named.getName()
|
|
97
|
+
const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter(
|
|
98
|
+
(id) => id.getText() === name && id !== named.getNameNode()
|
|
99
|
+
)
|
|
100
|
+
if (refs.length === 0) {
|
|
101
|
+
issues.push({
|
|
102
|
+
rule: 'dead-code',
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
message: `Unused import '${name}'. AI often imports more than it uses.`,
|
|
105
|
+
line: imp.getStartLineNumber(),
|
|
106
|
+
column: imp.getStartLinePos(),
|
|
107
|
+
snippet: getSnippet(imp, file),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return issues
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function detectDuplicateFunctionNames(file: SourceFile): DriftIssue[] {
|
|
117
|
+
const issues: DriftIssue[] = []
|
|
118
|
+
const seen = new Map<string, number>()
|
|
119
|
+
|
|
120
|
+
const fns = file.getFunctions()
|
|
121
|
+
for (const fn of fns) {
|
|
122
|
+
const name = fn.getName()
|
|
123
|
+
if (!name) continue
|
|
124
|
+
const normalized = name.toLowerCase().replace(/[_-]/g, '')
|
|
125
|
+
if (seen.has(normalized)) {
|
|
126
|
+
issues.push({
|
|
127
|
+
rule: 'duplicate-function-name',
|
|
128
|
+
severity: 'error',
|
|
129
|
+
message: `Function '${name}' looks like a duplicate of a previously defined function. AI often generates near-identical helpers.`,
|
|
130
|
+
line: fn.getStartLineNumber(),
|
|
131
|
+
column: fn.getStartLinePos(),
|
|
132
|
+
snippet: getSnippet(fn, file),
|
|
133
|
+
})
|
|
134
|
+
} else {
|
|
135
|
+
seen.set(normalized, fn.getStartLineNumber())
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return issues
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function detectAnyAbuse(file: SourceFile): DriftIssue[] {
|
|
142
|
+
const issues: DriftIssue[] = []
|
|
143
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.AnyKeyword)) {
|
|
144
|
+
issues.push({
|
|
145
|
+
rule: 'any-abuse',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
message: `Explicit 'any' type detected. AI defaults to 'any' when it can't infer types properly.`,
|
|
148
|
+
line: node.getStartLineNumber(),
|
|
149
|
+
column: node.getStartLinePos(),
|
|
150
|
+
snippet: getSnippet(node, file),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return issues
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function detectCatchSwallow(file: SourceFile): DriftIssue[] {
|
|
157
|
+
const issues: DriftIssue[] = []
|
|
158
|
+
for (const tryCatch of file.getDescendantsOfKind(SyntaxKind.TryStatement)) {
|
|
159
|
+
const catchClause = tryCatch.getCatchClause()
|
|
160
|
+
if (!catchClause) continue
|
|
161
|
+
const block = catchClause.getBlock()
|
|
162
|
+
const stmts = block.getStatements()
|
|
163
|
+
if (stmts.length === 0) {
|
|
164
|
+
const line = catchClause.getStartLineNumber()
|
|
165
|
+
if (hasIgnoreComment(file, line)) continue
|
|
166
|
+
issues.push({
|
|
167
|
+
rule: 'catch-swallow',
|
|
168
|
+
severity: 'warning',
|
|
169
|
+
message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
|
|
170
|
+
line,
|
|
171
|
+
column: catchClause.getStartLinePos(),
|
|
172
|
+
snippet: getSnippet(catchClause, file),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return issues
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function detectMissingReturnTypes(file: SourceFile): DriftIssue[] {
|
|
180
|
+
const issues: DriftIssue[] = []
|
|
181
|
+
for (const fn of file.getFunctions()) {
|
|
182
|
+
if (!fn.getReturnTypeNode()) {
|
|
183
|
+
issues.push({
|
|
184
|
+
rule: 'no-return-type',
|
|
185
|
+
severity: 'info',
|
|
186
|
+
message: `Function '${fn.getName() ?? 'anonymous'}' has no explicit return type.`,
|
|
187
|
+
line: fn.getStartLineNumber(),
|
|
188
|
+
column: fn.getStartLinePos(),
|
|
189
|
+
snippet: getSnippet(fn, file),
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return issues
|
|
194
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
3
|
+
export { detectHighComplexity } from './complexity.js'
|
|
4
|
+
export { detectDeepNesting, detectTooManyParams } from './nesting.js'
|
|
5
|
+
export { detectHighCoupling } from './coupling.js'
|
|
6
|
+
export { detectPromiseStyleMix } from './promise.js'
|
|
7
|
+
export { detectMagicNumbers } from './magic.js'
|
|
8
|
+
export { detectCommentContradiction } from './comments.js'
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { SourceFile } from 'ts-morph'
|
|
5
|
+
import type { DriftIssue } from '../types.js'
|
|
6
|
+
|
|
7
|
+
const SNIPPET_LENGTH = 80
|
|
8
|
+
// drift-ignore
|
|
9
|
+
const BIN_DIR = '/bin/'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect files that are never imported by any other file in the project.
|
|
13
|
+
* Entry-point files (index, main, cli, app, bin/) are excluded.
|
|
14
|
+
*/
|
|
15
|
+
export function detectDeadFiles(
|
|
16
|
+
sourceFiles: SourceFile[],
|
|
17
|
+
allImportedPaths: Set<string>,
|
|
18
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
19
|
+
): Map<string, DriftIssue> {
|
|
20
|
+
const issues = new Map<string, DriftIssue>()
|
|
21
|
+
|
|
22
|
+
for (const sf of sourceFiles) {
|
|
23
|
+
const sfPath = sf.getFilePath()
|
|
24
|
+
const basename = path.basename(sfPath)
|
|
25
|
+
const isBinFile = sfPath.replace(/\\/g, '/').includes(BIN_DIR)
|
|
26
|
+
const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile
|
|
27
|
+
|
|
28
|
+
if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
|
|
29
|
+
issues.set(sfPath, {
|
|
30
|
+
rule: 'dead-file',
|
|
31
|
+
severity: ruleWeights['dead-file'].severity,
|
|
32
|
+
message: 'File is never imported — may be dead code',
|
|
33
|
+
line: 1,
|
|
34
|
+
column: 1,
|
|
35
|
+
snippet: basename,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return issues
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect named exports that are never imported by any other file.
|
|
45
|
+
* Barrel files (index.*) are excluded since their entire surface is the public API.
|
|
46
|
+
*/
|
|
47
|
+
function checkExportDeclarations(
|
|
48
|
+
sf: SourceFile,
|
|
49
|
+
sfPath: string,
|
|
50
|
+
importedNamesForFile: Set<string> | undefined,
|
|
51
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
52
|
+
): DriftIssue[] {
|
|
53
|
+
const issues: DriftIssue[] = []
|
|
54
|
+
|
|
55
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
56
|
+
for (const namedExport of exportDecl.getNamedExports()) {
|
|
57
|
+
const name = namedExport.getName()
|
|
58
|
+
if (!importedNamesForFile?.has(name)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
rule: 'unused-export',
|
|
61
|
+
severity: ruleWeights['unused-export'].severity,
|
|
62
|
+
message: `'${name}' is exported but never imported`,
|
|
63
|
+
line: namedExport.getStartLineNumber(),
|
|
64
|
+
column: 1,
|
|
65
|
+
snippet: namedExport.getText().slice(0, SNIPPET_LENGTH),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return issues
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function checkInlineExports(
|
|
75
|
+
sf: SourceFile,
|
|
76
|
+
sfPath: string,
|
|
77
|
+
importedNamesForFile: Set<string> | undefined,
|
|
78
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
79
|
+
): DriftIssue[] {
|
|
80
|
+
const issues: DriftIssue[] = []
|
|
81
|
+
|
|
82
|
+
for (const exportSymbol of sf.getExportedDeclarations()) {
|
|
83
|
+
const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]]
|
|
84
|
+
if (exportName === 'default') continue
|
|
85
|
+
if (importedNamesForFile?.has(exportName)) continue
|
|
86
|
+
|
|
87
|
+
for (const decl of declarations) {
|
|
88
|
+
if (decl.getSourceFile().getFilePath() !== sfPath) continue
|
|
89
|
+
|
|
90
|
+
issues.push({
|
|
91
|
+
rule: 'unused-export',
|
|
92
|
+
severity: ruleWeights['unused-export'].severity,
|
|
93
|
+
message: `'${exportName}' is exported but never imported`,
|
|
94
|
+
line: decl.getStartLineNumber(),
|
|
95
|
+
column: 1,
|
|
96
|
+
snippet: decl.getText().split('\n')[0].slice(0, SNIPPET_LENGTH),
|
|
97
|
+
})
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return issues
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function detectUnusedExports(
|
|
106
|
+
sourceFiles: SourceFile[],
|
|
107
|
+
allImportedNames: Map<string, Set<string>>,
|
|
108
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
109
|
+
): Map<string, DriftIssue[]> {
|
|
110
|
+
const result = new Map<string, DriftIssue[]>()
|
|
111
|
+
|
|
112
|
+
for (const sf of sourceFiles) {
|
|
113
|
+
const sfPath = sf.getFilePath()
|
|
114
|
+
const basename = path.basename(sfPath)
|
|
115
|
+
const isBarrel = /^index\.(ts|tsx|js|jsx)$/.test(basename)
|
|
116
|
+
const importedNamesForFile = allImportedNames.get(sfPath)
|
|
117
|
+
const hasNamespaceImport = importedNamesForFile?.has('*') ?? false
|
|
118
|
+
|
|
119
|
+
if (isBarrel || hasNamespaceImport) continue
|
|
120
|
+
|
|
121
|
+
const issues: DriftIssue[] = [
|
|
122
|
+
...checkExportDeclarations(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
123
|
+
...checkInlineExports(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
if (issues.length > 0) {
|
|
127
|
+
result.set(sfPath, issues)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Detect packages in package.json that are never imported in any source file.
|
|
136
|
+
* @type-only packages (@types/*) are excluded.
|
|
137
|
+
*/
|
|
138
|
+
export function detectUnusedDependencies(
|
|
139
|
+
targetPath: string,
|
|
140
|
+
allLiteralImports: Set<string>,
|
|
141
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
142
|
+
): DriftIssue[] {
|
|
143
|
+
const pkgPath = path.join(targetPath, 'package.json') // drift-ignore
|
|
144
|
+
if (!fs.existsSync(pkgPath)) return []
|
|
145
|
+
|
|
146
|
+
let pkg: Record<string, unknown>
|
|
147
|
+
try {
|
|
148
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
149
|
+
} catch {
|
|
150
|
+
pkg = {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const deps = {
|
|
154
|
+
...((pkg.dependencies as Record<string, string>) ?? {}),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const unusedDeps: string[] = []
|
|
158
|
+
for (const depName of Object.keys(deps)) {
|
|
159
|
+
// Skip type-only packages (@types/*)
|
|
160
|
+
if (depName.startsWith('@types/')) continue
|
|
161
|
+
|
|
162
|
+
// A dependency is "used" if any import specifier starts with the package name
|
|
163
|
+
const isUsed = [...allLiteralImports].some(
|
|
164
|
+
imp => imp === depName || imp.startsWith(depName + '/')
|
|
165
|
+
)
|
|
166
|
+
if (!isUsed) unusedDeps.push(depName)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return unusedDeps.map(dep => ({
|
|
170
|
+
rule: 'unused-dependency',
|
|
171
|
+
severity: ruleWeights['unused-dependency'].severity,
|
|
172
|
+
message: `'${dep}' is in package.json but never imported`,
|
|
173
|
+
line: 1,
|
|
174
|
+
column: 1,
|
|
175
|
+
snippet: `"${dep}"`,
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import type { DriftIssue, LayerDefinition, ModuleBoundary } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DFS cycle detection in a directed import graph.
|
|
8
|
+
* Returns arrays of file paths that form cycles.
|
|
9
|
+
*/
|
|
10
|
+
export function findCycles(graph: Map<string, Set<string>>): Array<string[]> {
|
|
11
|
+
const visited = new Set<string>()
|
|
12
|
+
const inStack = new Set<string>()
|
|
13
|
+
const cycles: Array<string[]> = []
|
|
14
|
+
|
|
15
|
+
function dfs(node: string, stack: string[]): void {
|
|
16
|
+
visited.add(node)
|
|
17
|
+
inStack.add(node)
|
|
18
|
+
stack.push(node)
|
|
19
|
+
|
|
20
|
+
for (const neighbor of graph.get(node) ?? []) {
|
|
21
|
+
if (!visited.has(neighbor)) {
|
|
22
|
+
dfs(neighbor, stack)
|
|
23
|
+
} else if (inStack.has(neighbor)) {
|
|
24
|
+
// Found a cycle — extract the cycle portion from the stack
|
|
25
|
+
const cycleStart = stack.indexOf(neighbor)
|
|
26
|
+
cycles.push(stack.slice(cycleStart))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stack.pop()
|
|
31
|
+
inStack.delete(node)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const node of graph.keys()) {
|
|
35
|
+
if (!visited.has(node)) {
|
|
36
|
+
dfs(node, [])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return cycles
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect circular dependencies from the import graph.
|
|
45
|
+
* Returns a map of filePath → issue (one per unique cycle).
|
|
46
|
+
*/
|
|
47
|
+
export function detectCircularDependencies(
|
|
48
|
+
importGraph: Map<string, Set<string>>,
|
|
49
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
50
|
+
): Map<string, DriftIssue> {
|
|
51
|
+
const cycles = findCycles(importGraph)
|
|
52
|
+
const reportedCycleKeys = new Set<string>()
|
|
53
|
+
const result = new Map<string, DriftIssue>()
|
|
54
|
+
|
|
55
|
+
for (const cycle of cycles) {
|
|
56
|
+
const cycleKey = [...cycle].sort().join('|')
|
|
57
|
+
if (reportedCycleKeys.has(cycleKey)) continue
|
|
58
|
+
reportedCycleKeys.add(cycleKey)
|
|
59
|
+
|
|
60
|
+
const firstFile = cycle[0]
|
|
61
|
+
if (!firstFile) continue
|
|
62
|
+
|
|
63
|
+
const cycleDisplay = cycle
|
|
64
|
+
.map(p => path.basename(p))
|
|
65
|
+
.concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
|
|
66
|
+
.join(' → ')
|
|
67
|
+
|
|
68
|
+
result.set(firstFile, {
|
|
69
|
+
rule: 'circular-dependency',
|
|
70
|
+
severity: ruleWeights['circular-dependency'].severity,
|
|
71
|
+
message: `Circular dependency detected: ${cycleDisplay}`,
|
|
72
|
+
line: 1,
|
|
73
|
+
column: 1,
|
|
74
|
+
snippet: cycleDisplay,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect layer violations based on user-defined layer configuration.
|
|
83
|
+
* Returns a map of filePath → issues[].
|
|
84
|
+
*/
|
|
85
|
+
function matchLayer(filePath: string, layers: LayerDefinition[]): LayerDefinition | undefined {
|
|
86
|
+
const rel = filePath.replace(/\\/g, '/')
|
|
87
|
+
return layers.find(layer =>
|
|
88
|
+
layer.patterns.some(pattern => {
|
|
89
|
+
const regexStr = pattern
|
|
90
|
+
.replace(/\\/g, '/')
|
|
91
|
+
.replace(/[.+^${}()|[\]]/g, '\\$&')
|
|
92
|
+
.replace(/\*\*/g, '###DOUBLESTAR###')
|
|
93
|
+
.replace(/\*/g, '[^/]*')
|
|
94
|
+
.replace(/###DOUBLESTAR###/g, '.*')
|
|
95
|
+
return new RegExp(`^${regexStr}`).test(rel)
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function detectLayerViolations(
|
|
101
|
+
importGraph: Map<string, Set<string>>,
|
|
102
|
+
layers: LayerDefinition[],
|
|
103
|
+
targetPath: string,
|
|
104
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
105
|
+
): Map<string, DriftIssue[]> {
|
|
106
|
+
const result = new Map<string, DriftIssue[]>()
|
|
107
|
+
|
|
108
|
+
for (const [filePath, imports] of importGraph.entries()) {
|
|
109
|
+
const fileLayer = matchLayer(filePath, layers)
|
|
110
|
+
if (!fileLayer) continue
|
|
111
|
+
|
|
112
|
+
for (const importedPath of imports) {
|
|
113
|
+
const importedLayer = matchLayer(importedPath, layers)
|
|
114
|
+
if (!importedLayer) continue
|
|
115
|
+
if (importedLayer.name === fileLayer.name) continue
|
|
116
|
+
|
|
117
|
+
if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
|
|
118
|
+
if (!result.has(filePath)) result.set(filePath, [])
|
|
119
|
+
result.get(filePath)!.push({
|
|
120
|
+
rule: 'layer-violation',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
|
|
123
|
+
line: 1,
|
|
124
|
+
column: 1,
|
|
125
|
+
snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Detect cross-boundary imports based on user-defined module boundary configuration.
|
|
136
|
+
* Returns a map of filePath → issues[].
|
|
137
|
+
*/
|
|
138
|
+
function matchModule(filePath: string, modules: ModuleBoundary[]): ModuleBoundary | undefined {
|
|
139
|
+
const rel = filePath.replace(/\\/g, '/')
|
|
140
|
+
return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isAllowedImport(importedPath: string, allowedImports: string[]): boolean {
|
|
144
|
+
const relImported = importedPath.replace(/\\/g, '/')
|
|
145
|
+
return allowedImports.some(allowed =>
|
|
146
|
+
relImported.startsWith(allowed.replace(/\\/g, '/'))
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function detectCrossBoundaryImports(
|
|
151
|
+
importGraph: Map<string, Set<string>>,
|
|
152
|
+
modules: ModuleBoundary[],
|
|
153
|
+
targetPath: string,
|
|
154
|
+
ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
|
|
155
|
+
): Map<string, DriftIssue[]> {
|
|
156
|
+
const result = new Map<string, DriftIssue[]>()
|
|
157
|
+
|
|
158
|
+
for (const [filePath, imports] of importGraph.entries()) {
|
|
159
|
+
const fileModule = matchModule(filePath, modules)
|
|
160
|
+
if (!fileModule) continue
|
|
161
|
+
|
|
162
|
+
for (const importedPath of imports) {
|
|
163
|
+
const importedModule = matchModule(importedPath, modules)
|
|
164
|
+
if (!importedModule) continue
|
|
165
|
+
if (importedModule.name === fileModule.name) continue
|
|
166
|
+
|
|
167
|
+
const allowedImports = fileModule.allowedExternalImports ?? []
|
|
168
|
+
if (!isAllowedImport(importedPath, allowedImports)) {
|
|
169
|
+
if (!result.has(filePath)) result.set(filePath, [])
|
|
170
|
+
result.get(filePath)!.push({
|
|
171
|
+
rule: 'cross-boundary-import',
|
|
172
|
+
severity: 'warning',
|
|
173
|
+
message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
|
|
174
|
+
line: 1,
|
|
175
|
+
column: 1,
|
|
176
|
+
snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { SyntaxKind, type SourceFile } from 'ts-morph'
|
|
2
|
+
import type { DriftConfig, DriftIssue } from '../types.js'
|
|
3
|
+
|
|
4
|
+
const DB_IMPORT_PATTERNS = [
|
|
5
|
+
/\bprisma\b/i,
|
|
6
|
+
/\btypeorm\b/i,
|
|
7
|
+
/\bsequelize\b/i,
|
|
8
|
+
/\bmongoose\b/i,
|
|
9
|
+
/\bknex\b/i,
|
|
10
|
+
/\brepository\b/i,
|
|
11
|
+
/\/db\//i,
|
|
12
|
+
/\/database\//i,
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const HTTP_IMPORT_PATTERNS = [
|
|
16
|
+
/\bexpress\b/i,
|
|
17
|
+
/\bfastify\b/i,
|
|
18
|
+
/\bkoa\b/i,
|
|
19
|
+
/\bhono\b/i,
|
|
20
|
+
/^http$/i,
|
|
21
|
+
/^https$/i,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function isControllerFile(filePath: string): boolean {
|
|
25
|
+
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
|
|
26
|
+
return normalized.includes('/controller/') || normalized.includes('/controllers/') || normalized.endsWith('controller.ts') || normalized.endsWith('controller.js')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isServiceFile(filePath: string): boolean {
|
|
30
|
+
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
|
|
31
|
+
return normalized.includes('/service/') || normalized.includes('/services/') || normalized.endsWith('service.ts') || normalized.endsWith('service.js')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createIssue(rule: string, message: string, line: number, snippet: string): DriftIssue {
|
|
35
|
+
return {
|
|
36
|
+
rule,
|
|
37
|
+
severity: 'warning',
|
|
38
|
+
message,
|
|
39
|
+
line,
|
|
40
|
+
column: 1,
|
|
41
|
+
snippet,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function detectControllerNoDb(file: SourceFile, config?: DriftConfig): DriftIssue[] {
|
|
46
|
+
if (!config?.architectureRules?.controllerNoDb) return []
|
|
47
|
+
if (!isControllerFile(file.getFilePath())) return []
|
|
48
|
+
|
|
49
|
+
const issues: DriftIssue[] = []
|
|
50
|
+
for (const decl of file.getImportDeclarations()) {
|
|
51
|
+
const value = decl.getModuleSpecifierValue()
|
|
52
|
+
if (DB_IMPORT_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
53
|
+
issues.push(createIssue(
|
|
54
|
+
'controller-no-db',
|
|
55
|
+
`Controller imports database module '${value}'. Controllers should delegate persistence through services.`,
|
|
56
|
+
decl.getStartLineNumber(),
|
|
57
|
+
value,
|
|
58
|
+
))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return issues
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function detectServiceNoHttp(file: SourceFile, config?: DriftConfig): DriftIssue[] {
|
|
66
|
+
if (!config?.architectureRules?.serviceNoHttp) return []
|
|
67
|
+
if (!isServiceFile(file.getFilePath())) return []
|
|
68
|
+
|
|
69
|
+
const issues: DriftIssue[] = []
|
|
70
|
+
for (const decl of file.getImportDeclarations()) {
|
|
71
|
+
const value = decl.getModuleSpecifierValue()
|
|
72
|
+
if (HTTP_IMPORT_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
73
|
+
issues.push(createIssue(
|
|
74
|
+
'service-no-http',
|
|
75
|
+
`Service imports HTTP framework '${value}'. Keep transport concerns outside service layer.`,
|
|
76
|
+
decl.getStartLineNumber(),
|
|
77
|
+
value,
|
|
78
|
+
))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
83
|
+
const expressionText = call.getExpression().getText()
|
|
84
|
+
if (/\bfetch\b/.test(expressionText)) {
|
|
85
|
+
issues.push(createIssue(
|
|
86
|
+
'service-no-http',
|
|
87
|
+
'Service executes HTTP call directly (fetch). Move this to an adapter/client.',
|
|
88
|
+
call.getStartLineNumber(),
|
|
89
|
+
expressionText,
|
|
90
|
+
))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return issues
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function detectMaxFunctionLines(file: SourceFile, config?: DriftConfig): DriftIssue[] {
|
|
98
|
+
const maxLines = config?.architectureRules?.maxFunctionLines
|
|
99
|
+
if (!maxLines || maxLines <= 0) return []
|
|
100
|
+
|
|
101
|
+
const issues: DriftIssue[] = []
|
|
102
|
+
|
|
103
|
+
for (const fn of file.getFunctions()) {
|
|
104
|
+
const body = fn.getBody()
|
|
105
|
+
if (!body) continue
|
|
106
|
+
const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1
|
|
107
|
+
if (lines > maxLines) {
|
|
108
|
+
issues.push(createIssue(
|
|
109
|
+
'max-function-lines',
|
|
110
|
+
`Function '${fn.getName() ?? '(anonymous)'}' has ${lines} lines (max: ${maxLines}).`,
|
|
111
|
+
fn.getStartLineNumber(),
|
|
112
|
+
fn.getName() ?? '(anonymous)',
|
|
113
|
+
))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const method of file.getDescendantsOfKind(SyntaxKind.MethodDeclaration)) {
|
|
118
|
+
const body = method.getBody()
|
|
119
|
+
if (!body) continue
|
|
120
|
+
const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1
|
|
121
|
+
if (lines > maxLines) {
|
|
122
|
+
issues.push(createIssue(
|
|
123
|
+
'max-function-lines',
|
|
124
|
+
`Method '${method.getName()}' has ${lines} lines (max: ${maxLines}).`,
|
|
125
|
+
method.getStartLineNumber(),
|
|
126
|
+
method.getName(),
|
|
127
|
+
))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return issues
|
|
132
|
+
}
|