@eduardbar/drift 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.github/workflows/publish-vscode.yml +76 -0
  2. package/AGENTS.md +30 -12
  3. package/CHANGELOG.md +9 -0
  4. package/README.md +273 -168
  5. package/ROADMAP.md +130 -98
  6. package/dist/analyzer.d.ts +4 -38
  7. package/dist/analyzer.js +85 -1510
  8. package/dist/cli.js +47 -4
  9. package/dist/config.js +1 -1
  10. package/dist/fix.d.ts +13 -0
  11. package/dist/fix.js +120 -0
  12. package/dist/git/blame.d.ts +22 -0
  13. package/dist/git/blame.js +227 -0
  14. package/dist/git/helpers.d.ts +36 -0
  15. package/dist/git/helpers.js +152 -0
  16. package/dist/git/trend.d.ts +21 -0
  17. package/dist/git/trend.js +80 -0
  18. package/dist/git.d.ts +0 -4
  19. package/dist/git.js +2 -2
  20. package/dist/report.js +620 -293
  21. package/dist/rules/phase0-basic.d.ts +11 -0
  22. package/dist/rules/phase0-basic.js +176 -0
  23. package/dist/rules/phase1-complexity.d.ts +31 -0
  24. package/dist/rules/phase1-complexity.js +277 -0
  25. package/dist/rules/phase2-crossfile.d.ts +27 -0
  26. package/dist/rules/phase2-crossfile.js +122 -0
  27. package/dist/rules/phase3-arch.d.ts +31 -0
  28. package/dist/rules/phase3-arch.js +148 -0
  29. package/dist/rules/phase5-ai.d.ts +8 -0
  30. package/dist/rules/phase5-ai.js +262 -0
  31. package/dist/rules/phase8-semantic.d.ts +22 -0
  32. package/dist/rules/phase8-semantic.js +109 -0
  33. package/dist/rules/shared.d.ts +7 -0
  34. package/dist/rules/shared.js +27 -0
  35. package/package.json +8 -3
  36. package/packages/vscode-drift/.vscodeignore +9 -0
  37. package/packages/vscode-drift/LICENSE +21 -0
  38. package/packages/vscode-drift/README.md +64 -0
  39. package/packages/vscode-drift/images/icon.png +0 -0
  40. package/packages/vscode-drift/images/icon.svg +30 -0
  41. package/packages/vscode-drift/package-lock.json +485 -0
  42. package/packages/vscode-drift/package.json +119 -0
  43. package/packages/vscode-drift/src/analyzer.ts +38 -0
  44. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  45. package/packages/vscode-drift/src/extension.ts +111 -0
  46. package/packages/vscode-drift/src/statusbar.ts +47 -0
  47. package/packages/vscode-drift/src/treeview.ts +108 -0
  48. package/packages/vscode-drift/tsconfig.json +18 -0
  49. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  50. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  51. package/src/analyzer.ts +124 -1726
  52. package/src/cli.ts +53 -4
  53. package/src/config.ts +1 -1
  54. package/src/fix.ts +154 -0
  55. package/src/git/blame.ts +279 -0
  56. package/src/git/helpers.ts +198 -0
  57. package/src/git/trend.ts +116 -0
  58. package/src/git.ts +2 -2
  59. package/src/report.ts +631 -296
  60. package/src/rules/phase0-basic.ts +187 -0
  61. package/src/rules/phase1-complexity.ts +302 -0
  62. package/src/rules/phase2-crossfile.ts +149 -0
  63. package/src/rules/phase3-arch.ts +179 -0
  64. package/src/rules/phase5-ai.ts +292 -0
  65. package/src/rules/phase8-semantic.ts +132 -0
  66. package/src/rules/shared.ts +39 -0
  67. package/tests/helpers.ts +45 -0
  68. package/tests/rules.test.ts +1269 -0
  69. package/vitest.config.ts +15 -0
