@eduardbar/drift 1.0.0 → 1.2.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 +3 -1
- package/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +179 -6
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -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 +34 -0
- 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.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +206 -7
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +31 -1
- package/src/map.ts +219 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- 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 +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -0
|
@@ -1,302 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
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'
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
1
2
|
import * as fs from 'node:fs'
|
|
2
3
|
import * as path from 'node:path'
|
|
3
4
|
import { SourceFile } from 'ts-morph'
|
|
4
5
|
import type { DriftIssue } from '../types.js'
|
|
5
6
|
|
|
7
|
+
const SNIPPET_LENGTH = 80
|
|
8
|
+
// drift-ignore
|
|
9
|
+
const BIN_DIR = '/bin/'
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Detect files that are never imported by any other file in the project.
|
|
8
13
|
* Entry-point files (index, main, cli, app, bin/) are excluded.
|
|
@@ -17,7 +22,7 @@ export function detectDeadFiles(
|
|
|
17
22
|
for (const sf of sourceFiles) {
|
|
18
23
|
const sfPath = sf.getFilePath()
|
|
19
24
|
const basename = path.basename(sfPath)
|
|
20
|
-
const isBinFile = sfPath.replace(/\\/g, '/').includes(
|
|
25
|
+
const isBinFile = sfPath.replace(/\\/g, '/').includes(BIN_DIR)
|
|
21
26
|
const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile
|
|
22
27
|
|
|
23
28
|
if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
|
|
@@ -39,6 +44,64 @@ export function detectDeadFiles(
|
|
|
39
44
|
* Detect named exports that are never imported by any other file.
|
|
40
45
|
* Barrel files (index.*) are excluded since their entire surface is the public API.
|
|
41
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
|
+
|
|
42
105
|
export function detectUnusedExports(
|
|
43
106
|
sourceFiles: SourceFile[],
|
|
44
107
|
allImportedNames: Map<string, Set<string>>,
|
|
@@ -55,45 +118,10 @@ export function detectUnusedExports(
|
|
|
55
118
|
|
|
56
119
|
if (isBarrel || hasNamespaceImport) continue
|
|
57
120
|
|
|
58
|
-
const issues: DriftIssue[] = [
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
}
|
|
121
|
+
const issues: DriftIssue[] = [
|
|
122
|
+
...checkExportDeclarations(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
123
|
+
...checkInlineExports(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
124
|
+
]
|
|
97
125
|
|
|
98
126
|
if (issues.length > 0) {
|
|
99
127
|
result.set(sfPath, issues)
|
package/src/rules/phase3-arch.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
1
3
|
import * as path from 'node:path'
|
|
2
4
|
import type { DriftIssue, LayerDefinition, ModuleBoundary } from '../types.js'
|
|
3
5
|
|
|
@@ -80,6 +82,21 @@ export function detectCircularDependencies(
|
|
|
80
82
|
* Detect layer violations based on user-defined layer configuration.
|
|
81
83
|
* Returns a map of filePath → issues[].
|
|
82
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
|
+
|
|
83
100
|
export function detectLayerViolations(
|
|
84
101
|
importGraph: Map<string, Set<string>>,
|
|
85
102
|
layers: LayerDefinition[],
|
|
@@ -88,27 +105,12 @@ export function detectLayerViolations(
|
|
|
88
105
|
): Map<string, DriftIssue[]> {
|
|
89
106
|
const result = new Map<string, DriftIssue[]>()
|
|
90
107
|
|
|
91
|
-
function getLayer(filePath: string): LayerDefinition | undefined {
|
|
92
|
-
const rel = filePath.replace(/\\/g, '/')
|
|
93
|
-
return layers.find(layer =>
|
|
94
|
-
layer.patterns.some(pattern => {
|
|
95
|
-
const regexStr = pattern
|
|
96
|
-
.replace(/\\/g, '/')
|
|
97
|
-
.replace(/[.+^${}()|[\]]/g, '\\$&')
|
|
98
|
-
.replace(/\*\*/g, '###DOUBLESTAR###')
|
|
99
|
-
.replace(/\*/g, '[^/]*')
|
|
100
|
-
.replace(/###DOUBLESTAR###/g, '.*')
|
|
101
|
-
return new RegExp(`^${regexStr}`).test(rel)
|
|
102
|
-
})
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
108
|
for (const [filePath, imports] of importGraph.entries()) {
|
|
107
|
-
const fileLayer =
|
|
109
|
+
const fileLayer = matchLayer(filePath, layers)
|
|
108
110
|
if (!fileLayer) continue
|
|
109
111
|
|
|
110
112
|
for (const importedPath of imports) {
|
|
111
|
-
const importedLayer =
|
|
113
|
+
const importedLayer = matchLayer(importedPath, layers)
|
|
112
114
|
if (!importedLayer) continue
|
|
113
115
|
if (importedLayer.name === fileLayer.name) continue
|
|
114
116
|
|
|
@@ -133,6 +135,18 @@ export function detectLayerViolations(
|
|
|
133
135
|
* Detect cross-boundary imports based on user-defined module boundary configuration.
|
|
134
136
|
* Returns a map of filePath → issues[].
|
|
135
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
|
+
|
|
136
150
|
export function detectCrossBoundaryImports(
|
|
137
151
|
importGraph: Map<string, Set<string>>,
|
|
138
152
|
modules: ModuleBoundary[],
|
|
@@ -141,27 +155,17 @@ export function detectCrossBoundaryImports(
|
|
|
141
155
|
): Map<string, DriftIssue[]> {
|
|
142
156
|
const result = new Map<string, DriftIssue[]>()
|
|
143
157
|
|
|
144
|
-
function getModule(filePath: string): ModuleBoundary | undefined {
|
|
145
|
-
const rel = filePath.replace(/\\/g, '/')
|
|
146
|
-
return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')))
|
|
147
|
-
}
|
|
148
|
-
|
|
149
158
|
for (const [filePath, imports] of importGraph.entries()) {
|
|
150
|
-
const fileModule =
|
|
159
|
+
const fileModule = matchModule(filePath, modules)
|
|
151
160
|
if (!fileModule) continue
|
|
152
161
|
|
|
153
162
|
for (const importedPath of imports) {
|
|
154
|
-
const importedModule =
|
|
163
|
+
const importedModule = matchModule(importedPath, modules)
|
|
155
164
|
if (!importedModule) continue
|
|
156
165
|
if (importedModule.name === fileModule.name) continue
|
|
157
166
|
|
|
158
167
|
const allowedImports = fileModule.allowedExternalImports ?? []
|
|
159
|
-
|
|
160
|
-
const isAllowed = allowedImports.some(allowed =>
|
|
161
|
-
relImported.startsWith(allowed.replace(/\\/g, '/'))
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
if (!isAllowed) {
|
|
168
|
+
if (!isAllowedImport(importedPath, allowedImports)) {
|
|
165
169
|
if (!result.has(filePath)) result.set(filePath, [])
|
|
166
170
|
result.get(filePath)!.push({
|
|
167
171
|
rule: 'cross-boundary-import',
|
|
@@ -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
|
+
}
|