@eduardbar/drift 0.6.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -18,6 +18,28 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
18
18
 
19
19
  ---
20
20
 
21
+ ## [0.8.0] - 2026-02-24
22
+
23
+ ### Added
24
+ - `semantic-duplication` rule — Type-2 AST clone detection via SHA-256 fingerprinting
25
+ - Normalizes parameter names, local variable names, and literals before hashing — detects identical logic with different variable names
26
+ - Runs cross-file across the entire project; reports each duplicate pointing to all other locations
27
+ - Minimum threshold: functions with ≥ 8 body lines (reduces noise from trivial helpers)
28
+ - Skips test framework helpers (describe, it, test, beforeEach, afterEach)
29
+ - RULE_WEIGHTS entry: severity `warning`, weight `12`
30
+
31
+ ---
32
+
33
+ ## [0.7.0] - 2026-02-24
34
+
35
+ ### Added
36
+ - `eslint-plugin-drift` — separate npm package exposing all 26 drift rules as ESLint 9 flat config rules
37
+ - Each rule wraps drift's AST engine via `analyzeFile()` with a shared ts-morph `Project` instance
38
+ - Per-file result cache (max 100 entries) to prevent redundant analysis in watch mode
39
+ - `recommended` config array enabling all 26 rules at their canonical drift severity
40
+
41
+ ---
42
+
21
43
  ## [0.5.0] — 2026-02-24
22
44
 
23
45
  ### Added
