@eduardbar/drift 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-vscode.yml +76 -0
- package/AGENTS.md +30 -12
- package/CHANGELOG.md +9 -0
- package/README.md +273 -168
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +4 -38
- package/dist/analyzer.js +85 -1510
- package/dist/cli.js +47 -4
- package/dist/config.js +1 -1
- package/dist/fix.d.ts +13 -0
- package/dist/fix.js +120 -0
- package/dist/git/blame.d.ts +22 -0
- package/dist/git/blame.js +227 -0
- package/dist/git/helpers.d.ts +36 -0
- package/dist/git/helpers.js +152 -0
- package/dist/git/trend.d.ts +21 -0
- package/dist/git/trend.js +80 -0
- package/dist/git.d.ts +0 -4
- package/dist/git.js +2 -2
- package/dist/report.js +620 -293
- package/dist/rules/phase0-basic.d.ts +11 -0
- package/dist/rules/phase0-basic.js +176 -0
- package/dist/rules/phase1-complexity.d.ts +31 -0
- package/dist/rules/phase1-complexity.js +277 -0
- package/dist/rules/phase2-crossfile.d.ts +27 -0
- package/dist/rules/phase2-crossfile.js +122 -0
- package/dist/rules/phase3-arch.d.ts +31 -0
- package/dist/rules/phase3-arch.js +148 -0
- package/dist/rules/phase5-ai.d.ts +8 -0
- package/dist/rules/phase5-ai.js +262 -0
- package/dist/rules/phase8-semantic.d.ts +22 -0
- package/dist/rules/phase8-semantic.js +109 -0
- package/dist/rules/shared.d.ts +7 -0
- package/dist/rules/shared.js +27 -0
- package/package.json +8 -3
- package/packages/vscode-drift/.vscodeignore +9 -0
- package/packages/vscode-drift/LICENSE +21 -0
- package/packages/vscode-drift/README.md +64 -0
- package/packages/vscode-drift/images/icon.png +0 -0
- package/packages/vscode-drift/images/icon.svg +30 -0
- package/packages/vscode-drift/package-lock.json +485 -0
- package/packages/vscode-drift/package.json +119 -0
- package/packages/vscode-drift/src/analyzer.ts +38 -0
- package/packages/vscode-drift/src/diagnostics.ts +55 -0
- package/packages/vscode-drift/src/extension.ts +111 -0
- package/packages/vscode-drift/src/statusbar.ts +47 -0
- package/packages/vscode-drift/src/treeview.ts +108 -0
- package/packages/vscode-drift/tsconfig.json +18 -0
- package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
- package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
- package/src/analyzer.ts +124 -1726
- package/src/cli.ts +53 -4
- package/src/config.ts +1 -1
- package/src/fix.ts +154 -0
- package/src/git/blame.ts +279 -0
- package/src/git/helpers.ts +198 -0
- package/src/git/trend.ts +116 -0
- package/src/git.ts +2 -2
- package/src/report.ts +631 -296
- package/src/rules/phase0-basic.ts +187 -0
- package/src/rules/phase1-complexity.ts +302 -0
- package/src/rules/phase2-crossfile.ts +149 -0
- package/src/rules/phase3-arch.ts +179 -0
- package/src/rules/phase5-ai.ts +292 -0
- package/src/rules/phase8-semantic.ts +132 -0
- package/src/rules/shared.ts +39 -0
- package/tests/helpers.ts +45 -0
- package/tests/rules.test.ts +1269 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
|
2
|
+
import { analyzeCode, getRules, countRule, generateLines, generateFunction } from './helpers.js'
|
|
3
|
+
import { applyFixes } from '../src/fix.js'
|
|
4
|
+
import { analyzeProject } from '../src/analyzer.js'
|
|
5
|
+
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// large-file (threshold: > 300 lines)
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
describe('large-file', () => {
|
|
13
|
+
it('detects file with more than 300 lines', () => {
|
|
14
|
+
const code = generateLines(310)
|
|
15
|
+
expect(getRules(code)).toContain('large-file')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not detect file with exactly 300 lines', () => {
|
|
19
|
+
const code = generateLines(300)
|
|
20
|
+
expect(getRules(code)).not.toContain('large-file')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not detect file with fewer than 300 lines', () => {
|
|
24
|
+
const code = generateLines(10)
|
|
25
|
+
expect(getRules(code)).not.toContain('large-file')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports exactly one issue per file', () => {
|
|
29
|
+
const code = generateLines(350)
|
|
30
|
+
expect(countRule(code, 'large-file')).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// large-function (threshold: > 50 lines)
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
describe('large-function', () => {
|
|
38
|
+
it('detects function with more than 50 lines', () => {
|
|
39
|
+
const code = generateFunction(55)
|
|
40
|
+
expect(getRules(code)).toContain('large-function')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not detect function with exactly 50 lines (end - start = 50)', () => {
|
|
44
|
+
// generateFunction(49) → end - start = 50, which is NOT > 50 → no trigger
|
|
45
|
+
const code = generateFunction(49)
|
|
46
|
+
expect(getRules(code)).not.toContain('large-function')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not detect small functions', () => {
|
|
50
|
+
const code = `function small(): void { const x = 1 }`
|
|
51
|
+
expect(getRules(code)).not.toContain('large-function')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('detects large arrow functions', () => {
|
|
55
|
+
const body = Array.from({ length: 55 }, (_, i) => ` const _a${i} = ${i}`).join('\n')
|
|
56
|
+
const code = `const fn = (): void => {\n${body}\n}`
|
|
57
|
+
expect(getRules(code)).toContain('large-function')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('detects large class methods', () => {
|
|
61
|
+
const body = Array.from({ length: 55 }, (_, i) => ` const _m${i} = ${i}`).join('\n')
|
|
62
|
+
const code = `class Foo {\n bar(): void {\n${body}\n }\n}`
|
|
63
|
+
expect(getRules(code)).toContain('large-function')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// debug-leftover (console.log/warn/error/debug/info + TODO/FIXME/HACK/XXX/TEMP)
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
describe('debug-leftover', () => {
|
|
71
|
+
it('detects console.log', () => {
|
|
72
|
+
expect(getRules(`const x = 1\nconsole.log(x)`)).toContain('debug-leftover')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('detects console.warn', () => {
|
|
76
|
+
expect(getRules(`console.warn('test')`)).toContain('debug-leftover')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('detects console.error', () => {
|
|
80
|
+
expect(getRules(`console.error('err')`)).toContain('debug-leftover')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('detects console.debug', () => {
|
|
84
|
+
expect(getRules(`console.debug('val')`)).toContain('debug-leftover')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('detects TODO comment marker', () => {
|
|
88
|
+
expect(getRules(`// TODO: implement this\nconst x = 1`)).toContain('debug-leftover')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('detects FIXME marker', () => {
|
|
92
|
+
expect(getRules(`// FIXME: broken logic\nconst x = 1`)).toContain('debug-leftover')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('detects HACK marker', () => {
|
|
96
|
+
expect(getRules(`// HACK: workaround\nconst x = 1`)).toContain('debug-leftover')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not detect clean code', () => {
|
|
100
|
+
expect(getRules(`function greet(name: string): string { return 'Hello ' + name }`)).not.toContain('debug-leftover')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
// dead-code (unused named imports)
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
describe('dead-code', () => {
|
|
108
|
+
it('detects unused named import', () => {
|
|
109
|
+
const code = `import { readFile } from 'fs'\nconst x = 1`
|
|
110
|
+
expect(getRules(code)).toContain('dead-code')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('detects multiple unused named imports', () => {
|
|
114
|
+
const code = `import { readFile, writeFile } from 'fs'\nconst x = 1`
|
|
115
|
+
expect(countRule(code, 'dead-code')).toBeGreaterThanOrEqual(1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('does not detect used named import', () => {
|
|
119
|
+
const code = `import { join } from 'path'\nconst p = join('a', 'b')`
|
|
120
|
+
expect(getRules(code)).not.toContain('dead-code')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not detect default imports (only named)', () => {
|
|
124
|
+
const code = `import fs from 'fs'\nconst x = 1`
|
|
125
|
+
expect(getRules(code)).not.toContain('dead-code')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
// duplicate-function-name (case-insensitive, normalized: _/- removed)
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
describe('duplicate-function-name', () => {
|
|
133
|
+
it('detects two functions with the same name', () => {
|
|
134
|
+
const code = `function processData(): void {}\nfunction processData(): void {}`
|
|
135
|
+
expect(getRules(code)).toContain('duplicate-function-name')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('detects case-insensitive duplicates', () => {
|
|
139
|
+
const code = `function processData(): void {}\nfunction ProcessData(): void {}`
|
|
140
|
+
expect(getRules(code)).toContain('duplicate-function-name')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('detects snake_case vs camelCase duplicates (normalized)', () => {
|
|
144
|
+
const code = `function processData(): void {}\nfunction process_data(): void {}`
|
|
145
|
+
expect(getRules(code)).toContain('duplicate-function-name')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('does not detect functions with different names', () => {
|
|
149
|
+
const code = `function fetchData(): void {}\nfunction saveData(): void {}`
|
|
150
|
+
expect(getRules(code)).not.toContain('duplicate-function-name')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not trigger on single function', () => {
|
|
154
|
+
const code = `function doWork(): void {}`
|
|
155
|
+
expect(getRules(code)).not.toContain('duplicate-function-name')
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
// any-abuse (each 'any' type annotation triggers one issue)
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
describe('any-abuse', () => {
|
|
163
|
+
it('detects explicit any type annotation', () => {
|
|
164
|
+
expect(getRules(`const a: any = 1`)).toContain('any-abuse')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('detects multiple any annotations', () => {
|
|
168
|
+
const code = `const a: any = 1\nconst b: any = 2\nconst c: any = 3`
|
|
169
|
+
expect(countRule(code, 'any-abuse')).toBe(3)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not detect properly typed code', () => {
|
|
173
|
+
expect(getRules(`const a: string = 'hello'\nconst b: number = 42`)).not.toContain('any-abuse')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('detects any in function parameters', () => {
|
|
177
|
+
expect(getRules(`function f(x: any): void {}`)).toContain('any-abuse')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// catch-swallow (empty catch block)
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
describe('catch-swallow', () => {
|
|
185
|
+
it('detects empty catch block', () => {
|
|
186
|
+
const code = `try { const x = 1 } catch (e) {}`
|
|
187
|
+
expect(getRules(code)).toContain('catch-swallow')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('does not detect catch with error handling', () => {
|
|
191
|
+
const code = `try { const x = 1 } catch (e) { console.error(e) }`
|
|
192
|
+
expect(getRules(code)).not.toContain('catch-swallow')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('does not detect catch with throw rethrow', () => {
|
|
196
|
+
const code = `try { const x = 1 } catch (e) { throw e }`
|
|
197
|
+
expect(getRules(code)).not.toContain('catch-swallow')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('detects nested empty catch blocks', () => {
|
|
201
|
+
const code = `
|
|
202
|
+
function outer(): void {
|
|
203
|
+
try {
|
|
204
|
+
try { const x = 1 } catch (e) {}
|
|
205
|
+
} catch (e) {}
|
|
206
|
+
}`
|
|
207
|
+
expect(countRule(code, 'catch-swallow')).toBe(2)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
// no-return-type (function declarations without explicit return type)
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
describe('no-return-type', () => {
|
|
215
|
+
it('detects function without return type annotation', () => {
|
|
216
|
+
expect(getRules(`function greet(name: string) { return 'Hello ' + name }`)).toContain('no-return-type')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not detect function with explicit return type', () => {
|
|
220
|
+
expect(getRules(`function greet(name: string): string { return 'Hello ' + name }`)).not.toContain('no-return-type')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('detects multiple functions without return types', () => {
|
|
224
|
+
const code = `function a() { return 1 }\nfunction b() { return 2 }`
|
|
225
|
+
expect(countRule(code, 'no-return-type')).toBe(2)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('does not flag functions with void return type', () => {
|
|
229
|
+
expect(getRules(`function log(msg: string): void { return }`)).not.toContain('no-return-type')
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// high-complexity (cyclomatic complexity > 10)
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
describe('high-complexity', () => {
|
|
237
|
+
it('detects function with complexity > 10', () => {
|
|
238
|
+
// 11 if-statements = complexity 12 (base 1 + 11)
|
|
239
|
+
const ifs = Array.from({ length: 11 }, (_, i) => ` if (x === ${i}) return ${i}`).join('\n')
|
|
240
|
+
const code = `function check(x: number): number {\n${ifs}\n return -1\n}`
|
|
241
|
+
expect(getRules(code)).toContain('high-complexity')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not detect simple function with complexity <= 10', () => {
|
|
245
|
+
const code = `function simple(x: number): number {
|
|
246
|
+
if (x > 0) return 1
|
|
247
|
+
if (x < 0) return -1
|
|
248
|
+
return 0
|
|
249
|
+
}`
|
|
250
|
+
expect(getRules(code)).not.toContain('high-complexity')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('counts &&/|| operators as complexity increments', () => {
|
|
254
|
+
// base 1 + 5 ifs + 5 && = 11 → complexity > 10
|
|
255
|
+
const code = `function validate(a: number, b: number, c: number, d: number, e: number, f: number): boolean {
|
|
256
|
+
if (a > 0 && b > 0) return false
|
|
257
|
+
if (c > 0 && d > 0) return false
|
|
258
|
+
if (e > 0 && f > 0) return false
|
|
259
|
+
if (a > 1 && b > 1) return false
|
|
260
|
+
if (c > 1 && d > 1) return false
|
|
261
|
+
return true
|
|
262
|
+
}`
|
|
263
|
+
expect(getRules(code)).toContain('high-complexity')
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// deep-nesting (max nesting depth > 3)
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
describe('deep-nesting', () => {
|
|
271
|
+
it('detects nesting depth > 3', () => {
|
|
272
|
+
const code = `function deeply(): void {
|
|
273
|
+
if (true) {
|
|
274
|
+
if (true) {
|
|
275
|
+
if (true) {
|
|
276
|
+
if (true) {
|
|
277
|
+
const x = 1
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}`
|
|
283
|
+
expect(getRules(code)).toContain('deep-nesting')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('does not detect nesting depth of 3', () => {
|
|
287
|
+
const code = `function normal(): void {
|
|
288
|
+
if (true) {
|
|
289
|
+
if (true) {
|
|
290
|
+
if (true) {
|
|
291
|
+
const x = 1
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}`
|
|
296
|
+
expect(getRules(code)).not.toContain('deep-nesting')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('does not detect flat code', () => {
|
|
300
|
+
const code = `function flat(x: number): number {
|
|
301
|
+
const a = x + 1
|
|
302
|
+
const b = a * 2
|
|
303
|
+
return b
|
|
304
|
+
}`
|
|
305
|
+
expect(getRules(code)).not.toContain('deep-nesting')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('detects deep nesting with mixed control flow', () => {
|
|
309
|
+
const code = `function mixed(): void {
|
|
310
|
+
for (let i = 0; i < 10; i++) {
|
|
311
|
+
while (true) {
|
|
312
|
+
try {
|
|
313
|
+
if (i > 5) {
|
|
314
|
+
const x = i
|
|
315
|
+
}
|
|
316
|
+
} catch (e) { throw e }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}`
|
|
320
|
+
expect(getRules(code)).toContain('deep-nesting')
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
325
|
+
// too-many-params (> 4 parameters)
|
|
326
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
327
|
+
describe('too-many-params', () => {
|
|
328
|
+
it('detects function with 5 parameters', () => {
|
|
329
|
+
const code = `function f(a: string, b: number, c: boolean, d: string, e: number): void {}`
|
|
330
|
+
expect(getRules(code)).toContain('too-many-params')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('does not detect function with exactly 4 parameters', () => {
|
|
334
|
+
const code = `function f(a: string, b: number, c: boolean, d: string): void {}`
|
|
335
|
+
expect(getRules(code)).not.toContain('too-many-params')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('does not detect function with 2 parameters', () => {
|
|
339
|
+
const code = `function f(a: string, b: number): void {}`
|
|
340
|
+
expect(getRules(code)).not.toContain('too-many-params')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('detects arrow function with too many params', () => {
|
|
344
|
+
const code = `const fn = (a: string, b: number, c: boolean, d: string, e: number): void => {}`
|
|
345
|
+
expect(getRules(code)).toContain('too-many-params')
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
// high-coupling (> 10 distinct import sources)
|
|
351
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
352
|
+
describe('high-coupling', () => {
|
|
353
|
+
it('detects more than 10 distinct imports', () => {
|
|
354
|
+
const imports = Array.from({ length: 11 }, (_, i) => `import _m${i} from 'module${i}'`).join('\n')
|
|
355
|
+
expect(getRules(imports + '\nconst x = 1')).toContain('high-coupling')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('does not detect exactly 10 distinct imports', () => {
|
|
359
|
+
const imports = Array.from({ length: 10 }, (_, i) => `import _m${i} from 'module${i}'`).join('\n')
|
|
360
|
+
expect(getRules(imports + '\nconst x = 1')).not.toContain('high-coupling')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('does not detect few imports', () => {
|
|
364
|
+
const code = `import { readFile } from 'fs'\nimport { join } from 'path'\nconst x = 1`
|
|
365
|
+
expect(getRules(code)).not.toContain('high-coupling')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('reports exactly one issue', () => {
|
|
369
|
+
const imports = Array.from({ length: 12 }, (_, i) => `import _m${i} from 'module${i}'`).join('\n')
|
|
370
|
+
expect(countRule(imports + '\nconst x = 1', 'high-coupling')).toBe(1)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
// promise-style-mix (both .then() and async/await in the same file)
|
|
376
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
377
|
+
describe('promise-style-mix', () => {
|
|
378
|
+
it('detects mix of .then() and async/await', () => {
|
|
379
|
+
const code = `
|
|
380
|
+
async function fetchData(): Promise<void> {
|
|
381
|
+
const result = await fetch('http://example.com')
|
|
382
|
+
}
|
|
383
|
+
function loadData(): void {
|
|
384
|
+
fetch('http://example.com').then(r => r.json())
|
|
385
|
+
}
|
|
386
|
+
`
|
|
387
|
+
expect(getRules(code)).toContain('promise-style-mix')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('does not detect when only async/await is used', () => {
|
|
391
|
+
const code = `
|
|
392
|
+
async function fetchData(): Promise<void> {
|
|
393
|
+
const result = await fetch('http://example.com')
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
`
|
|
397
|
+
expect(getRules(code)).not.toContain('promise-style-mix')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('does not detect when only .then() is used', () => {
|
|
401
|
+
const code = `
|
|
402
|
+
function loadData(): void {
|
|
403
|
+
fetch('http://example.com').then(r => r.json()).catch(err => { throw err })
|
|
404
|
+
}
|
|
405
|
+
`
|
|
406
|
+
expect(getRules(code)).not.toContain('promise-style-mix')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('reports exactly one issue per file', () => {
|
|
410
|
+
const code = `
|
|
411
|
+
async function a(): Promise<void> { await Promise.resolve() }
|
|
412
|
+
async function b(): Promise<void> { await Promise.resolve() }
|
|
413
|
+
function c(): void { Promise.resolve().then(() => {}) }
|
|
414
|
+
`
|
|
415
|
+
expect(countRule(code, 'promise-style-mix')).toBe(1)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
420
|
+
// magic-number (numeric literals not in [0,1,-1,2,100], not in variable declarations)
|
|
421
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
422
|
+
describe('magic-number', () => {
|
|
423
|
+
it('detects magic number in if condition', () => {
|
|
424
|
+
const code = `function check(x: number): boolean { if (x > 42) return true; return false }`
|
|
425
|
+
expect(getRules(code)).toContain('magic-number')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('detects magic number in binary expression', () => {
|
|
429
|
+
const code = `function calc(x: number): number { return x * 1000 }`
|
|
430
|
+
expect(getRules(code)).toContain('magic-number')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('does not detect 0 (allowed)', () => {
|
|
434
|
+
const code = `function isZero(x: number): boolean { return x === 0 }`
|
|
435
|
+
expect(getRules(code)).not.toContain('magic-number')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('does not detect 1 (allowed)', () => {
|
|
439
|
+
const code = `function increment(x: number): number { return x + 1 }`
|
|
440
|
+
expect(getRules(code)).not.toContain('magic-number')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('does not detect 2 (allowed)', () => {
|
|
444
|
+
const code = `function double(x: number): number { return x * 2 }`
|
|
445
|
+
expect(getRules(code)).not.toContain('magic-number')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('does not detect 100 (allowed)', () => {
|
|
449
|
+
const code = `function percentage(x: number): number { return x / 100 }`
|
|
450
|
+
expect(getRules(code)).not.toContain('magic-number')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('does not detect number in variable declaration (that IS the named constant)', () => {
|
|
454
|
+
// VariableDeclaration parent is skipped — the const IS the named constant
|
|
455
|
+
const code = `const TIMEOUT_MS = 3000`
|
|
456
|
+
expect(getRules(code)).not.toContain('magic-number')
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
461
|
+
// comment-contradiction (trivial comments restating the code)
|
|
462
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
463
|
+
describe('comment-contradiction', () => {
|
|
464
|
+
it('detects "// return" comment above return statement', () => {
|
|
465
|
+
const code = `function f(): number {\n // return the value\n return 42\n}`
|
|
466
|
+
expect(getRules(code)).toContain('comment-contradiction')
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('detects "// increment" comment above x++', () => {
|
|
470
|
+
const code = `function f(): void {\n let x = 0\n // increment x\n x++\n}`
|
|
471
|
+
expect(getRules(code)).toContain('comment-contradiction')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('detects "// check if" above if statement', () => {
|
|
475
|
+
const code = `function f(x: number): void {\n // check if positive\n if (x > 0) {}\n}`
|
|
476
|
+
expect(getRules(code)).toContain('comment-contradiction')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('detects "// loop" above for statement', () => {
|
|
480
|
+
const code = `function f(): void {\n // loop over items\n for (let i = 0; i < 10; i++) {}\n}`
|
|
481
|
+
expect(getRules(code)).toContain('comment-contradiction')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('detects "// declare" above const declaration', () => {
|
|
485
|
+
const code = `function f(): void {\n // declare counter\n const counter = 0\n}`
|
|
486
|
+
expect(getRules(code)).toContain('comment-contradiction')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('does not detect meaningful comments', () => {
|
|
490
|
+
const code = `function f(x: number): number {\n // Early exit to avoid division by zero\n if (x === 0) return 0\n return 100 / x\n}`
|
|
491
|
+
expect(getRules(code)).not.toContain('comment-contradiction')
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
496
|
+
// over-commented (>= 40% comment density in function body with >= 6 lines)
|
|
497
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
498
|
+
describe('over-commented', () => {
|
|
499
|
+
it('detects function with >= 40% comment density and >= 6 total lines', () => {
|
|
500
|
+
// 10 lines total, 5 comments = 50%
|
|
501
|
+
const code = `function heavilyCommented(): void {
|
|
502
|
+
// step 1
|
|
503
|
+
const a = 1
|
|
504
|
+
// step 2
|
|
505
|
+
const b = 2
|
|
506
|
+
// step 3
|
|
507
|
+
const c = 3
|
|
508
|
+
// step 4
|
|
509
|
+
const d = 4
|
|
510
|
+
// step 5
|
|
511
|
+
return
|
|
512
|
+
}`
|
|
513
|
+
expect(getRules(code)).toContain('over-commented')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('does not detect function with < 40% comment density', () => {
|
|
517
|
+
const code = `function lightComment(): void {
|
|
518
|
+
// initialize
|
|
519
|
+
const a = 1
|
|
520
|
+
const b = 2
|
|
521
|
+
const c = 3
|
|
522
|
+
const d = 4
|
|
523
|
+
const e = 5
|
|
524
|
+
return
|
|
525
|
+
}`
|
|
526
|
+
expect(getRules(code)).not.toContain('over-commented')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('does not detect short functions (< 6 lines body)', () => {
|
|
530
|
+
const code = `function tiny(): void {
|
|
531
|
+
// comment
|
|
532
|
+
const a = 1
|
|
533
|
+
// comment
|
|
534
|
+
return
|
|
535
|
+
}`
|
|
536
|
+
expect(getRules(code)).not.toContain('over-commented')
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
541
|
+
// hardcoded-config (URLs, IPs, connection strings in string literals)
|
|
542
|
+
// NOTE: analyzeCode uses 'test.ts' by default but hardcoded-config skips .test. files.
|
|
543
|
+
// We use a non-test filePath here.
|
|
544
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
545
|
+
describe('hardcoded-config', () => {
|
|
546
|
+
it('detects postgresql connection string', () => {
|
|
547
|
+
const code = `const DB = 'postgresql://user:pass@localhost:5432/db'`
|
|
548
|
+
expect(getRules(code, undefined, 'src/config.ts')).toContain('hardcoded-config')
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('detects mongodb connection string', () => {
|
|
552
|
+
const code = `const MONGO = 'mongodb://localhost:27017/mydb'`
|
|
553
|
+
expect(getRules(code, undefined, 'src/db.ts')).toContain('hardcoded-config')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('detects HTTP URL', () => {
|
|
557
|
+
const code = `const API = 'https://api.example.com/v1'`
|
|
558
|
+
expect(getRules(code, undefined, 'src/api.ts')).toContain('hardcoded-config')
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('detects redis connection string', () => {
|
|
562
|
+
const code = `const CACHE = 'redis://localhost:6379'`
|
|
563
|
+
expect(getRules(code, undefined, 'src/cache.ts')).toContain('hardcoded-config')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('detects IP address', () => {
|
|
567
|
+
const code = `const HOST = '192.168.1.100'`
|
|
568
|
+
expect(getRules(code, undefined, 'src/server.ts')).toContain('hardcoded-config')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('does not detect process.env usage', () => {
|
|
572
|
+
const code = `const DB = process.env.DATABASE_URL`
|
|
573
|
+
expect(getRules(code, undefined, 'src/config.ts')).not.toContain('hardcoded-config')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('does not detect hardcoded config in spec files (pattern .spec.)', () => {
|
|
577
|
+
// Only .test. / .spec. / __tests__ are skipped — test.ts (no dot before 'test') is NOT skipped
|
|
578
|
+
const code = `const DB = 'postgresql://user:pass@localhost:5432/db'`
|
|
579
|
+
expect(getRules(code, undefined, 'src/db.spec.ts')).not.toContain('hardcoded-config')
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('does not flag import paths', () => {
|
|
583
|
+
// Import strings are explicitly skipped
|
|
584
|
+
const code = `import { foo } from './foo'`
|
|
585
|
+
expect(getRules(code, undefined, 'src/bar.ts')).not.toContain('hardcoded-config')
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
590
|
+
// inconsistent-error-handling (mix of try/catch + .catch() + .then(_, handler))
|
|
591
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
592
|
+
describe('inconsistent-error-handling', () => {
|
|
593
|
+
it('detects mix of try/catch and .catch()', () => {
|
|
594
|
+
const code = `
|
|
595
|
+
function a(): void {
|
|
596
|
+
try { const x = 1 } catch (e) { throw e }
|
|
597
|
+
}
|
|
598
|
+
function b(): void {
|
|
599
|
+
fetch('url').catch(err => { throw err })
|
|
600
|
+
}
|
|
601
|
+
`
|
|
602
|
+
expect(getRules(code)).toContain('inconsistent-error-handling')
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('detects mix of try/catch and .then(_, handler)', () => {
|
|
606
|
+
const code = `
|
|
607
|
+
function a(): void {
|
|
608
|
+
try { const x = 1 } catch (e) { throw e }
|
|
609
|
+
}
|
|
610
|
+
function b(): void {
|
|
611
|
+
fetch('url').then(() => {}, err => { throw err })
|
|
612
|
+
}
|
|
613
|
+
`
|
|
614
|
+
expect(getRules(code)).toContain('inconsistent-error-handling')
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('does not detect consistent try/catch only', () => {
|
|
618
|
+
const code = `
|
|
619
|
+
function a(): void { try { const x = 1 } catch (e) { throw e } }
|
|
620
|
+
function b(): void { try { const y = 2 } catch (e) { throw e } }
|
|
621
|
+
`
|
|
622
|
+
expect(getRules(code)).not.toContain('inconsistent-error-handling')
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('does not detect consistent .catch() only', () => {
|
|
626
|
+
const code = `
|
|
627
|
+
function a(): void { fetch('url').catch(e => { throw e }) }
|
|
628
|
+
function b(): void { fetch('url2').catch(e => { throw e }) }
|
|
629
|
+
`
|
|
630
|
+
expect(getRules(code)).not.toContain('inconsistent-error-handling')
|
|
631
|
+
})
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
635
|
+
// unnecessary-abstraction (interface with 1 method used <= 2 times)
|
|
636
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
637
|
+
describe('unnecessary-abstraction', () => {
|
|
638
|
+
it('detects interface with 1 method used only once', () => {
|
|
639
|
+
const code = `
|
|
640
|
+
interface Fetcher {
|
|
641
|
+
fetch(url: string): Promise<string>
|
|
642
|
+
}
|
|
643
|
+
const impl: Fetcher = { fetch: async (url) => url }
|
|
644
|
+
`
|
|
645
|
+
expect(getRules(code, undefined, 'src/service.ts')).toContain('unnecessary-abstraction')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('does not detect interface with multiple methods', () => {
|
|
649
|
+
const code = `
|
|
650
|
+
interface Repository {
|
|
651
|
+
find(id: string): string
|
|
652
|
+
save(data: string): void
|
|
653
|
+
delete(id: string): void
|
|
654
|
+
}
|
|
655
|
+
const impl: Repository = { find: (id) => id, save: () => {}, delete: () => {} }
|
|
656
|
+
`
|
|
657
|
+
expect(getRules(code, undefined, 'src/repo.ts')).not.toContain('unnecessary-abstraction')
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('does not detect interface with properties', () => {
|
|
661
|
+
const code = `
|
|
662
|
+
interface Config {
|
|
663
|
+
url: string
|
|
664
|
+
fetch(url: string): Promise<string>
|
|
665
|
+
}
|
|
666
|
+
const c: Config = { url: 'x', fetch: async (u) => u }
|
|
667
|
+
`
|
|
668
|
+
expect(getRules(code, undefined, 'src/config.ts')).not.toContain('unnecessary-abstraction')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('detects abstract class with 1 abstract method used <= 2 times', () => {
|
|
672
|
+
// 'Handler' appears: 1 (declaration) + 1 (extends clause) = 2 times → <= 2 triggers
|
|
673
|
+
const code = `
|
|
674
|
+
abstract class Handler {
|
|
675
|
+
abstract handle(data: string): void
|
|
676
|
+
}
|
|
677
|
+
class Impl extends Handler {
|
|
678
|
+
handle(data: string): void {}
|
|
679
|
+
}
|
|
680
|
+
`
|
|
681
|
+
// NOTE: 'Handler' appears 3 times here (declaration + extends + Impl body return type inference)
|
|
682
|
+
// Use a code where it appears exactly <= 2 times
|
|
683
|
+
const code2 = `
|
|
684
|
+
abstract class Processor {
|
|
685
|
+
abstract process(x: number): void
|
|
686
|
+
}
|
|
687
|
+
`
|
|
688
|
+
expect(getRules(code2, 'src/processor.ts')).toContain('unnecessary-abstraction')
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
693
|
+
// naming-inconsistency (camelCase and snake_case mixed in same function scope, >= 3 vars)
|
|
694
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
695
|
+
describe('naming-inconsistency', () => {
|
|
696
|
+
it('detects mixed camelCase and snake_case in same function', () => {
|
|
697
|
+
const code = `function process(): void {
|
|
698
|
+
const firstName = 'John'
|
|
699
|
+
const last_name = 'Doe'
|
|
700
|
+
const userAge = 30
|
|
701
|
+
const birth_date = '1990-01-01'
|
|
702
|
+
}`
|
|
703
|
+
expect(getRules(code)).toContain('naming-inconsistency')
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it('does not detect consistent camelCase', () => {
|
|
707
|
+
const code = `function process(): void {
|
|
708
|
+
const firstName = 'John'
|
|
709
|
+
const lastName = 'Doe'
|
|
710
|
+
const userAge = 30
|
|
711
|
+
}`
|
|
712
|
+
expect(getRules(code)).not.toContain('naming-inconsistency')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
it('does not detect consistent snake_case', () => {
|
|
716
|
+
const code = `function process(): void {
|
|
717
|
+
const first_name = 'John'
|
|
718
|
+
const last_name = 'Doe'
|
|
719
|
+
const user_age = 30
|
|
720
|
+
}`
|
|
721
|
+
expect(getRules(code)).not.toContain('naming-inconsistency')
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it('does not flag when fewer than 3 variables (too few to be significant)', () => {
|
|
725
|
+
// Only 2 vars — rule requires >= 3 to be significant
|
|
726
|
+
const code = `function f(): void {
|
|
727
|
+
const myVar = 1
|
|
728
|
+
const other_var = 2
|
|
729
|
+
}`
|
|
730
|
+
expect(getRules(code)).not.toContain('naming-inconsistency')
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
735
|
+
// drift-ignore-file (whole file suppression)
|
|
736
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
describe('drift-ignore-file', () => {
|
|
738
|
+
it('suppresses all issues when drift-ignore-file is in first 10 lines', () => {
|
|
739
|
+
const code = `// drift-ignore-file\nconst a: any = 1\nconsole.log(a)`
|
|
740
|
+
const report = analyzeCode(code)
|
|
741
|
+
expect(report.issues).toHaveLength(0)
|
|
742
|
+
expect(report.score).toBe(0)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('does not suppress when drift-ignore-file is not present', () => {
|
|
746
|
+
const code = `const a: any = 1\nconsole.log(a)`
|
|
747
|
+
const report = analyzeCode(code)
|
|
748
|
+
expect(report.issues.length).toBeGreaterThan(0)
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
753
|
+
// score calculation
|
|
754
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
755
|
+
describe('score calculation', () => {
|
|
756
|
+
it('returns score 0 for clean code', () => {
|
|
757
|
+
const code = `export function add(a: number, b: number): number { return a + b }`
|
|
758
|
+
const report = analyzeCode(code)
|
|
759
|
+
expect(report.score).toBe(0)
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('returns score > 0 when issues are found', () => {
|
|
763
|
+
const code = `const a: any = 1\nconsole.log(a)`
|
|
764
|
+
const report = analyzeCode(code)
|
|
765
|
+
expect(report.score).toBeGreaterThan(0)
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('caps score at 100', () => {
|
|
769
|
+
// Many issues — score should never exceed 100
|
|
770
|
+
const code = [
|
|
771
|
+
`const a: any = 1`,
|
|
772
|
+
`const b: any = 2`,
|
|
773
|
+
`const c: any = 3`,
|
|
774
|
+
`const d: any = 4`,
|
|
775
|
+
`const e: any = 5`,
|
|
776
|
+
`console.log(a,b,c,d,e)`,
|
|
777
|
+
`try { const x = 1 } catch (e) {}`,
|
|
778
|
+
generateFunction(55),
|
|
779
|
+
].join('\n')
|
|
780
|
+
const report = analyzeCode(code)
|
|
781
|
+
expect(report.score).toBeLessThanOrEqual(100)
|
|
782
|
+
})
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
786
|
+
// JS/JSX support
|
|
787
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
788
|
+
describe('JS/JSX support', () => {
|
|
789
|
+
it('detects debug-leftover in .js file', () => {
|
|
790
|
+
const rules = getRules('console.log("test")', undefined, 'app.js')
|
|
791
|
+
expect(rules).toContain('debug-leftover')
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('detects large-function in .jsx file', () => {
|
|
795
|
+
const lines = Array(60).fill(' const x = 1').join('\n')
|
|
796
|
+
const code = `function Component() {\n${lines}\n return null\n}`
|
|
797
|
+
const rules = getRules(code, undefined, 'Component.jsx')
|
|
798
|
+
expect(rules).toContain('large-function')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('detects catch-swallow in .js file', () => {
|
|
802
|
+
const code = `try { foo() } catch(e) {}`
|
|
803
|
+
const rules = getRules(code, undefined, 'utils.js')
|
|
804
|
+
expect(rules).toContain('catch-swallow')
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('does not flag pure JS with no issues', () => {
|
|
808
|
+
const code = `function add(a, b) { return a + b }`
|
|
809
|
+
const report = analyzeCode(code, undefined, 'math.js')
|
|
810
|
+
const meaningful = report.issues.filter(i => i.severity !== 'info')
|
|
811
|
+
expect(meaningful).toHaveLength(0)
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
816
|
+
// drift fix (applyFixes)
|
|
817
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
818
|
+
describe('drift fix', () => {
|
|
819
|
+
let tmpDir: string
|
|
820
|
+
let tmpFile: string
|
|
821
|
+
|
|
822
|
+
afterEach(() => {
|
|
823
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
function createTmp(content: string): string {
|
|
827
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'drift-fix-test-'))
|
|
828
|
+
tmpFile = join(tmpDir, 'test.ts')
|
|
829
|
+
writeFileSync(tmpFile, content, 'utf8')
|
|
830
|
+
return tmpFile
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── Test 1: debug-leftover console.* — dryRun reports, real run removes line ──
|
|
834
|
+
it('debug-leftover console.* — dryRun: reports applied:true without writing', async () => {
|
|
835
|
+
const filePath = createTmp(`const x = 1\nconsole.log('debug')\nconst y = 2\n`)
|
|
836
|
+
const results = await applyFixes(filePath, undefined, { dryRun: true })
|
|
837
|
+
|
|
838
|
+
expect(results).toHaveLength(1)
|
|
839
|
+
expect(results[0].rule).toBe('debug-leftover')
|
|
840
|
+
expect(results[0].applied).toBe(true)
|
|
841
|
+
|
|
842
|
+
// File must NOT have been modified in dry-run
|
|
843
|
+
const content = readFileSync(filePath, 'utf8')
|
|
844
|
+
expect(content).toContain("console.log('debug')")
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('debug-leftover console.* — real run: removes the console line', async () => {
|
|
848
|
+
const filePath = createTmp(`const x = 1\nconsole.log('debug')\nconst y = 2\n`)
|
|
849
|
+
const results = await applyFixes(filePath, undefined, { dryRun: false })
|
|
850
|
+
|
|
851
|
+
expect(results).toHaveLength(1)
|
|
852
|
+
expect(results[0].applied).toBe(true)
|
|
853
|
+
|
|
854
|
+
const content = readFileSync(filePath, 'utf8')
|
|
855
|
+
expect(content).not.toContain('console.log')
|
|
856
|
+
expect(content).toContain('const x = 1')
|
|
857
|
+
expect(content).toContain('const y = 2')
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
// ── Test 2: debug-leftover TODO — NOT auto-fixable ──
|
|
861
|
+
it('debug-leftover TODO/FIXME — not returned as fixable result', async () => {
|
|
862
|
+
const filePath = createTmp(`const x = 1\n// TODO: fix this\nconst y = 2\n`)
|
|
863
|
+
const results = await applyFixes(filePath, undefined, { dryRun: true })
|
|
864
|
+
|
|
865
|
+
// TODO markers are not fixable — no results expected
|
|
866
|
+
expect(results).toHaveLength(0)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
// ── Test 3: catch-swallow — inserts TODO comment ──
|
|
870
|
+
it('catch-swallow — dryRun: reports applied:true', async () => {
|
|
871
|
+
const filePath = createTmp(`function f(): void {\n try {\n const x = 1\n } catch (e) {}\n}\n`)
|
|
872
|
+
const results = await applyFixes(filePath, undefined, { dryRun: true })
|
|
873
|
+
|
|
874
|
+
expect(results).toHaveLength(1)
|
|
875
|
+
expect(results[0].rule).toBe('catch-swallow')
|
|
876
|
+
expect(results[0].applied).toBe(true)
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it('catch-swallow — real run: inserts TODO comment in empty catch', async () => {
|
|
880
|
+
const filePath = createTmp(`function f(): void {\n try {\n const x = 1\n } catch (e) {}\n}\n`)
|
|
881
|
+
const results = await applyFixes(filePath, undefined, { dryRun: false })
|
|
882
|
+
|
|
883
|
+
expect(results).toHaveLength(1)
|
|
884
|
+
expect(results[0].applied).toBe(true)
|
|
885
|
+
|
|
886
|
+
const content = readFileSync(filePath, 'utf8')
|
|
887
|
+
expect(content).toContain('// TODO: handle error')
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
// ── Test 4: --rule filter ──
|
|
891
|
+
it('rule filter — only fixes the specified rule when both issues exist', async () => {
|
|
892
|
+
const filePath = createTmp(
|
|
893
|
+
`console.log('debug')\nfunction f(): void {\n try {\n const x = 1\n } catch (e) {}\n}\n`
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
const results = await applyFixes(filePath, undefined, { rule: 'debug-leftover', dryRun: true })
|
|
897
|
+
|
|
898
|
+
expect(results).toHaveLength(1)
|
|
899
|
+
expect(results[0].rule).toBe('debug-leftover')
|
|
900
|
+
// catch-swallow must NOT appear
|
|
901
|
+
expect(results.every(r => r.rule === 'debug-leftover')).toBe(true)
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
// ── Test 5: dry-run does not write the file ──
|
|
905
|
+
it('dryRun: true — file is not modified', async () => {
|
|
906
|
+
const original = `const a = 1\nconsole.log('test')\nconst b = 2\n`
|
|
907
|
+
const filePath = createTmp(original)
|
|
908
|
+
|
|
909
|
+
await applyFixes(filePath, undefined, { dryRun: true })
|
|
910
|
+
|
|
911
|
+
const content = readFileSync(filePath, 'utf8')
|
|
912
|
+
expect(content).toBe(original)
|
|
913
|
+
})
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
917
|
+
// unused-export (cross-file: export never imported anywhere in the project)
|
|
918
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
919
|
+
describe('unused-export', () => {
|
|
920
|
+
let tmpDir: string
|
|
921
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
922
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
923
|
+
|
|
924
|
+
it('detects named export that is never imported in any file', () => {
|
|
925
|
+
// Only one file — its export is never consumed by anyone
|
|
926
|
+
writeFileSync(join(tmpDir, 'utils.ts'), `export function helper(): void {}\n`)
|
|
927
|
+
|
|
928
|
+
const reports = analyzeProject(tmpDir)
|
|
929
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
930
|
+
expect(allRules).toContain('unused-export')
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it('does not report export that is imported by another file', () => {
|
|
934
|
+
writeFileSync(join(tmpDir, 'utils.ts'), `export function helper(): void {}\n`)
|
|
935
|
+
writeFileSync(join(tmpDir, 'main.ts'), `import { helper } from './utils.js'\nhelper()\n`)
|
|
936
|
+
|
|
937
|
+
const reports = analyzeProject(tmpDir)
|
|
938
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
939
|
+
expect(allRules).not.toContain('unused-export')
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('does not report default export (only named exports are checked)', () => {
|
|
943
|
+
writeFileSync(join(tmpDir, 'service.ts'), `export default function service(): void {}\n`)
|
|
944
|
+
|
|
945
|
+
const reports = analyzeProject(tmpDir)
|
|
946
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
947
|
+
expect(allRules).not.toContain('unused-export')
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
it('ignores barrel file (index.ts) even if its exports are unused', () => {
|
|
951
|
+
// index.ts is a barrel — unused-export is skipped for barrels
|
|
952
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export function publicApi(): void {}\n`)
|
|
953
|
+
|
|
954
|
+
const reports = analyzeProject(tmpDir)
|
|
955
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
956
|
+
expect(allRules).not.toContain('unused-export')
|
|
957
|
+
})
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
961
|
+
// dead-file (cross-file: file never imported by any other file)
|
|
962
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
963
|
+
describe('dead-file', () => {
|
|
964
|
+
let tmpDir: string
|
|
965
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
966
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
967
|
+
|
|
968
|
+
it('detects .ts file that is never imported', () => {
|
|
969
|
+
// orphan.ts is not imported by anyone and is not an entry point
|
|
970
|
+
writeFileSync(join(tmpDir, 'orphan.ts'), `export const VALUE = 42\n`)
|
|
971
|
+
|
|
972
|
+
const reports = analyzeProject(tmpDir)
|
|
973
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
974
|
+
expect(allRules).toContain('dead-file')
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('does not flag index.ts (entry point)', () => {
|
|
978
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export const VALUE = 42\n`)
|
|
979
|
+
|
|
980
|
+
const reports = analyzeProject(tmpDir)
|
|
981
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
982
|
+
expect(allRules).not.toContain('dead-file')
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
it('does not flag a file that is imported by another file', () => {
|
|
986
|
+
writeFileSync(join(tmpDir, 'utils.ts'), `export function helper(): void {}\n`)
|
|
987
|
+
writeFileSync(join(tmpDir, 'main.ts'), `import { helper } from './utils.js'\nhelper()\n`)
|
|
988
|
+
|
|
989
|
+
const reports = analyzeProject(tmpDir)
|
|
990
|
+
const utilsReport = reports.find(r => r.path.endsWith('utils.ts'))
|
|
991
|
+
const deadFileIssues = utilsReport?.issues.filter(i => i.rule === 'dead-file') ?? []
|
|
992
|
+
expect(deadFileIssues).toHaveLength(0)
|
|
993
|
+
})
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
997
|
+
// unused-dependency (package.json dep never imported in any source file)
|
|
998
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
999
|
+
describe('unused-dependency', () => {
|
|
1000
|
+
let tmpDir: string
|
|
1001
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
1002
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
1003
|
+
|
|
1004
|
+
it('detects dependency in package.json that has no matching import', () => {
|
|
1005
|
+
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({
|
|
1006
|
+
name: 'test-pkg',
|
|
1007
|
+
dependencies: { 'lodash': '^4.0.0' },
|
|
1008
|
+
}))
|
|
1009
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export const x = 1\n`)
|
|
1010
|
+
|
|
1011
|
+
const reports = analyzeProject(tmpDir)
|
|
1012
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1013
|
+
expect(allRules).toContain('unused-dependency')
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
it('does not report dependency that is actually imported', () => {
|
|
1017
|
+
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({
|
|
1018
|
+
name: 'test-pkg',
|
|
1019
|
+
dependencies: { 'kleur': '^4.0.0' },
|
|
1020
|
+
}))
|
|
1021
|
+
writeFileSync(join(tmpDir, 'index.ts'), `import kleur from 'kleur'\nconst c = kleur.red('hi')\n`)
|
|
1022
|
+
|
|
1023
|
+
const reports = analyzeProject(tmpDir)
|
|
1024
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1025
|
+
expect(allRules).not.toContain('unused-dependency')
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
it('ignores @types/ packages (they are dev-only type definitions)', () => {
|
|
1029
|
+
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({
|
|
1030
|
+
name: 'test-pkg',
|
|
1031
|
+
dependencies: { '@types/node': '^20.0.0' },
|
|
1032
|
+
}))
|
|
1033
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export const x = 1\n`)
|
|
1034
|
+
|
|
1035
|
+
const reports = analyzeProject(tmpDir)
|
|
1036
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1037
|
+
expect(allRules).not.toContain('unused-dependency')
|
|
1038
|
+
})
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1042
|
+
// circular-dependency (A → B → A, or longer cycles)
|
|
1043
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1044
|
+
describe('circular-dependency', () => {
|
|
1045
|
+
let tmpDir: string
|
|
1046
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
1047
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
1048
|
+
|
|
1049
|
+
it('detects direct cycle: A imports B, B imports A', () => {
|
|
1050
|
+
writeFileSync(join(tmpDir, 'a.ts'), `import { b } from './b.js'\nexport const a = 'a'\n`)
|
|
1051
|
+
writeFileSync(join(tmpDir, 'b.ts'), `import { a } from './a.js'\nexport const b = 'b'\n`)
|
|
1052
|
+
|
|
1053
|
+
const reports = analyzeProject(tmpDir)
|
|
1054
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1055
|
+
expect(allRules).toContain('circular-dependency')
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('detects indirect cycle of 3: A → B → C → A', () => {
|
|
1059
|
+
writeFileSync(join(tmpDir, 'a.ts'), `import { c } from './c.js'\nexport const a = 'a'\n`)
|
|
1060
|
+
writeFileSync(join(tmpDir, 'b.ts'), `import { a } from './a.js'\nexport const b = 'b'\n`)
|
|
1061
|
+
writeFileSync(join(tmpDir, 'c.ts'), `import { b } from './b.js'\nexport const c = 'c'\n`)
|
|
1062
|
+
|
|
1063
|
+
const reports = analyzeProject(tmpDir)
|
|
1064
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1065
|
+
expect(allRules).toContain('circular-dependency')
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
it('does not flag linear chain A → B → C (no cycle)', () => {
|
|
1069
|
+
writeFileSync(join(tmpDir, 'c.ts'), `export const c = 'c'\n`)
|
|
1070
|
+
writeFileSync(join(tmpDir, 'b.ts'), `import { c } from './c.js'\nexport const b = 'b'\n`)
|
|
1071
|
+
writeFileSync(join(tmpDir, 'a.ts'), `import { b } from './b.js'\nexport const a = 'a'\n`)
|
|
1072
|
+
|
|
1073
|
+
const reports = analyzeProject(tmpDir)
|
|
1074
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1075
|
+
expect(allRules).not.toContain('circular-dependency')
|
|
1076
|
+
})
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1080
|
+
// layer-violation (requires config.layers — architectural boundary enforcement)
|
|
1081
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1082
|
+
describe('layer-violation', () => {
|
|
1083
|
+
let tmpDir: string
|
|
1084
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
1085
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
1086
|
+
|
|
1087
|
+
it('detects import from a disallowed layer (api → ui is forbidden)', () => {
|
|
1088
|
+
// api layer must NOT import from ui layer
|
|
1089
|
+
mkdirSync(join(tmpDir, 'ui'))
|
|
1090
|
+
mkdirSync(join(tmpDir, 'api'))
|
|
1091
|
+
writeFileSync(join(tmpDir, 'ui', 'Button.ts'), `export function Button(): void {}\n`)
|
|
1092
|
+
writeFileSync(join(tmpDir, 'api', 'fetch.ts'), `import { Button } from '../ui/Button.js'\nexport function fetchData(): void {}\n`)
|
|
1093
|
+
|
|
1094
|
+
const config = {
|
|
1095
|
+
layers: [
|
|
1096
|
+
{ name: 'ui', patterns: [`${tmpDir.replace(/\\/g, '/')}/ui/**`], canImportFrom: ['api'] },
|
|
1097
|
+
{ name: 'api', patterns: [`${tmpDir.replace(/\\/g, '/')}/api/**`], canImportFrom: [] },
|
|
1098
|
+
],
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const reports = analyzeProject(tmpDir, config)
|
|
1102
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1103
|
+
expect(allRules).toContain('layer-violation')
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
it('does not report a permitted layer import (ui → api is allowed)', () => {
|
|
1107
|
+
mkdirSync(join(tmpDir, 'ui'))
|
|
1108
|
+
mkdirSync(join(tmpDir, 'api'))
|
|
1109
|
+
writeFileSync(join(tmpDir, 'api', 'client.ts'), `export function fetchData(): void {}\n`)
|
|
1110
|
+
writeFileSync(join(tmpDir, 'ui', 'view.ts'), `import { fetchData } from '../api/client.js'\nexport function View(): void { fetchData() }\n`)
|
|
1111
|
+
|
|
1112
|
+
const config = {
|
|
1113
|
+
layers: [
|
|
1114
|
+
{ name: 'ui', patterns: [`${tmpDir.replace(/\\/g, '/')}/ui/**`], canImportFrom: ['api'] },
|
|
1115
|
+
{ name: 'api', patterns: [`${tmpDir.replace(/\\/g, '/')}/api/**`], canImportFrom: [] },
|
|
1116
|
+
],
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const reports = analyzeProject(tmpDir, config)
|
|
1120
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1121
|
+
expect(allRules).not.toContain('layer-violation')
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
it('does not crash and reports nothing when no layers config is provided', () => {
|
|
1125
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export const x = 1\n`)
|
|
1126
|
+
|
|
1127
|
+
const reports = analyzeProject(tmpDir, {})
|
|
1128
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1129
|
+
expect(allRules).not.toContain('layer-violation')
|
|
1130
|
+
})
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1134
|
+
// cross-boundary-import (requires config.modules — module boundary enforcement)
|
|
1135
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1136
|
+
describe('cross-boundary-import', () => {
|
|
1137
|
+
let tmpDir: string
|
|
1138
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
1139
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
1140
|
+
|
|
1141
|
+
it('detects import from a disallowed module boundary', () => {
|
|
1142
|
+
mkdirSync(join(tmpDir, 'moduleA'))
|
|
1143
|
+
mkdirSync(join(tmpDir, 'moduleB'))
|
|
1144
|
+
writeFileSync(join(tmpDir, 'moduleB', 'service.ts'), `export function serviceB(): void {}\n`)
|
|
1145
|
+
writeFileSync(join(tmpDir, 'moduleA', 'app.ts'), `import { serviceB } from '../moduleB/service.js'\nexport function appA(): void { serviceB() }\n`)
|
|
1146
|
+
|
|
1147
|
+
const normalizedTmp = tmpDir.replace(/\\/g, '/')
|
|
1148
|
+
const config = {
|
|
1149
|
+
modules: [
|
|
1150
|
+
{ name: 'moduleA', root: `${normalizedTmp}/moduleA`, allowedExternalImports: [] },
|
|
1151
|
+
{ name: 'moduleB', root: `${normalizedTmp}/moduleB`, allowedExternalImports: [] },
|
|
1152
|
+
],
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const reports = analyzeProject(tmpDir, config)
|
|
1156
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1157
|
+
expect(allRules).toContain('cross-boundary-import')
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('does not report import that is explicitly allowed', () => {
|
|
1161
|
+
mkdirSync(join(tmpDir, 'moduleA'))
|
|
1162
|
+
mkdirSync(join(tmpDir, 'moduleB'))
|
|
1163
|
+
writeFileSync(join(tmpDir, 'moduleB', 'service.ts'), `export function serviceB(): void {}\n`)
|
|
1164
|
+
writeFileSync(join(tmpDir, 'moduleA', 'app.ts'), `import { serviceB } from '../moduleB/service.js'\nexport function appA(): void { serviceB() }\n`)
|
|
1165
|
+
|
|
1166
|
+
const normalizedTmp = tmpDir.replace(/\\/g, '/')
|
|
1167
|
+
const config = {
|
|
1168
|
+
modules: [
|
|
1169
|
+
{
|
|
1170
|
+
name: 'moduleA',
|
|
1171
|
+
root: `${normalizedTmp}/moduleA`,
|
|
1172
|
+
allowedExternalImports: [`${normalizedTmp}/moduleB`],
|
|
1173
|
+
},
|
|
1174
|
+
{ name: 'moduleB', root: `${normalizedTmp}/moduleB`, allowedExternalImports: [] },
|
|
1175
|
+
],
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const reports = analyzeProject(tmpDir, config)
|
|
1179
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1180
|
+
expect(allRules).not.toContain('cross-boundary-import')
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
it('does not crash and reports nothing when no modules config is provided', () => {
|
|
1184
|
+
writeFileSync(join(tmpDir, 'index.ts'), `export const x = 1\n`)
|
|
1185
|
+
|
|
1186
|
+
const reports = analyzeProject(tmpDir, {})
|
|
1187
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1188
|
+
expect(allRules).not.toContain('cross-boundary-import')
|
|
1189
|
+
})
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1193
|
+
// semantic-duplication (same function body fingerprint in different files)
|
|
1194
|
+
// MIN_LINES = 8 lines in the body — functions shorter than that are skipped
|
|
1195
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1196
|
+
describe('semantic-duplication', () => {
|
|
1197
|
+
let tmpDir: string
|
|
1198
|
+
beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'drift-test-')) })
|
|
1199
|
+
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }) })
|
|
1200
|
+
|
|
1201
|
+
// Helper: generate a function body with N statements (>= 8 to pass MIN_LINES filter)
|
|
1202
|
+
function makeFunc(name: string, paramName = 'x'): string {
|
|
1203
|
+
return [
|
|
1204
|
+
`export function ${name}(${paramName}: number): number {`,
|
|
1205
|
+
` const a = ${paramName} + 1`,
|
|
1206
|
+
` const b = a * 2`,
|
|
1207
|
+
` const c = b - 3`,
|
|
1208
|
+
` const d = c + 4`,
|
|
1209
|
+
` const e = d * 5`,
|
|
1210
|
+
` const f = e - 6`,
|
|
1211
|
+
` const g = f + 7`,
|
|
1212
|
+
` return g`,
|
|
1213
|
+
`}`,
|
|
1214
|
+
].join('\n')
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
it('detects two functions with identical logic (same AST fingerprint) in different files', () => {
|
|
1218
|
+
// Same structure, different names and parameter names → same fingerprint
|
|
1219
|
+
writeFileSync(join(tmpDir, 'fileA.ts'), makeFunc('computeA', 'x') + '\n')
|
|
1220
|
+
writeFileSync(join(tmpDir, 'fileB.ts'), makeFunc('computeB', 'n') + '\n')
|
|
1221
|
+
|
|
1222
|
+
const reports = analyzeProject(tmpDir)
|
|
1223
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1224
|
+
expect(allRules).toContain('semantic-duplication')
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it('does not flag two functions with structurally different logic', () => {
|
|
1228
|
+
const funcA = [
|
|
1229
|
+
`export function doWork(x: number): number {`,
|
|
1230
|
+
` const a = x + 1`,
|
|
1231
|
+
` const b = a * 2`,
|
|
1232
|
+
` const c = b - 3`,
|
|
1233
|
+
` const d = c + 4`,
|
|
1234
|
+
` const e = d * 5`,
|
|
1235
|
+
` const f = e - 6`,
|
|
1236
|
+
` const g = f + 7`,
|
|
1237
|
+
` return g`,
|
|
1238
|
+
`}`,
|
|
1239
|
+
].join('\n')
|
|
1240
|
+
|
|
1241
|
+
const funcB = [
|
|
1242
|
+
`export function doOther(x: number): number {`,
|
|
1243
|
+
` const a = x * 10`,
|
|
1244
|
+
` const b = a + 20`,
|
|
1245
|
+
` const c = b / 30`,
|
|
1246
|
+
` const d = c - 40`,
|
|
1247
|
+
` const e = d + 50`,
|
|
1248
|
+
` const f = e * 60`,
|
|
1249
|
+
` const g = f - 70`,
|
|
1250
|
+
` return g`,
|
|
1251
|
+
`}`,
|
|
1252
|
+
].join('\n')
|
|
1253
|
+
|
|
1254
|
+
writeFileSync(join(tmpDir, 'fileA.ts'), funcA + '\n')
|
|
1255
|
+
writeFileSync(join(tmpDir, 'fileB.ts'), funcB + '\n')
|
|
1256
|
+
|
|
1257
|
+
const reports = analyzeProject(tmpDir)
|
|
1258
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1259
|
+
expect(allRules).not.toContain('semantic-duplication')
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
it('does not flag when there is only one function (nothing to compare against)', () => {
|
|
1263
|
+
writeFileSync(join(tmpDir, 'only.ts'), makeFunc('solo', 'x') + '\n')
|
|
1264
|
+
|
|
1265
|
+
const reports = analyzeProject(tmpDir)
|
|
1266
|
+
const allRules = reports.flatMap(r => r.issues.map(i => i.rule))
|
|
1267
|
+
expect(allRules).not.toContain('semantic-duplication')
|
|
1268
|
+
})
|
|
1269
|
+
})
|