@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,148 @@
1
+ import * as path from 'node:path';
2
+ /**
3
+ * DFS cycle detection in a directed import graph.
4
+ * Returns arrays of file paths that form cycles.
5
+ */
6
+ export function findCycles(graph) {
7
+ const visited = new Set();
8
+ const inStack = new Set();
9
+ const cycles = [];
10
+ function dfs(node, stack) {
11
+ visited.add(node);
12
+ inStack.add(node);
13
+ stack.push(node);
14
+ for (const neighbor of graph.get(node) ?? []) {
15
+ if (!visited.has(neighbor)) {
16
+ dfs(neighbor, stack);
17
+ }
18
+ else if (inStack.has(neighbor)) {
19
+ // Found a cycle — extract the cycle portion from the stack
20
+ const cycleStart = stack.indexOf(neighbor);
21
+ cycles.push(stack.slice(cycleStart));
22
+ }
23
+ }
24
+ stack.pop();
25
+ inStack.delete(node);
26
+ }
27
+ for (const node of graph.keys()) {
28
+ if (!visited.has(node)) {
29
+ dfs(node, []);
30
+ }
31
+ }
32
+ return cycles;
33
+ }
34
+ /**
35
+ * Detect circular dependencies from the import graph.
36
+ * Returns a map of filePath → issue (one per unique cycle).
37
+ */
38
+ export function detectCircularDependencies(importGraph, ruleWeights) {
39
+ const cycles = findCycles(importGraph);
40
+ const reportedCycleKeys = new Set();
41
+ const result = new Map();
42
+ for (const cycle of cycles) {
43
+ const cycleKey = [...cycle].sort().join('|');
44
+ if (reportedCycleKeys.has(cycleKey))
45
+ continue;
46
+ reportedCycleKeys.add(cycleKey);
47
+ const firstFile = cycle[0];
48
+ if (!firstFile)
49
+ continue;
50
+ const cycleDisplay = cycle
51
+ .map(p => path.basename(p))
52
+ .concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
53
+ .join(' → ');
54
+ result.set(firstFile, {
55
+ rule: 'circular-dependency',
56
+ severity: ruleWeights['circular-dependency'].severity,
57
+ message: `Circular dependency detected: ${cycleDisplay}`,
58
+ line: 1,
59
+ column: 1,
60
+ snippet: cycleDisplay,
61
+ });
62
+ }
63
+ return result;
64
+ }
65
+ /**
66
+ * Detect layer violations based on user-defined layer configuration.
67
+ * Returns a map of filePath → issues[].
68
+ */
69
+ export function detectLayerViolations(importGraph, layers, targetPath, ruleWeights) {
70
+ const result = new Map();
71
+ function getLayer(filePath) {
72
+ const rel = filePath.replace(/\\/g, '/');
73
+ return layers.find(layer => layer.patterns.some(pattern => {
74
+ const regexStr = pattern
75
+ .replace(/\\/g, '/')
76
+ .replace(/[.+^${}()|[\]]/g, '\\$&')
77
+ .replace(/\*\*/g, '###DOUBLESTAR###')
78
+ .replace(/\*/g, '[^/]*')
79
+ .replace(/###DOUBLESTAR###/g, '.*');
80
+ return new RegExp(`^${regexStr}`).test(rel);
81
+ }));
82
+ }
83
+ for (const [filePath, imports] of importGraph.entries()) {
84
+ const fileLayer = getLayer(filePath);
85
+ if (!fileLayer)
86
+ continue;
87
+ for (const importedPath of imports) {
88
+ const importedLayer = getLayer(importedPath);
89
+ if (!importedLayer)
90
+ continue;
91
+ if (importedLayer.name === fileLayer.name)
92
+ continue;
93
+ if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
94
+ if (!result.has(filePath))
95
+ result.set(filePath, []);
96
+ result.get(filePath).push({
97
+ rule: 'layer-violation',
98
+ severity: 'error',
99
+ message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
100
+ line: 1,
101
+ column: 1,
102
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
103
+ });
104
+ }
105
+ }
106
+ }
107
+ return result;
108
+ }
109
+ /**
110
+ * Detect cross-boundary imports based on user-defined module boundary configuration.
111
+ * Returns a map of filePath → issues[].
112
+ */
113
+ export function detectCrossBoundaryImports(importGraph, modules, targetPath, ruleWeights) {
114
+ const result = new Map();
115
+ function getModule(filePath) {
116
+ const rel = filePath.replace(/\\/g, '/');
117
+ return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')));
118
+ }
119
+ for (const [filePath, imports] of importGraph.entries()) {
120
+ const fileModule = getModule(filePath);
121
+ if (!fileModule)
122
+ continue;
123
+ for (const importedPath of imports) {
124
+ const importedModule = getModule(importedPath);
125
+ if (!importedModule)
126
+ continue;
127
+ if (importedModule.name === fileModule.name)
128
+ continue;
129
+ const allowedImports = fileModule.allowedExternalImports ?? [];
130
+ const relImported = importedPath.replace(/\\/g, '/');
131
+ const isAllowed = allowedImports.some(allowed => relImported.startsWith(allowed.replace(/\\/g, '/')));
132
+ if (!isAllowed) {
133
+ if (!result.has(filePath))
134
+ result.set(filePath, []);
135
+ result.get(filePath).push({
136
+ rule: 'cross-boundary-import',
137
+ severity: 'warning',
138
+ message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
139
+ line: 1,
140
+ column: 1,
141
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
142
+ });
143
+ }
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+ //# sourceMappingURL=phase3-arch.js.map
@@ -0,0 +1,8 @@
1
+ import { SourceFile } from 'ts-morph';
2
+ import type { DriftIssue } from '../types.js';
3
+ export declare function detectOverCommented(file: SourceFile): DriftIssue[];
4
+ export declare function detectHardcodedConfig(file: SourceFile): DriftIssue[];
5
+ export declare function detectInconsistentErrorHandling(file: SourceFile): DriftIssue[];
6
+ export declare function detectUnnecessaryAbstraction(file: SourceFile): DriftIssue[];
7
+ export declare function detectNamingInconsistency(file: SourceFile): DriftIssue[];
8
+ //# sourceMappingURL=phase5-ai.d.ts.map
@@ -0,0 +1,262 @@
1
+ // drift-ignore-file
2
+ import { SyntaxKind } from 'ts-morph';
3
+ export function detectOverCommented(file) {
4
+ const issues = [];
5
+ for (const fn of file.getFunctions()) {
6
+ const body = fn.getBody();
7
+ if (!body)
8
+ continue;
9
+ const bodyText = body.getText();
10
+ const lines = bodyText.split('\n');
11
+ const totalLines = lines.length;
12
+ if (totalLines < 6)
13
+ continue;
14
+ let commentLines = 0;
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
18
+ commentLines++;
19
+ }
20
+ }
21
+ const ratio = commentLines / totalLines;
22
+ if (ratio >= 0.4) {
23
+ issues.push({
24
+ rule: 'over-commented',
25
+ severity: 'info',
26
+ message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
27
+ line: fn.getStartLineNumber(),
28
+ column: fn.getStartLinePos(),
29
+ snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
30
+ });
31
+ }
32
+ }
33
+ for (const cls of file.getClasses()) {
34
+ for (const method of cls.getMethods()) {
35
+ const body = method.getBody();
36
+ if (!body)
37
+ continue;
38
+ const bodyText = body.getText();
39
+ const lines = bodyText.split('\n');
40
+ const totalLines = lines.length;
41
+ if (totalLines < 6)
42
+ continue;
43
+ let commentLines = 0;
44
+ for (const line of lines) {
45
+ const trimmed = line.trim();
46
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
47
+ commentLines++;
48
+ }
49
+ }
50
+ const ratio = commentLines / totalLines;
51
+ if (ratio >= 0.4) {
52
+ issues.push({
53
+ rule: 'over-commented',
54
+ severity: 'info',
55
+ message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
56
+ line: method.getStartLineNumber(),
57
+ column: method.getStartLinePos(),
58
+ snippet: `${cls.getName()}.${method.getName()}`,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return issues;
64
+ }
65
+ export function detectHardcodedConfig(file) {
66
+ const issues = [];
67
+ const CONFIG_PATTERNS = [
68
+ { pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
69
+ { pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
70
+ { pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
71
+ { pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
72
+ { pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
73
+ { pattern: /^redis:\/\//i, label: 'Redis connection string' },
74
+ { pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
75
+ { pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
76
+ { pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
77
+ { pattern: /^\/[a-z]/i, label: 'Absolute file path' },
78
+ { pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
79
+ ];
80
+ const filePath = file.getFilePath().replace(/\\/g, '/');
81
+ if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
82
+ return issues;
83
+ }
84
+ for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
85
+ const value = node.getLiteralValue();
86
+ if (!value || value.length < 4)
87
+ continue;
88
+ const parent = node.getParent();
89
+ if (!parent)
90
+ continue;
91
+ const parentKind = parent.getKindName();
92
+ if (parentKind === 'ImportDeclaration' ||
93
+ parentKind === 'ExportDeclaration' ||
94
+ (parentKind === 'CallExpression' && parent.getText().startsWith('import(')))
95
+ continue;
96
+ for (const { pattern, label } of CONFIG_PATTERNS) {
97
+ if (pattern.test(value)) {
98
+ issues.push({
99
+ rule: 'hardcoded-config',
100
+ severity: 'warning',
101
+ message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
102
+ line: node.getStartLineNumber(),
103
+ column: node.getStartLinePos(),
104
+ snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
105
+ });
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ return issues;
111
+ }
112
+ export function detectInconsistentErrorHandling(file) {
113
+ const issues = [];
114
+ let hasTryCatch = false;
115
+ let hasDotCatch = false;
116
+ let hasThenErrorHandler = false;
117
+ let firstLine = 0;
118
+ // Detectar try/catch
119
+ const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement);
120
+ if (tryCatches.length > 0) {
121
+ hasTryCatch = true;
122
+ firstLine = firstLine || tryCatches[0].getStartLineNumber();
123
+ }
124
+ // Detectar .catch(handler) en call expressions
125
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
126
+ const expr = call.getExpression();
127
+ if (expr.getKindName() === 'PropertyAccessExpression') {
128
+ const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
129
+ const propName = propAccess.getName();
130
+ if (propName === 'catch') {
131
+ // Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
132
+ if (call.getArguments().length > 0) {
133
+ hasDotCatch = true;
134
+ if (!firstLine)
135
+ firstLine = call.getStartLineNumber();
136
+ }
137
+ }
138
+ // Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
139
+ if (propName === 'then' && call.getArguments().length >= 2) {
140
+ hasThenErrorHandler = true;
141
+ if (!firstLine)
142
+ firstLine = call.getStartLineNumber();
143
+ }
144
+ }
145
+ }
146
+ const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length;
147
+ if (stylesUsed >= 2) {
148
+ const styles = [];
149
+ if (hasTryCatch)
150
+ styles.push('try/catch');
151
+ if (hasDotCatch)
152
+ styles.push('.catch()');
153
+ if (hasThenErrorHandler)
154
+ styles.push('.then(_, handler)');
155
+ issues.push({
156
+ rule: 'inconsistent-error-handling',
157
+ severity: 'warning',
158
+ message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
159
+ line: firstLine || 1,
160
+ column: 1,
161
+ snippet: styles.join(' + '),
162
+ });
163
+ }
164
+ return issues;
165
+ }
166
+ export function detectUnnecessaryAbstraction(file) {
167
+ const issues = [];
168
+ const fileText = file.getFullText();
169
+ // Interfaces con un solo método
170
+ for (const iface of file.getInterfaces()) {
171
+ const methods = iface.getMethods();
172
+ const properties = iface.getProperties();
173
+ // Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
174
+ if (methods.length !== 1 || properties.length !== 0)
175
+ continue;
176
+ const ifaceName = iface.getName();
177
+ // Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
178
+ const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length;
179
+ // La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
180
+ // Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
181
+ if (usageCount <= 2) {
182
+ issues.push({
183
+ rule: 'unnecessary-abstraction',
184
+ severity: 'warning',
185
+ message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
186
+ line: iface.getStartLineNumber(),
187
+ column: iface.getStartLinePos(),
188
+ snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
189
+ });
190
+ }
191
+ }
192
+ // Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
193
+ for (const cls of file.getClasses()) {
194
+ if (!cls.isAbstract())
195
+ continue;
196
+ const abstractMethods = cls.getMethods().filter(m => m.isAbstract());
197
+ const concreteMethods = cls.getMethods().filter(m => !m.isAbstract());
198
+ if (abstractMethods.length !== 1 || concreteMethods.length !== 0)
199
+ continue;
200
+ const clsName = cls.getName() ?? '';
201
+ const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length;
202
+ if (usageCount <= 2) {
203
+ issues.push({
204
+ rule: 'unnecessary-abstraction',
205
+ severity: 'warning',
206
+ message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
207
+ line: cls.getStartLineNumber(),
208
+ column: cls.getStartLinePos(),
209
+ snippet: `abstract class ${clsName}`,
210
+ });
211
+ }
212
+ }
213
+ return issues;
214
+ }
215
+ export function detectNamingInconsistency(file) {
216
+ const issues = [];
217
+ const isCamelCase = (name) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name);
218
+ const isSnakeCase = (name) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name);
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ function checkFunction(fn) {
221
+ const vars = fn.getVariableDeclarations();
222
+ if (vars.length < 3)
223
+ return; // muy pocas vars para ser significativo
224
+ let camelCount = 0;
225
+ let snakeCount = 0;
226
+ const snakeExamples = [];
227
+ const camelExamples = [];
228
+ for (const v of vars) {
229
+ const name = v.getName();
230
+ if (isCamelCase(name)) {
231
+ camelCount++;
232
+ if (camelExamples.length < 2)
233
+ camelExamples.push(name);
234
+ }
235
+ else if (isSnakeCase(name)) {
236
+ snakeCount++;
237
+ if (snakeExamples.length < 2)
238
+ snakeExamples.push(name);
239
+ }
240
+ }
241
+ if (camelCount >= 1 && snakeCount >= 1) {
242
+ issues.push({
243
+ rule: 'naming-inconsistency',
244
+ severity: 'warning',
245
+ message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
246
+ line: fn.getStartLineNumber(),
247
+ column: fn.getStartLinePos(),
248
+ snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
249
+ });
250
+ }
251
+ }
252
+ for (const fn of file.getFunctions()) {
253
+ checkFunction(fn);
254
+ }
255
+ for (const cls of file.getClasses()) {
256
+ for (const method of cls.getMethods()) {
257
+ checkFunction(method);
258
+ }
259
+ }
260
+ return issues;
261
+ }
262
+ //# sourceMappingURL=phase5-ai.js.map
@@ -0,0 +1,22 @@
1
+ import { SourceFile, FunctionDeclaration, ArrowFunction, FunctionExpression, MethodDeclaration } from 'ts-morph';
2
+ import type { DriftIssue } from '../types.js';
3
+ export type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration;
4
+ /** Normalize a function body to a canonical string (Type-2 clone detection).
5
+ * Variable names, parameter names, and numeric/string literals are replaced
6
+ * with canonical tokens so that two functions with identical logic but
7
+ * different identifiers produce the same fingerprint.
8
+ */
9
+ export declare function normalizeFunctionBody(fn: FunctionLikeNode): string;
10
+ /** Return a SHA-256 fingerprint for a function body (normalized). */
11
+ export declare function fingerprintFunction(fn: FunctionLikeNode): string;
12
+ export declare function collectFunctions(sf: SourceFile): Array<{
13
+ fn: FunctionLikeNode;
14
+ name: string;
15
+ line: number;
16
+ col: number;
17
+ }>;
18
+ export declare function calculateScore(issues: DriftIssue[], ruleWeights: Record<string, {
19
+ severity: DriftIssue['severity'];
20
+ weight: number;
21
+ }>): number;
22
+ //# sourceMappingURL=phase8-semantic.d.ts.map
@@ -0,0 +1,109 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { SyntaxKind, } from 'ts-morph';
3
+ /** Normalize a function body to a canonical string (Type-2 clone detection).
4
+ * Variable names, parameter names, and numeric/string literals are replaced
5
+ * with canonical tokens so that two functions with identical logic but
6
+ * different identifiers produce the same fingerprint.
7
+ */
8
+ export function normalizeFunctionBody(fn) {
9
+ // Build a substitution map: localName → canonical token
10
+ const subst = new Map();
11
+ // Map parameters first
12
+ for (const [i, param] of fn.getParameters().entries()) {
13
+ const name = param.getName();
14
+ if (name && name !== '_')
15
+ subst.set(name, `P${i}`);
16
+ }
17
+ // Map locally declared variables (VariableDeclaration)
18
+ let varIdx = 0;
19
+ fn.forEachDescendant(node => {
20
+ if (node.getKind() === SyntaxKind.VariableDeclaration) {
21
+ const nameNode = node.getNameNode();
22
+ // Support destructuring — getNameNode() may be a BindingPattern
23
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
24
+ const name = nameNode.getText();
25
+ if (!subst.has(name))
26
+ subst.set(name, `V${varIdx++}`);
27
+ }
28
+ }
29
+ });
30
+ function serializeNode(node) {
31
+ const kind = node.getKindName();
32
+ switch (node.getKind()) {
33
+ case SyntaxKind.Identifier: {
34
+ const text = node.getText();
35
+ return subst.get(text) ?? text; // external refs (Math, console) kept as-is
36
+ }
37
+ case SyntaxKind.NumericLiteral:
38
+ return 'NL';
39
+ case SyntaxKind.StringLiteral:
40
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
41
+ return 'SL';
42
+ case SyntaxKind.TrueKeyword:
43
+ return 'TRUE';
44
+ case SyntaxKind.FalseKeyword:
45
+ return 'FALSE';
46
+ case SyntaxKind.NullKeyword:
47
+ return 'NULL';
48
+ }
49
+ const children = node.getChildren();
50
+ if (children.length === 0)
51
+ return kind;
52
+ const childStr = children.map(serializeNode).join('|');
53
+ return `${kind}(${childStr})`;
54
+ }
55
+ const body = fn.getBody();
56
+ if (!body)
57
+ return '';
58
+ return serializeNode(body);
59
+ }
60
+ /** Return a SHA-256 fingerprint for a function body (normalized). */
61
+ export function fingerprintFunction(fn) {
62
+ const normalized = normalizeFunctionBody(fn);
63
+ return crypto.createHash('sha256').update(normalized).digest('hex');
64
+ }
65
+ /** Return all function-like nodes from a SourceFile that are worth comparing:
66
+ * - At least MIN_LINES lines in their body
67
+ * - Not test helpers (describe/it/test/beforeEach/afterEach)
68
+ */
69
+ const MIN_LINES = 8;
70
+ export function collectFunctions(sf) {
71
+ const results = [];
72
+ const kinds = [
73
+ SyntaxKind.FunctionDeclaration,
74
+ SyntaxKind.FunctionExpression,
75
+ SyntaxKind.ArrowFunction,
76
+ SyntaxKind.MethodDeclaration,
77
+ ];
78
+ for (const kind of kinds) {
79
+ for (const node of sf.getDescendantsOfKind(kind)) {
80
+ const body = node.getBody();
81
+ if (!body)
82
+ continue;
83
+ const start = body.getStartLineNumber();
84
+ const end = body.getEndLineNumber();
85
+ if (end - start + 1 < MIN_LINES)
86
+ continue;
87
+ // Skip test-framework helpers
88
+ const name = node.getKind() === SyntaxKind.FunctionDeclaration
89
+ ? node.getName() ?? '<anonymous>'
90
+ : node.getKind() === SyntaxKind.MethodDeclaration
91
+ ? node.getName()
92
+ : '<anonymous>';
93
+ if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name))
94
+ continue;
95
+ const pos = node.getStart();
96
+ const lineInfo = sf.getLineAndColumnAtPos(pos);
97
+ results.push({ fn: node, name, line: lineInfo.line, col: lineInfo.column });
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+ export function calculateScore(issues, ruleWeights) {
103
+ let raw = 0;
104
+ for (const issue of issues) {
105
+ raw += ruleWeights[issue.rule]?.weight ?? 5;
106
+ }
107
+ return Math.min(100, raw);
108
+ }
109
+ //# sourceMappingURL=phase8-semantic.js.map
@@ -0,0 +1,7 @@
1
+ import { SourceFile, Node, FunctionDeclaration, ArrowFunction, FunctionExpression, MethodDeclaration } from 'ts-morph';
2
+ export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration;
3
+ export declare function hasIgnoreComment(file: SourceFile, line: number): boolean;
4
+ export declare function isFileIgnored(file: SourceFile): boolean;
5
+ export declare function getSnippet(node: Node, file: SourceFile): string;
6
+ export declare function getFunctionLikeLines(node: FunctionLike): number;
7
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1,27 @@
1
+ export function hasIgnoreComment(file, line) {
2
+ const lines = file.getFullText().split('\n');
3
+ const currentLine = lines[line - 1] ?? '';
4
+ const prevLine = lines[line - 2] ?? '';
5
+ if (/\/\/\s*drift-ignore\b/.test(currentLine))
6
+ return true;
7
+ if (/\/\/\s*drift-ignore\b/.test(prevLine))
8
+ return true;
9
+ return false;
10
+ }
11
+ export function isFileIgnored(file) {
12
+ const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n'); // drift-ignore
13
+ return /\/\/\s*drift-ignore-file\b/.test(firstLines);
14
+ }
15
+ export function getSnippet(node, file) {
16
+ const startLine = node.getStartLineNumber();
17
+ const lines = file.getFullText().split('\n');
18
+ return lines
19
+ .slice(Math.max(0, startLine - 1), startLine + 1)
20
+ .join('\n')
21
+ .trim()
22
+ .slice(0, 120); // drift-ignore
23
+ }
24
+ export function getFunctionLikeLines(node) {
25
+ return node.getEndLineNumber() - node.getStartLineNumber();
26
+ }
27
+ //# sourceMappingURL=shared.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eduardbar/drift",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "Detect silent technical debt left by AI-generated code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,7 +11,10 @@
11
11
  "build": "tsc",
12
12
  "dev": "tsc --watch",
13
13
  "start": "node dist/cli.js",
14
- "prepublishOnly": "npm run build"
14
+ "prepublishOnly": "npm run build",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "test:coverage": "vitest run --coverage"
15
18
  },
16
19
  "keywords": [
17
20
  "vibe-coding",
@@ -38,6 +41,8 @@
38
41
  },
39
42
  "devDependencies": {
40
43
  "@types/node": "^25.3.0",
41
- "typescript": "^5.9.3"
44
+ "@vitest/coverage-v8": "^4.0.18",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18"
42
47
  }
43
48
  }
@@ -0,0 +1,9 @@
1
+ .vscode/**
2
+ src/**
3
+ node_modules/**
4
+ **/*.map
5
+ **/*.ts
6
+ !**/*.d.ts
7
+ tsconfig.json
8
+ .gitignore
9
+ images/*.svg
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Eduard Barrera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.