package/assets/og.svg CHANGED
@@ -1,105 +1,105 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
2
- <defs>
3
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
- <stop offset="0%" style="stop-color:#0a0a0f"/>
5
- <stop offset="100%" style="stop-color:#0f0f1a"/>
6
- </linearGradient>
7
- <linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
8
- <stop offset="0%" style="stop-color:#6366f1"/>
9
- <stop offset="100%" style="stop-color:#8b5cf6"/>
10
- </linearGradient>
11
- <linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
12
- <stop offset="0%" style="stop-color:#6366f1;stop-opacity:0.15"/>
13
- <stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:0.05"/>
14
- </linearGradient>
15
- <filter id="blur-glow">
16
- <feGaussianBlur stdDeviation="40" result="blur"/>
17
- <feComposite in="SourceGraphic" in2="blur" operator="over"/>
18
- </filter>
19
- <filter id="soft-glow">
20
- <feGaussianBlur stdDeviation="3" result="blur"/>
21
- <feComposite in="SourceGraphic" in2="blur" operator="over"/>
22
- </filter>
23
- </defs>
24
-
25
- <!-- Background -->
26
- <rect width="1200" height="630" fill="url(#bg)"/>
27
-
28
- <!-- Ambient glow top-left -->
29
- <ellipse cx="200" cy="200" rx="300" ry="250" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.8"/>
30
-
31
- <!-- Ambient glow bottom-right -->
32
- <ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.6"/>
33
-
34
- <!-- Grid lines subtle -->
35
- <line x1="0" y1="210" x2="1200" y2="210" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
36
- <line x1="0" y1="420" x2="1200" y2="420" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
37
- <line x1="300" y1="0" x2="300" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
38
- <line x1="900" y1="0" x2="900" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
39
-
40
- <!-- Left accent bar -->
41
- <rect x="72" y="180" width="4" height="270" rx="2" fill="url(#accent)"/>
42
-
43
- <!-- Main title "drift" -->
44
- <text x="102" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', monospace" font-size="120" font-weight="700" fill="#ffffff" letter-spacing="-4">drift</text>
45
-
46
- <!-- Subtitle -->
47
- <text x="104" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="400" fill="#94a3b8" letter-spacing="0.5">Detect silent technical debt left by AI-generated code.</text>
48
-
49
- <!-- Terminal block right side -->
50
- <rect x="680" y="165" width="448" height="300" rx="12" fill="#0d0d18" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
51
-
52
- <!-- Terminal header bar -->
53
- <rect x="680" y="165" width="448" height="40" rx="12" fill="#1a1a2e"/>
54
- <rect x="680" y="185" width="448" height="20" fill="#1a1a2e"/>
55
-
56
- <!-- Terminal dots -->
57
- <circle cx="710" cy="185" r="6" fill="#ff5f57"/>
58
- <circle cx="730" cy="185" r="6" fill="#febc2e"/>
59
- <circle cx="750" cy="185" r="6" fill="#28c840"/>
60
-
61
- <!-- Terminal text -->
62
- <text x="700" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#6366f1">$</text>
63
- <text x="716" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#e2e8f0"> npx @eduardbar/drift scan ./src</text>
64
-
65
- <text x="700" y="265" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> drift — vibe coding debt detector</text>
66
-
67
- <text x="700" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> Score </text>
68
- <text x="755" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" font-weight="700" fill="#ef4444"> 67</text>
69
- <text x="779" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b">/100 HIGH</text>
70
-
71
- <text x="700" y="320" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> 4 file(s) · 5 errors · 12 warnings</text>
72
-
73
- <text x="700" y="352" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b"> src/api/users.ts</text>
74
- <text x="700" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444"> ✖ </text>
75
- <text x="720" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444">large-file</text>
76
- <text x="800" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> 412 lines detected</text>
77
-
78
- <text x="700" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
79
- <text x="720" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">catch-swallow</text>
80
- <text x="820" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> empty catch block</text>
81
-
82
- <text x="700" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
83
- <text x="720" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">any-abuse</text>
84
- <text x="793" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> explicit any type</text>
85
-
86
- <text x="700" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#22c55e"> ◦ </text>
87
- <text x="720" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b">no-return-type</text>
88
- <text x="830" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> missing return type</text>
89
-
90
- <!-- Badges row -->
91
- <rect x="104" y="405" width="100" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
92
- <text x="154" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">TypeScript</text>
93
-
94
- <rect x="214" y="405" width="72" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
95
- <text x="250" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">ts-morph</text>
96
-
97
- <rect x="296" y="405" width="52" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
98
- <text x="322" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">MIT</text>
99
-
100
- <!-- Author -->
101
- <text x="104" y="520" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="16" fill="#334155">github.com/eduardbar/drift</text>
102
-
103
- <!-- Bottom accent line -->
104
- <rect x="0" y="622" width="1200" height="8" fill="url(#accent)" opacity="0.7"/>
105
- </svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#0a0a0f"/>
5
+ <stop offset="100%" style="stop-color:#0f0f1a"/>
6
+ </linearGradient>
7
+ <linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
8
+ <stop offset="0%" style="stop-color:#6366f1"/>
9
+ <stop offset="100%" style="stop-color:#8b5cf6"/>
10
+ </linearGradient>
11
+ <linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
12
+ <stop offset="0%" style="stop-color:#6366f1;stop-opacity:0.15"/>
13
+ <stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:0.05"/>
14
+ </linearGradient>
15
+ <filter id="blur-glow">
16
+ <feGaussianBlur stdDeviation="40" result="blur"/>
17
+ <feComposite in="SourceGraphic" in2="blur" operator="over"/>
18
+ </filter>
19
+ <filter id="soft-glow">
20
+ <feGaussianBlur stdDeviation="3" result="blur"/>
21
+ <feComposite in="SourceGraphic" in2="blur" operator="over"/>
22
+ </filter>
23
+ </defs>
24
+
25
+ <!-- Background -->
26
+ <rect width="1200" height="630" fill="url(#bg)"/>
27
+
28
+ <!-- Ambient glow top-left -->
29
+ <ellipse cx="200" cy="200" rx="300" ry="250" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.8"/>
30
+
31
+ <!-- Ambient glow bottom-right -->
32
+ <ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.6"/>
33
+
34
+ <!-- Grid lines subtle -->
35
+ <line x1="0" y1="210" x2="1200" y2="210" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
36
+ <line x1="0" y1="420" x2="1200" y2="420" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
37
+ <line x1="300" y1="0" x2="300" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
38
+ <line x1="900" y1="0" x2="900" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
39
+
40
+ <!-- Left accent bar -->
41
+ <rect x="72" y="180" width="4" height="270" rx="2" fill="url(#accent)"/>
42
+
43
+ <!-- Main title "drift" -->
44
+ <text x="102" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', monospace" font-size="120" font-weight="700" fill="#ffffff" letter-spacing="-4">drift</text>
45
+
46
+ <!-- Subtitle -->
47
+ <text x="104" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="400" fill="#94a3b8" letter-spacing="0.5">Detect silent technical debt left by AI-generated code.</text>
48
+
49
+ <!-- Terminal block right side -->
50
+ <rect x="680" y="165" width="448" height="300" rx="12" fill="#0d0d18" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
51
+
52
+ <!-- Terminal header bar -->
53
+ <rect x="680" y="165" width="448" height="40" rx="12" fill="#1a1a2e"/>
54
+ <rect x="680" y="185" width="448" height="20" fill="#1a1a2e"/>
55
+
56
+ <!-- Terminal dots -->
57
+ <circle cx="710" cy="185" r="6" fill="#ff5f57"/>
58
+ <circle cx="730" cy="185" r="6" fill="#febc2e"/>
59
+ <circle cx="750" cy="185" r="6" fill="#28c840"/>
60
+
61
+ <!-- Terminal text -->
62
+ <text x="700" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#6366f1">$</text>
63
+ <text x="716" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#e2e8f0"> npx @eduardbar/drift scan ./src</text>
64
+
65
+ <text x="700" y="265" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> drift — vibe coding debt detector</text>
66
+
67
+ <text x="700" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> Score </text>
68
+ <text x="755" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" font-weight="700" fill="#ef4444"> 67</text>
69
+ <text x="779" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b">/100 HIGH</text>
70
+
71
+ <text x="700" y="320" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> 4 file(s) · 5 errors · 12 warnings</text>
72
+
73
+ <text x="700" y="352" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b"> src/api/users.ts</text>
74
+ <text x="700" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444"> ✖ </text>
75
+ <text x="720" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444">large-file</text>
76
+ <text x="800" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> 412 lines detected</text>
77
+
78
+ <text x="700" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
79
+ <text x="720" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">catch-swallow</text>
80
+ <text x="820" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> empty catch block</text>
81
+
82
+ <text x="700" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
83
+ <text x="720" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">any-abuse</text>
84
+ <text x="793" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> explicit any type</text>
85
+
86
+ <text x="700" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#22c55e"> ◦ </text>
87
+ <text x="720" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b">no-return-type</text>
88
+ <text x="830" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> missing return type</text>
89
+
90
+ <!-- Badges row -->
91
+ <rect x="104" y="405" width="100" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
92
+ <text x="154" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">TypeScript</text>
93
+
94
+ <rect x="214" y="405" width="72" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
95
+ <text x="250" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">ts-morph</text>
96
+
97
+ <rect x="296" y="405" width="52" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
98
+ <text x="322" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">MIT</text>
99
+
100
+ <!-- Author -->
101
+ <text x="104" y="520" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="16" fill="#334155">github.com/eduardbar/drift</text>
102
+
103
+ <!-- Bottom accent line -->
104
+ <rect x="0" y="622" width="1200" height="8" fill="url(#accent)" opacity="0.7"/>
105
+ </svg>
@@ -1,5 +1,9 @@
1
1
  import { SourceFile } from 'ts-morph';