@@ -0,0 +1,179 @@
1
+ import * as path from 'node:path'
2
+ import type { DriftIssue, LayerDefinition, ModuleBoundary } from '../types.js'
3
+
4
+ /**
5
+ * DFS cycle detection in a directed import graph.
6
+ * Returns arrays of file paths that form cycles.
7
+ */
8
+ export function findCycles(graph: Map<string, Set<string>>): Array<string[]> {
9
+ const visited = new Set<string>()
10
+ const inStack = new Set<string>()
11
+ const cycles: Array<string[]> = []
12
+
13
+ function dfs(node: string, stack: string[]): void {
14
+ visited.add(node)
15
+ inStack.add(node)
16
+ stack.push(node)
17
+
18
+ for (const neighbor of graph.get(node) ?? []) {
19
+ if (!visited.has(neighbor)) {
20
+ dfs(neighbor, stack)
21
+ } else if (inStack.has(neighbor)) {
22
+ // Found a cycle — extract the cycle portion from the stack
23
+ const cycleStart = stack.indexOf(neighbor)
24
+ cycles.push(stack.slice(cycleStart))
25
+ }
26
+ }
27
+
28
+ stack.pop()
29
+ inStack.delete(node)
30
+ }
31
+
32
+ for (const node of graph.keys()) {
33
+ if (!visited.has(node)) {
34
+ dfs(node, [])
35
+ }
36
+ }
37
+
38
+ return cycles
39
+ }
40
+
41
+ /**
42
+ * Detect circular dependencies from the import graph.
43
+ * Returns a map of filePath → issue (one per unique cycle).
44
+ */
45
+ export function detectCircularDependencies(
46
+ importGraph: Map<string, Set<string>>,
47
+ ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
48
+ ): Map<string, DriftIssue> {
49
+ const cycles = findCycles(importGraph)
50
+ const reportedCycleKeys = new Set<string>()
51
+ const result = new Map<string, DriftIssue>()
52
+
53
+ for (const cycle of cycles) {
54
+ const cycleKey = [...cycle].sort().join('|')
55
+ if (reportedCycleKeys.has(cycleKey)) continue
56
+ reportedCycleKeys.add(cycleKey)
57
+
58
+ const firstFile = cycle[0]
59
+ if (!firstFile) continue
60
+
61
+ const cycleDisplay = cycle
62
+ .map(p => path.basename(p))
63
+ .concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
64
+ .join(' → ')
65
+
66
+ result.set(firstFile, {
67
+ rule: 'circular-dependency',
68
+ severity: ruleWeights['circular-dependency'].severity,
69
+ message: `Circular dependency detected: ${cycleDisplay}`,
70
+ line: 1,
71
+ column: 1,
72
+ snippet: cycleDisplay,
73
+ })
74
+ }
75
+
76
+ return result
77
+ }
78
+
79
+ /**
80
+ * Detect layer violations based on user-defined layer configuration.
81
+ * Returns a map of filePath → issues[].
82
+ */
83
+ export function detectLayerViolations(
84
+ importGraph: Map<string, Set<string>>,
85
+ layers: LayerDefinition[],
86
+ targetPath: string,
87
+ ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
88
+ ): Map<string, DriftIssue[]> {
89
+ const result = new Map<string, DriftIssue[]>()
90
+
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
+ for (const [filePath, imports] of importGraph.entries()) {
107
+ const fileLayer = getLayer(filePath)
108
+ if (!fileLayer) continue
109
+
110
+ for (const importedPath of imports) {
111
+ const importedLayer = getLayer(importedPath)
112
+ if (!importedLayer) continue
113
+ if (importedLayer.name === fileLayer.name) continue
114
+
115
+ if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
116
+ if (!result.has(filePath)) result.set(filePath, [])
117
+ result.get(filePath)!.push({
118
+ rule: 'layer-violation',
119
+ severity: 'error',
120
+ message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
121
+ line: 1,
122
+ column: 1,
123
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
124
+ })
125
+ }
126
+ }
127
+ }
128
+
129
+ return result
130
+ }
131
+
132
+ /**
133
+ * Detect cross-boundary imports based on user-defined module boundary configuration.
134
+ * Returns a map of filePath → issues[].
135
+ */
136
+ export function detectCrossBoundaryImports(
137
+ importGraph: Map<string, Set<string>>,
138
+ modules: ModuleBoundary[],
139
+ targetPath: string,
140
+ ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>,
141
+ ): Map<string, DriftIssue[]> {
142
+ const result = new Map<string, DriftIssue[]>()
143
+
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
+ for (const [filePath, imports] of importGraph.entries()) {
150
+ const fileModule = getModule(filePath)
151
+ if (!fileModule) continue
152
+
153
+ for (const importedPath of imports) {
154
+ const importedModule = getModule(importedPath)
155
+ if (!importedModule) continue
156
+ if (importedModule.name === fileModule.name) continue
157
+
158
+ 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) {
165
+ if (!result.has(filePath)) result.set(filePath, [])
166
+ result.get(filePath)!.push({
167
+ rule: 'cross-boundary-import',
168
+ severity: 'warning',
169
+ message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
170
+ line: 1,
171
+ column: 1,
172
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
173
+ })
174
+ }
175
+ }
176
+ }
177
+
178
+ return result
179
+ }
@@ -0,0 +1,292 @@
1
+ // drift-ignore-file
2
+ import { SourceFile, SyntaxKind } from 'ts-morph'
3
+ import type { DriftIssue } from '../types.js'
4
+
5
+ export function detectOverCommented(file: SourceFile): DriftIssue[] {
6
+ const issues: DriftIssue[] = []
7
+
8
+ for (const fn of file.getFunctions()) {
9
+ const body = fn.getBody()
10
+ if (!body) continue
11
+
12
+ const bodyText = body.getText()
13
+ const lines = bodyText.split('\n')
14
+ const totalLines = lines.length
15
+
16
+ if (totalLines < 6) continue
17
+
18
+ let commentLines = 0
19
+ for (const line of lines) {
20
+ const trimmed = line.trim()
21
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
22
+ commentLines++
23
+ }
24
+ }
25
+
26
+ const ratio = commentLines / totalLines
27
+ if (ratio >= 0.4) {
28
+ issues.push({
29
+ rule: 'over-commented',
30
+ severity: 'info',
31
+ message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
32
+ line: fn.getStartLineNumber(),
33
+ column: fn.getStartLinePos(),
34
+ snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
35
+ })
36
+ }
37
+ }
38
+
39
+ for (const cls of file.getClasses()) {
40
+ for (const method of cls.getMethods()) {
41
+ const body = method.getBody()
42
+ if (!body) continue
43
+
44
+ const bodyText = body.getText()
45
+ const lines = bodyText.split('\n')
46
+ const totalLines = lines.length
47
+
48
+ if (totalLines < 6) continue
49
+
50
+ let commentLines = 0
51
+ for (const line of lines) {
52
+ const trimmed = line.trim()
53
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
54
+ commentLines++
55
+ }
56
+ }
57
+
58
+ const ratio = commentLines / totalLines
59
+ if (ratio >= 0.4) {
60
+ issues.push({
61
+ rule: 'over-commented',
62
+ severity: 'info',
63
+ message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
64
+ line: method.getStartLineNumber(),
65
+ column: method.getStartLinePos(),
66
+ snippet: `${cls.getName()}.${method.getName()}`,
67
+ })
68
+ }
69
+ }
70
+ }
71
+
72
+ return issues
73
+ }
74
+
75
+ export function detectHardcodedConfig(file: SourceFile): DriftIssue[] {
76
+ const issues: DriftIssue[] = []
77
+
78
+ const CONFIG_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
79
+ { pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
80
+ { pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
81
+ { pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
82
+ { pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
83
+ { pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
84
+ { pattern: /^redis:\/\//i, label: 'Redis connection string' },
85
+ { pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
86
+ { pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
87
+ { pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
88
+ { pattern: /^\/[a-z]/i, label: 'Absolute file path' },
89
+ { pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
90
+ ]
91
+
92
+ const filePath = file.getFilePath().replace(/\\/g, '/')
93
+ if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
94
+ return issues
95
+ }
96
+
97
+ for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
98
+ const value = node.getLiteralValue()
99
+ if (!value || value.length < 4) continue
100
+
101
+ const parent = node.getParent()
102
+ if (!parent) continue
103
+ const parentKind = parent.getKindName()
104
+ if (
105
+ parentKind === 'ImportDeclaration' ||
106
+ parentKind === 'ExportDeclaration' ||
107
+ (parentKind === 'CallExpression' && parent.getText().startsWith('import('))
108
+ ) continue
109
+
110
+ for (const { pattern, label } of CONFIG_PATTERNS) {
111
+ if (pattern.test(value)) {
112
+ issues.push({
113
+ rule: 'hardcoded-config',
114
+ severity: 'warning',
115
+ message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
116
+ line: node.getStartLineNumber(),
117
+ column: node.getStartLinePos(),
118
+ snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
119
+ })
120
+ break
121
+ }
122
+ }
123
+ }
124
+
125
+ return issues
126
+ }
127
+
128
+ export function detectInconsistentErrorHandling(file: SourceFile): DriftIssue[] {
129
+ const issues: DriftIssue[] = []
130
+
131
+ let hasTryCatch = false
132
+ let hasDotCatch = false
133
+ let hasThenErrorHandler = false
134
+ let firstLine = 0
135
+
136
+ // Detectar try/catch
137
+ const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement)
138
+ if (tryCatches.length > 0) {
139
+ hasTryCatch = true
140
+ firstLine = firstLine || tryCatches[0].getStartLineNumber()
141
+ }
142
+
143
+ // Detectar .catch(handler) en call expressions
144
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
145
+ const expr = call.getExpression()
146
+ if (expr.getKindName() === 'PropertyAccessExpression') {
147
+ const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression)
148
+ const propName = propAccess.getName()
149
+ if (propName === 'catch') {
150
+ // Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
151
+ if (call.getArguments().length > 0) {
152
+ hasDotCatch = true
153
+ if (!firstLine) firstLine = call.getStartLineNumber()
154
+ }
155
+ }
156
+ // Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
157
+ if (propName === 'then' && call.getArguments().length >= 2) {
158
+ hasThenErrorHandler = true
159
+ if (!firstLine) firstLine = call.getStartLineNumber()
160
+ }
161
+ }
162
+ }
163
+
164
+ const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length
165
+
166
+ if (stylesUsed >= 2) {
167
+ const styles: string[] = []
168
+ if (hasTryCatch) styles.push('try/catch')
169
+ if (hasDotCatch) styles.push('.catch()')
170
+ if (hasThenErrorHandler) styles.push('.then(_, handler)')
171
+
172
+ issues.push({
173
+ rule: 'inconsistent-error-handling',
174
+ severity: 'warning',
175
+ message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
176
+ line: firstLine || 1,
177
+ column: 1,
178
+ snippet: styles.join(' + '),
179
+ })
180
+ }
181
+
182
+ return issues
183
+ }
184
+
185
+ export function detectUnnecessaryAbstraction(file: SourceFile): DriftIssue[] {
186
+ const issues: DriftIssue[] = []
187
+ const fileText = file.getFullText()
188
+
189
+ // Interfaces con un solo método
190
+ for (const iface of file.getInterfaces()) {
191
+ const methods = iface.getMethods()
192
+ const properties = iface.getProperties()
193
+
194
+ // Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
195
+ if (methods.length !== 1 || properties.length !== 0) continue
196
+
197
+ const ifaceName = iface.getName()
198
+
199
+ // Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
200
+ const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length
201
+ // La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
202
+ // Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
203
+ if (usageCount <= 2) {
204
+ issues.push({
205
+ rule: 'unnecessary-abstraction',
206
+ severity: 'warning',
207
+ message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
208
+ line: iface.getStartLineNumber(),
209
+ column: iface.getStartLinePos(),
210
+ snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
211
+ })
212
+ }
213
+ }
214
+
215
+ // Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
216
+ for (const cls of file.getClasses()) {
217
+ if (!cls.isAbstract()) continue
218
+
219
+ const abstractMethods = cls.getMethods().filter(m => m.isAbstract())
220
+ const concreteMethods = cls.getMethods().filter(m => !m.isAbstract())
221
+
222
+ if (abstractMethods.length !== 1 || concreteMethods.length !== 0) continue
223
+
224
+ const clsName = cls.getName() ?? ''
225
+ const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length
226
+
227
+ if (usageCount <= 2) {
228
+ issues.push({
229
+ rule: 'unnecessary-abstraction',
230
+ severity: 'warning',
231
+ message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
232
+ line: cls.getStartLineNumber(),
233
+ column: cls.getStartLinePos(),
234
+ snippet: `abstract class ${clsName}`,
235
+ })
236
+ }
237
+ }
238
+
239
+ return issues
240
+ }
241
+
242
+ export function detectNamingInconsistency(file: SourceFile): DriftIssue[] {
243
+ const issues: DriftIssue[] = []
244
+
245
+ const isCamelCase = (name: string) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)
246
+ const isSnakeCase = (name: string) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name)
247
+
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
+ function checkFunction(fn: any): void {
250
+ const vars = fn.getVariableDeclarations()
251
+ if (vars.length < 3) return // muy pocas vars para ser significativo
252
+
253
+ let camelCount = 0
254
+ let snakeCount = 0
255
+ const snakeExamples: string[] = []
256
+ const camelExamples: string[] = []
257
+
258
+ for (const v of vars) {
259
+ const name = v.getName()
260
+ if (isCamelCase(name)) {
261
+ camelCount++
262
+ if (camelExamples.length < 2) camelExamples.push(name)
263
+ } else if (isSnakeCase(name)) {
264
+ snakeCount++
265
+ if (snakeExamples.length < 2) snakeExamples.push(name)
266
+ }
267
+ }
268
+
269
+ if (camelCount >= 1 && snakeCount >= 1) {
270
+ issues.push({
271
+ rule: 'naming-inconsistency',
272
+ severity: 'warning',
273
+ message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
274
+ line: fn.getStartLineNumber(),
275
+ column: fn.getStartLinePos(),
276
+ snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
277
+ })
278
+ }
279
+ }
280
+
281
+ for (const fn of file.getFunctions()) {
282
+ checkFunction(fn)
283
+ }
284
+
285
+ for (const cls of file.getClasses()) {
286
+ for (const method of cls.getMethods()) {
287
+ checkFunction(method)
288
+ }
289
+ }
290
+
291
+ return issues
292
+ }
@@ -0,0 +1,132 @@
1
+ import * as crypto from 'node:crypto'
2
+ import {
3
+ SourceFile,
4
+ SyntaxKind,
5
+ Node,
6
+ FunctionDeclaration,
7
+ ArrowFunction,
8
+ FunctionExpression,
9
+ MethodDeclaration,
10
+ } from 'ts-morph'
11
+ import type { DriftIssue } from '../types.js'
12
+
13
+ export type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
14
+
15
+ /** Normalize a function body to a canonical string (Type-2 clone detection).
16
+ * Variable names, parameter names, and numeric/string literals are replaced
17
+ * with canonical tokens so that two functions with identical logic but
18
+ * different identifiers produce the same fingerprint.
19
+ */
20
+ export function normalizeFunctionBody(fn: FunctionLikeNode): string {
21
+ // Build a substitution map: localName → canonical token
22
+ const subst = new Map<string, string>()
23
+
24
+ // Map parameters first
25
+ for (const [i, param] of fn.getParameters().entries()) {
26
+ const name = param.getName()
27
+ if (name && name !== '_') subst.set(name, `P${i}`)
28
+ }
29
+
30
+ // Map locally declared variables (VariableDeclaration)
31
+ let varIdx = 0
32
+ fn.forEachDescendant(node => {
33
+ if (node.getKind() === SyntaxKind.VariableDeclaration) {
34
+ const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
35
+ // Support destructuring — getNameNode() may be a BindingPattern
36
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
37
+ const name = nameNode.getText()
38
+ if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
39
+ }
40
+ }
41
+ })
42
+
43
+ function serializeNode(node: Node): string {
44
+ const kind = node.getKindName()
45
+
46
+ switch (node.getKind()) {
47
+ case SyntaxKind.Identifier: {
48
+ const text = node.getText()
49
+ return subst.get(text) ?? text // external refs (Math, console) kept as-is
50
+ }
51
+ case SyntaxKind.NumericLiteral:
52
+ return 'NL'
53
+ case SyntaxKind.StringLiteral:
54
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
55
+ return 'SL'
56
+ case SyntaxKind.TrueKeyword:
57
+ return 'TRUE'
58
+ case SyntaxKind.FalseKeyword:
59
+ return 'FALSE'
60
+ case SyntaxKind.NullKeyword:
61
+ return 'NULL'
62
+ }
63
+
64
+ const children = node.getChildren()
65
+ if (children.length === 0) return kind
66
+
67
+ const childStr = children.map(serializeNode).join('|')
68
+ return `${kind}(${childStr})`
69
+ }
70
+
71
+ const body = fn.getBody()
72
+ if (!body) return ''
73
+ return serializeNode(body)
74
+ }
75
+
76
+ /** Return a SHA-256 fingerprint for a function body (normalized). */
77
+ export function fingerprintFunction(fn: FunctionLikeNode): string {
78
+ const normalized = normalizeFunctionBody(fn)
79
+ return crypto.createHash('sha256').update(normalized).digest('hex')
80
+ }
81
+
82
+ /** Return all function-like nodes from a SourceFile that are worth comparing:
83
+ * - At least MIN_LINES lines in their body
84
+ * - Not test helpers (describe/it/test/beforeEach/afterEach)
85
+ */
86
+ const MIN_LINES = 8
87
+
88
+ export function collectFunctions(sf: SourceFile): Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> {
89
+ const results: Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> = []
90
+
91
+ const kinds = [
92
+ SyntaxKind.FunctionDeclaration,
93
+ SyntaxKind.FunctionExpression,
94
+ SyntaxKind.ArrowFunction,
95
+ SyntaxKind.MethodDeclaration,
96
+ ] as const
97
+
98
+ for (const kind of kinds) {
99
+ for (const node of sf.getDescendantsOfKind(kind)) {
100
+ const body = (node as FunctionLikeNode).getBody()
101
+ if (!body) continue
102
+
103
+ const start = body.getStartLineNumber()
104
+ const end = body.getEndLineNumber()
105
+ if (end - start + 1 < MIN_LINES) continue
106
+
107
+ // Skip test-framework helpers
108
+ const name = node.getKind() === SyntaxKind.FunctionDeclaration
109
+ ? (node as FunctionDeclaration).getName() ?? '<anonymous>'
110
+ : node.getKind() === SyntaxKind.MethodDeclaration
111
+ ? (node as MethodDeclaration).getName()
112
+ : '<anonymous>'
113
+
114
+ if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name)) continue
115
+
116
+ const pos = node.getStart()
117
+ const lineInfo = sf.getLineAndColumnAtPos(pos)
118
+
119
+ results.push({ fn: node as FunctionLikeNode, name, line: lineInfo.line, col: lineInfo.column })
120
+ }
121
+ }
122
+
123
+ return results
124
+ }
125
+
126
+ export function calculateScore(issues: DriftIssue[], ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>): number {
127
+ let raw = 0
128
+ for (const issue of issues) {
129
+ raw += ruleWeights[issue.rule]?.weight ?? 5
130
+ }
131
+ return Math.min(100, raw)
132
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ SourceFile,
3
+ Node,
4
+ FunctionDeclaration,
5
+ ArrowFunction,
6
+ FunctionExpression,
7
+ MethodDeclaration,
8
+ } from 'ts-morph'
9
+
10
+ export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
11
+
12
+ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
13
+ const lines = file.getFullText().split('\n')
14
+ const currentLine = lines[line - 1] ?? ''
15
+ const prevLine = lines[line - 2] ?? ''
16
+
17
+ if (/\/\/\s*drift-ignore\b/.test(currentLine)) return true
18
+ if (/\/\/\s*drift-ignore\b/.test(prevLine)) return true
19
+ return false
20
+ }
21
+
22
+ export function isFileIgnored(file: SourceFile): boolean {
23
+ const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n') // drift-ignore
24
+ return /\/\/\s*drift-ignore-file\b/.test(firstLines)
25
+ }
26
+
27
+ export function getSnippet(node: Node, file: SourceFile): string {
28
+ const startLine = node.getStartLineNumber()
29
+ const lines = file.getFullText().split('\n')
30
+ return lines
31
+ .slice(Math.max(0, startLine - 1), startLine + 1)
32
+ .join('\n')
33
+ .trim()
34
+ .slice(0, 120) // drift-ignore
35
+ }
36
+
37
+ export function getFunctionLikeLines(node: FunctionLike): number {
38
+ return node.getEndLineNumber() - node.getStartLineNumber()
39
+ }
@@ -0,0 +1,45 @@
1
+ // drift-ignore-file
2
+ import { Project } from 'ts-morph'
3
+ import { analyzeFile } from '../src/analyzer.js'
4
+ import type { DriftConfig } from '../src/types.js'
5
+ import type { FileReport } from '../src/types.js'
6
+
7
+ /**
8
+ * Crea un SourceFile temporal en memoria y corre analyzeFile sobre él.
9
+ * El filePath por defecto es 'test.ts' (no en test/spec para que
10
+ * hardcoded-config NO lo skip automáticamente).
11
+ * Acepta un filename opcional para testear .js/.jsx/.tsx.
12
+ */
13
+ export function analyzeCode(code: string, config?: Partial<DriftConfig>, filename = 'test.ts'): FileReport {
14
+ const project = new Project({
15
+ useInMemoryFileSystem: true,
16
+ compilerOptions: { allowJs: true, jsx: 1 }, // 1 = JsxEmit.Preserve
17
+ })
18
+ const sourceFile = project.createSourceFile(filename, code)
19
+ return analyzeFile(sourceFile, config as DriftConfig)
20
+ }
21
+
22
+ /** Extrae solo los nombres de reglas que dispararon */
23
+ export function getRules(code: string, config?: Partial<DriftConfig>, filename = 'test.ts'): string[] {
24
+ return analyzeCode(code, config, filename).issues.map(i => i.rule)
25
+ }
26
+
27
+ /** Cuenta cuántas veces disparó una regla específica */
28
+ export function countRule(code: string, rule: string, filePath = 'test.ts'): number {
29
+ return analyzeCode(code, undefined, filePath).issues.filter(i => i.rule === rule).length
30
+ }
31
+
32
+ /** Genera N líneas de código válido TypeScript (para large-file) */
33
+ export function generateLines(n: number): string {
34
+ const lines: string[] = []
35
+ for (let i = 0; i < n; i++) {
36
+ lines.push(`const _v${i} = ${i}`)
37
+ }
38
+ return lines.join('\n')
39
+ }
40
+
41
+ /** Genera una función con N líneas de cuerpo (para large-function) */
42
+ export function generateFunction(bodyLines: number): string {
43
+ const body = Array.from({ length: bodyLines }, (_, i) => ` const _x${i} = ${i}`).join('\n')
44
+ return `function bigFn(): void {\n${body}\n}`
45
+ }