@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.
Files changed (105) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +3 -1
  4. package/.github/workflows/review-pr.yml +61 -0
  5. package/AGENTS.md +53 -11
  6. package/README.md +106 -1
  7. package/dist/analyzer.d.ts +6 -2
  8. package/dist/analyzer.js +116 -3
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +179 -6
  12. package/dist/diff.d.ts +0 -7
  13. package/dist/diff.js +26 -25
  14. package/dist/fix.d.ts +4 -0
  15. package/dist/fix.js +59 -47
  16. package/dist/git/trend.js +1 -0
  17. package/dist/git.d.ts +0 -9
  18. package/dist/git.js +25 -19
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.js +4 -0
  21. package/dist/map.d.ts +4 -0
  22. package/dist/map.js +191 -0
  23. package/dist/metrics.d.ts +4 -0
  24. package/dist/metrics.js +176 -0
  25. package/dist/plugins.d.ts +6 -0
  26. package/dist/plugins.js +74 -0
  27. package/dist/printer.js +20 -0
  28. package/dist/report.js +34 -0
  29. package/dist/reporter.js +85 -2
  30. package/dist/review.d.ts +15 -0
  31. package/dist/review.js +80 -0
  32. package/dist/rules/comments.d.ts +4 -0
  33. package/dist/rules/comments.js +45 -0
  34. package/dist/rules/complexity.d.ts +4 -0
  35. package/dist/rules/complexity.js +51 -0
  36. package/dist/rules/coupling.d.ts +4 -0
  37. package/dist/rules/coupling.js +19 -0
  38. package/dist/rules/magic.d.ts +4 -0
  39. package/dist/rules/magic.js +33 -0
  40. package/dist/rules/nesting.d.ts +5 -0
  41. package/dist/rules/nesting.js +82 -0
  42. package/dist/rules/phase0-basic.js +14 -7
  43. package/dist/rules/phase1-complexity.d.ts +6 -30
  44. package/dist/rules/phase1-complexity.js +7 -276
  45. package/dist/rules/phase2-crossfile.d.ts +0 -4
  46. package/dist/rules/phase2-crossfile.js +52 -39
  47. package/dist/rules/phase3-arch.d.ts +0 -8
  48. package/dist/rules/phase3-arch.js +26 -23
  49. package/dist/rules/phase3-configurable.d.ts +6 -0
  50. package/dist/rules/phase3-configurable.js +97 -0
  51. package/dist/rules/phase8-semantic.d.ts +0 -5
  52. package/dist/rules/phase8-semantic.js +30 -29
  53. package/dist/rules/promise.d.ts +4 -0
  54. package/dist/rules/promise.js +24 -0
  55. package/dist/saas.d.ts +83 -0
  56. package/dist/saas.js +321 -0
  57. package/dist/snapshot.d.ts +19 -0
  58. package/dist/snapshot.js +119 -0
  59. package/dist/types.d.ts +75 -0
  60. package/dist/utils.d.ts +2 -1
  61. package/dist/utils.js +1 -0
  62. package/docs/AGENTS.md +146 -0
  63. package/docs/PRD.md +157 -0
  64. package/package.json +1 -1
  65. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  66. package/packages/vscode-drift/package.json +1 -1
  67. package/packages/vscode-drift/src/analyzer.ts +2 -0
  68. package/packages/vscode-drift/src/code-actions.ts +53 -0
  69. package/packages/vscode-drift/src/extension.ts +98 -63
  70. package/packages/vscode-drift/src/statusbar.ts +13 -5
  71. package/packages/vscode-drift/src/treeview.ts +2 -0
  72. package/src/analyzer.ts +144 -12
  73. package/src/badge.ts +38 -16
  74. package/src/ci.ts +38 -17
  75. package/src/cli.ts +206 -7
  76. package/src/diff.ts +36 -30
  77. package/src/fix.ts +77 -53
  78. package/src/git/trend.ts +3 -2
  79. package/src/git.ts +31 -22
  80. package/src/index.ts +31 -1
  81. package/src/map.ts +219 -0
  82. package/src/metrics.ts +200 -0
  83. package/src/plugins.ts +76 -0
  84. package/src/printer.ts +20 -0
  85. package/src/report.ts +35 -0
  86. package/src/reporter.ts +95 -2
  87. package/src/review.ts +98 -0
  88. package/src/rules/comments.ts +56 -0
  89. package/src/rules/complexity.ts +57 -0
  90. package/src/rules/coupling.ts +23 -0
  91. package/src/rules/magic.ts +38 -0
  92. package/src/rules/nesting.ts +88 -0
  93. package/src/rules/phase0-basic.ts +14 -7
  94. package/src/rules/phase1-complexity.ts +8 -302
  95. package/src/rules/phase2-crossfile.ts +68 -40
  96. package/src/rules/phase3-arch.ts +34 -30
  97. package/src/rules/phase3-configurable.ts +132 -0
  98. package/src/rules/phase8-semantic.ts +33 -29
  99. package/src/rules/promise.ts +29 -0
  100. package/src/saas.ts +433 -0
  101. package/src/snapshot.ts +175 -0
  102. package/src/types.ts +81 -1
  103. package/src/utils.ts +3 -1
  104. package/tests/new-features.test.ts +180 -0
  105. package/tests/saas-foundation.test.ts +107 -0
@@ -1,302 +1,8 @@
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
- }
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('/bin/')
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
- 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
- }
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)
@@ -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 = getLayer(filePath)
109
+ const fileLayer = matchLayer(filePath, layers)
108
110
  if (!fileLayer) continue
109
111
 
110
112
  for (const importedPath of imports) {
111
- const importedLayer = getLayer(importedPath)
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 = getModule(filePath)
159
+ const fileModule = matchModule(filePath, modules)
151
160
  if (!fileModule) continue
152
161
 
153
162
  for (const importedPath of imports) {
154
- const importedModule = getModule(importedPath)
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
- const relImported = importedPath.replace(/\\/g, '/')
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
+ }