2
- import type { FileReport, DriftConfig } from './types.js';
2
+ import type { DriftIssue, FileReport, DriftConfig } from './types.js';
3
+ export declare const RULE_WEIGHTS: Record<string, {
4
+ severity: DriftIssue['severity'];
5
+ weight: number;
6
+ }>;
3
7
  export declare function analyzeFile(file: SourceFile): FileReport;
4
8
  export declare function analyzeProject(targetPath: string, config?: DriftConfig): FileReport[];
5
9
  //# sourceMappingURL=analyzer.d.ts.map
package/dist/analyzer.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as crypto from 'node:crypto';
2
3
  import * as path from 'node:path';
3
4
  import { Project, SyntaxKind, } from 'ts-morph';
4
5
  // Rules and their drift score weight
5
- const RULE_WEIGHTS = {
6
+ export const RULE_WEIGHTS = {
6
7
  'large-file': { severity: 'error', weight: 20 },
7
8
  'large-function': { severity: 'error', weight: 15 },
8
9
  'debug-leftover': { severity: 'warning', weight: 10 },
@@ -34,6 +35,8 @@ const RULE_WEIGHTS = {
34
35
  'inconsistent-error-handling': { severity: 'warning', weight: 8 },
35
36
  'unnecessary-abstraction': { severity: 'warning', weight: 7 },
36
37
  'naming-inconsistency': { severity: 'warning', weight: 6 },
38
+ // Phase 8: semantic duplication
39
+ 'semantic-duplication': { severity: 'warning', weight: 12 },
37
40
  };
38
41
  function hasIgnoreComment(file, line) {
39
42
  const lines = file.getFullText().split('\n');
@@ -786,6 +789,105 @@ function calculateScore(issues) {
786
789
  }
787
790
  return Math.min(100, raw);
788
791
  }
792
+ /** Normalize a function body to a canonical string (Type-2 clone detection).
793
+ * Variable names, parameter names, and numeric/string literals are replaced
794
+ * with canonical tokens so that two functions with identical logic but
795
+ * different identifiers produce the same fingerprint.
796
+ */
797
+ function normalizeFunctionBody(fn) {
798
+ // Build a substitution map: localName → canonical token
799
+ const subst = new Map();
800
+ // Map parameters first
801
+ for (const [i, param] of fn.getParameters().entries()) {
802
+ const name = param.getName();
803
+ if (name && name !== '_')
804
+ subst.set(name, `P${i}`);
805
+ }
806
+ // Map locally declared variables (VariableDeclaration)
807
+ let varIdx = 0;
808
+ fn.forEachDescendant(node => {
809
+ if (node.getKind() === SyntaxKind.VariableDeclaration) {
810
+ const nameNode = node.getNameNode();
811
+ // Support destructuring — getNameNode() may be a BindingPattern
812
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
813
+ const name = nameNode.getText();
814
+ if (!subst.has(name))
815
+ subst.set(name, `V${varIdx++}`);
816
+ }
817
+ }
818
+ });
819
+ function serializeNode(node) {
820
+ const kind = node.getKindName();
821
+ switch (node.getKind()) {
822
+ case SyntaxKind.Identifier: {
823
+ const text = node.getText();
824
+ return subst.get(text) ?? text; // external refs (Math, console) kept as-is
825
+ }
826
+ case SyntaxKind.NumericLiteral:
827
+ return 'NL';
828
+ case SyntaxKind.StringLiteral:
829
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
830
+ return 'SL';
831
+ case SyntaxKind.TrueKeyword:
832
+ return 'TRUE';
833
+ case SyntaxKind.FalseKeyword:
834
+ return 'FALSE';
835
+ case SyntaxKind.NullKeyword:
836
+ return 'NULL';
837
+ }
838
+ const children = node.getChildren();
839
+ if (children.length === 0)
840
+ return kind;
841
+ const childStr = children.map(serializeNode).join('|');
842
+ return `${kind}(${childStr})`;
843
+ }
844
+ const body = fn.getBody();
845
+ if (!body)
846
+ return '';
847
+ return serializeNode(body);
848
+ }
849
+ /** Return a SHA-256 fingerprint for a function body (normalized). */
850
+ function fingerprintFunction(fn) {
851
+ const normalized = normalizeFunctionBody(fn);
852
+ return crypto.createHash('sha256').update(normalized).digest('hex');
853
+ }
854
+ /** Return all function-like nodes from a SourceFile that are worth comparing:
855
+ * - At least MIN_LINES lines in their body
856
+ * - Not test helpers (describe/it/test/beforeEach/afterEach)
857
+ */
858
+ const MIN_LINES = 8;
859
+ function collectFunctions(sf) {
860
+ const results = [];
861
+ const kinds = [
862
+ SyntaxKind.FunctionDeclaration,
863
+ SyntaxKind.FunctionExpression,
864
+ SyntaxKind.ArrowFunction,
865
+ SyntaxKind.MethodDeclaration,
866
+ ];
867
+ for (const kind of kinds) {
868
+ for (const node of sf.getDescendantsOfKind(kind)) {
869
+ const body = node.getBody();
870
+ if (!body)
871
+ continue;
872
+ const start = body.getStartLineNumber();
873
+ const end = body.getEndLineNumber();
874
+ if (end - start + 1 < MIN_LINES)
875
+ continue;
876
+ // Skip test-framework helpers
877
+ const name = node.getKind() === SyntaxKind.FunctionDeclaration
878
+ ? node.getName() ?? '<anonymous>'
879
+ : node.getKind() === SyntaxKind.MethodDeclaration
880
+ ? node.getName()
881
+ : '<anonymous>';
882
+ if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name))
883
+ continue;
884
+ const pos = node.getStart();
885
+ const lineInfo = sf.getLineAndColumnAtPos(pos);
886
+ results.push({ fn: node, name, line: lineInfo.line, col: lineInfo.column });
887
+ }
888
+ }
889
+ return results;
890
+ }
789
891
  // ---------------------------------------------------------------------------
790
892
  // Public API
791
893
  // ---------------------------------------------------------------------------
@@ -1162,6 +1264,46 @@ export function analyzeProject(targetPath, config) {
1162
1264
  }
1163
1265
  }
1164
1266
  }
1267
+ // ── Phase 8: semantic-duplication ────────────────────────────────────────
1268
+ // Build a fingerprint → [{filePath, fnName, line, col}] map across all files
1269
+ const fingerprintMap = new Map();
1270
+ for (const sf of sourceFiles) {
1271
+ const sfPath = sf.getFilePath();
1272
+ for (const { fn, name, line, col } of collectFunctions(sf)) {
1273
+ const fp = fingerprintFunction(fn);
1274
+ if (!fingerprintMap.has(fp))
1275
+ fingerprintMap.set(fp, []);
1276
+ fingerprintMap.get(fp).push({ filePath: sfPath, name, line, col });
1277
+ }
1278
+ }
1279
+ // For each fingerprint with 2+ functions: report each as a duplicate of the others
1280
+ for (const [, entries] of fingerprintMap) {
1281
+ if (entries.length < 2)
1282
+ continue;
1283
+ for (const entry of entries) {
1284
+ const report = reportByPath.get(entry.filePath);
1285
+ if (!report)
1286
+ continue;
1287
+ // Build the "duplicated in" list (all other locations)
1288
+ const others = entries
1289
+ .filter(e => e !== entry)
1290
+ .map(e => {
1291
+ const rel = path.relative(targetPath, e.filePath).replace(/\\/g, '/');
1292
+ return `${rel}:${e.line} (${e.name})`;
1293
+ })
1294
+ .join(', ');
1295
+ const weight = RULE_WEIGHTS['semantic-duplication']?.weight ?? 12;
1296
+ report.issues.push({
1297
+ rule: 'semantic-duplication',
1298
+ severity: 'warning',
1299
+ message: `Function '${entry.name}' is semantically identical to: ${others}`,
1300
+ line: entry.line,
1301
+ column: entry.col,
1302
+ snippet: `function ${entry.name} — duplicated in ${entries.length - 1} other location${entries.length > 2 ? 's' : ''}`,
1303
+ });
1304
+ report.score = Math.min(100, report.score + weight);
1305
+ }
1306
+ }
1165
1307
  return reports;
1166
1308
  }
1167
1309
  //# sourceMappingURL=analyzer.js.map
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { analyzeProject, analyzeFile } from './analyzer.js';
1
+ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js';
2
2
  export { buildReport, formatMarkdown } from './reporter.js';
3
3
  export { computeDiff } from './diff.js';
4
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js';
4
+ export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js';
5
5
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { analyzeProject, analyzeFile } from './analyzer.js';
1
+ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js';
2
2
  export { buildReport, formatMarkdown } from './reporter.js';
3
3
  export { computeDiff } from './diff.js';
4
4
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eduardbar/drift",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Detect silent technical debt left by AI-generated code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,88 @@
1
+ # eslint-plugin-drift
2
+
3
+ ESLint plugin that exposes [drift](https://github.com/eduardbar/drift)'s 26 technical debt rules as standard ESLint rules, compatible with ESLint 9 flat config.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install --save-dev eslint-plugin-drift eslint
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Recommended config (all 26 rules)
14
+
15
+ In your `eslint.config.js` (or `eslint.config.mjs`):
16
+
17
+ ```js
18
+ import drift from 'eslint-plugin-drift'
19
+
20
+ export default [
21
+ ...drift.configs.recommended,
22
+ ]
23
+ ```
24
+
25
+ ### Manual config (pick individual rules)
26
+
27
+ ```js
28
+ import drift from 'eslint-plugin-drift'
29
+
30
+ export default [
31
+ {
32
+ plugins: { drift },
33
+ rules: {
34
+ 'drift/large-file': 'error',
35
+ 'drift/large-function': 'error',
36
+ 'drift/any-abuse': 'warn',
37
+ 'drift/magic-number': 'warn',
38
+ },
39
+ },
40
+ ]
41
+ ```
42
+
43
+ ## Rules
44
+
45
+ | Rule | Severity | Description |
46
+ |------|----------|-------------|
47
+ | `drift/large-file` | error | Files over 300 lines |
48
+ | `drift/large-function` | error | Functions over 50 lines |
49
+ | `drift/duplicate-function-name` | error | Near-identical function names |
50
+ | `drift/high-complexity` | error | Cyclomatic complexity > 10 |
51
+ | `drift/circular-dependency` | error | Circular import chains |
52
+ | `drift/layer-violation` | error | Prohibited architectural layer imports |
53
+ | `drift/debug-leftover` | warn | console.log, TODO, FIXME in production |
54
+ | `drift/dead-code` | warn | Unused imports |
55
+ | `drift/any-abuse` | warn | Explicit `any` type |
56
+ | `drift/catch-swallow` | warn | Empty catch blocks |
57
+ | `drift/comment-contradiction` | warn | Comments that restate the code |
58
+ | `drift/deep-nesting` | warn | Nesting depth > 3 |
59
+ | `drift/too-many-params` | warn | Functions with more than 4 parameters |
60
+ | `drift/high-coupling` | warn | Files importing from more than 10 modules |
61
+ | `drift/promise-style-mix` | warn | Mixed async/await and .then() |
62
+ | `drift/unused-export` | warn | Exports never imported anywhere |
63
+ | `drift/dead-file` | warn | Files never imported |
64
+ | `drift/unused-dependency` | warn | Packages in package.json never used |
65
+ | `drift/cross-boundary-import` | warn | Cross-module boundary imports |
66
+ | `drift/hardcoded-config` | warn | Hardcoded URLs, IPs, connection strings |
67
+ | `drift/inconsistent-error-handling` | warn | Mixed try/catch and .catch() |
68
+ | `drift/unnecessary-abstraction` | warn | Single-use interfaces and abstract classes |
69
+ | `drift/naming-inconsistency` | warn | Mixed camelCase and snake_case |
70
+ | `drift/no-return-type` | warn | Missing explicit return types |
71
+ | `drift/magic-number` | warn | Numeric literals in logic |
72
+ | `drift/over-commented` | warn | Comments exceed 40% of function lines |
73
+
74
+ ## How it works
75
+
76
+ The plugin runs drift's AST analysis engine on each `.ts`/`.tsx` file when ESLint processes it. Results are cached per file so each file is analyzed once regardless of how many rules are enabled.
77
+
78
+ > **Note:** This plugin analyzes files individually. Cross-file rules (`unused-export`, `dead-file`, `unused-dependency`) work best when running `drift scan` on the full project for comprehensive cross-file analysis.
79
+
80
+ ## Requirements
81
+
82
+ - ESLint 9+
83
+ - Node.js 18+
84
+ - TypeScript or JavaScript project
85
+
86
+ ## License
87
+
88
+ MIT — [Eduard Barrera](https://github.com/eduardbar)