@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 +22 -0
- package/assets/og.svg +105 -105
- package/dist/analyzer.d.ts +5 -1
- package/dist/analyzer.js +143 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/README.md +88 -0
- package/packages/eslint-plugin-drift/package-lock.json +1250 -0
- package/packages/eslint-plugin-drift/package.json +47 -0
- package/packages/eslint-plugin-drift/src/index.ts +170 -0
- package/packages/eslint-plugin-drift/tsconfig.json +17 -0
- package/src/analyzer.ts +164 -1
- package/src/index.ts +2 -2
- package/src/printer.ts +60 -60
- package/src/types.ts +15 -15
- package/src/utils.ts +35 -35
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-drift",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ESLint plugin that exposes drift's technical debt rules for ESLint 9 flat config",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"eslint",
|
|
17
|
+
"eslintplugin",
|
|
18
|
+
"eslint-plugin",
|
|
19
|
+
"drift",
|
|
20
|
+
"technical-debt",
|
|
21
|
+
"ai",
|
|
22
|
+
"typescript"
|
|
23
|
+
],
|
|
24
|
+
"author": "eduardbar",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://github.com/eduardbar/drift#readme",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/eduardbar/drift.git"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"eslint": ">=9.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@eduardbar/drift": "^0.7.0",
|
|
36
|
+
"ts-morph": "^27.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.0.0",
|
|
40
|
+
"eslint": "^9.0.0",
|
|
41
|
+
"typescript": "^5.9.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { analyzeFile } from '@eduardbar/drift'
|
|
2
|
+
import type { FileReport } from '@eduardbar/drift'
|
|
3
|
+
import { Project } from 'ts-morph'
|
|
4
|
+
import type { Rule } from 'eslint'
|
|
5
|
+
|
|
6
|
+
// Tipos auxiliares
|
|
7
|
+
type RuleType = 'problem' | 'suggestion' | 'layout'
|
|
8
|
+
|
|
9
|
+
// Mapeo rule → descripción legible
|
|
10
|
+
const RULE_DOCS: Record<string, { type: RuleType; description: string }> = {
|
|
11
|
+
'large-file': { type: 'problem', description: 'Files over 300 lines — AI dumps everything into one place' },
|
|
12
|
+
'large-function': { type: 'problem', description: 'Functions over 50 lines — AI avoids splitting logic' },
|
|
13
|
+
'duplicate-function-name': { type: 'problem', description: 'Near-identical function names — AI regenerates instead of reusing' },
|
|
14
|
+
'high-complexity': { type: 'problem', description: 'Cyclomatic complexity > 10 — AI generates correct code, not simple code' },
|
|
15
|
+
'circular-dependency': { type: 'problem', description: 'Circular import chains between modules' },
|
|
16
|
+
'layer-violation': { type: 'problem', description: 'Import from a prohibited architectural layer (requires drift.config.ts)' },
|
|
17
|
+
'debug-leftover': { type: 'suggestion', description: 'console.log, TODO, FIXME comments left in production code' },
|
|
18
|
+
'dead-code': { type: 'suggestion', description: 'Unused imports — AI imports more than it uses' },
|
|
19
|
+
'any-abuse': { type: 'suggestion', description: 'Explicit any type — AI defaults to any when it cannot infer' },
|
|
20
|
+
'catch-swallow': { type: 'suggestion', description: 'Empty catch blocks — AI makes code not throw' },
|
|
21
|
+
'comment-contradiction': { type: 'suggestion', description: 'Comments that restate what the code already says' },
|
|
22
|
+
'deep-nesting': { type: 'suggestion', description: 'Nesting depth > 3 — unreadable control flow' },
|
|
23
|
+
'too-many-params': { type: 'suggestion', description: 'Functions with more than 4 parameters' },
|
|
24
|
+
'high-coupling': { type: 'suggestion', description: 'Files importing from more than 10 modules' },
|
|
25
|
+
'promise-style-mix': { type: 'suggestion', description: 'async/await and .then() mixed in the same file' },
|
|
26
|
+
'unused-export': { type: 'suggestion', description: 'Named exports never imported anywhere in the project' },
|
|
27
|
+
'dead-file': { type: 'suggestion', description: 'Files never imported by any other file' },
|
|
28
|
+
'unused-dependency': { type: 'suggestion', description: 'Packages in package.json never imported in source code' },
|
|
29
|
+
'cross-boundary-import': { type: 'suggestion', description: 'Module imports from another module outside allowed boundaries' },
|
|
30
|
+
'hardcoded-config': { type: 'suggestion', description: 'Hardcoded URLs, IPs, or connection strings — AI skips env vars' },
|
|
31
|
+
'inconsistent-error-handling': { type: 'suggestion', description: 'Mixed try/catch and .catch() patterns in the same file' },
|
|
32
|
+
'unnecessary-abstraction': { type: 'suggestion', description: 'Single-method interfaces or abstract classes never reused' },
|
|
33
|
+
'naming-inconsistency': { type: 'suggestion', description: 'Mixed camelCase and snake_case in the same scope' },
|
|
34
|
+
'no-return-type': { type: 'suggestion', description: 'Missing explicit return types on functions' },
|
|
35
|
+
'magic-number': { type: 'suggestion', description: 'Numeric literals used directly in logic' },
|
|
36
|
+
'over-commented': { type: 'suggestion', description: 'Functions where comments exceed 40% of lines' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ts-morph Project singleton — reutilizado para todos los archivos en un lint run
|
|
40
|
+
const project = new Project({
|
|
41
|
+
skipAddingFilesFromTsConfig: true,
|
|
42
|
+
compilerOptions: { allowJs: true },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Cache de FileReport por filename para no llamar analyzeFile 26 veces por archivo
|
|
46
|
+
const cache = new Map<string, FileReport>()
|
|
47
|
+
|
|
48
|
+
function getFileReport(filename: string): FileReport {
|
|
49
|
+
if (cache.has(filename)) return cache.get(filename)!
|
|
50
|
+
|
|
51
|
+
// Obtener o agregar el SourceFile al Project
|
|
52
|
+
let sourceFile = project.getSourceFile(filename)
|
|
53
|
+
if (!sourceFile) {
|
|
54
|
+
sourceFile = project.addSourceFileAtPath(filename)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const report = analyzeFile(sourceFile)
|
|
58
|
+
cache.set(filename, report)
|
|
59
|
+
|
|
60
|
+
// Evitar memory leak en watch mode — mantener máximo 100 entradas
|
|
61
|
+
if (cache.size > 100) {
|
|
62
|
+
const firstKey = cache.keys().next().value
|
|
63
|
+
if (firstKey !== undefined) cache.delete(firstKey)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return report
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Crea una regla ESLint para una regla drift específica
|
|
70
|
+
function createRule(ruleName: string): Rule.RuleModule {
|
|
71
|
+
const doc = RULE_DOCS[ruleName] ?? { type: 'suggestion' as RuleType, description: ruleName }
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
meta: {
|
|
75
|
+
type: doc.type,
|
|
76
|
+
docs: {
|
|
77
|
+
description: doc.description,
|
|
78
|
+
url: `https://github.com/eduardbar/drift#${ruleName}`,
|
|
79
|
+
},
|
|
80
|
+
schema: [],
|
|
81
|
+
messages: {
|
|
82
|
+
issue: '{{ message }}',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
create(context) {
|
|
86
|
+
return {
|
|
87
|
+
'Program:exit'() {
|
|
88
|
+
const filename = context.filename
|
|
89
|
+
if (!filename.endsWith('.ts') && !filename.endsWith('.tsx')) return
|
|
90
|
+
if (filename.includes('node_modules')) return
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const fileReport = getFileReport(filename)
|
|
94
|
+
for (const issue of fileReport.issues) {
|
|
95
|
+
if (issue.rule !== ruleName) continue
|
|
96
|
+
const col = issue.column > 0 ? issue.column - 1 : 0
|
|
97
|
+
context.report({
|
|
98
|
+
loc: {
|
|
99
|
+
start: { line: issue.line, column: col },
|
|
100
|
+
end: { line: issue.line, column: col + 1 },
|
|
101
|
+
},
|
|
102
|
+
messageId: 'issue',
|
|
103
|
+
data: { message: issue.message },
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Archivo no parseable por ts-morph — silenciar
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Objeto con todas las reglas
|
|
116
|
+
const rules: Record<string, Rule.RuleModule> = Object.fromEntries(
|
|
117
|
+
Object.keys(RULE_DOCS).map(name => [name, createRule(name)])
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Plugin object
|
|
121
|
+
const plugin = {
|
|
122
|
+
meta: {
|
|
123
|
+
name: 'eslint-plugin-drift',
|
|
124
|
+
version: '0.1.0',
|
|
125
|
+
},
|
|
126
|
+
rules,
|
|
127
|
+
configs: {} as Record<string, unknown>,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Config recommended — todas las reglas en su severidad canónica de drift
|
|
131
|
+
Object.assign(plugin.configs, {
|
|
132
|
+
recommended: [
|
|
133
|
+
{
|
|
134
|
+
plugins: { drift: plugin },
|
|
135
|
+
rules: {
|
|
136
|
+
// errors
|
|
137
|
+
'drift/large-file': 'error',
|
|
138
|
+
'drift/large-function': 'error',
|
|
139
|
+
'drift/duplicate-function-name': 'error',
|
|
140
|
+
'drift/high-complexity': 'error',
|
|
141
|
+
'drift/circular-dependency': 'error',
|
|
142
|
+
'drift/layer-violation': 'error',
|
|
143
|
+
// warnings
|
|
144
|
+
'drift/debug-leftover': 'warn',
|
|
145
|
+
'drift/dead-code': 'warn',
|
|
146
|
+
'drift/any-abuse': 'warn',
|
|
147
|
+
'drift/catch-swallow': 'warn',
|
|
148
|
+
'drift/comment-contradiction': 'warn',
|
|
149
|
+
'drift/deep-nesting': 'warn',
|
|
150
|
+
'drift/too-many-params': 'warn',
|
|
151
|
+
'drift/high-coupling': 'warn',
|
|
152
|
+
'drift/promise-style-mix': 'warn',
|
|
153
|
+
'drift/unused-export': 'warn',
|
|
154
|
+
'drift/dead-file': 'warn',
|
|
155
|
+
'drift/unused-dependency': 'warn',
|
|
156
|
+
'drift/cross-boundary-import': 'warn',
|
|
157
|
+
'drift/hardcoded-config': 'warn',
|
|
158
|
+
'drift/inconsistent-error-handling': 'warn',
|
|
159
|
+
'drift/unnecessary-abstraction': 'warn',
|
|
160
|
+
'drift/naming-inconsistency': 'warn',
|
|
161
|
+
// info → ESLint no tiene "info", mapeamos a "warn"
|
|
162
|
+
'drift/no-return-type': 'warn',
|
|
163
|
+
'drift/magic-number': 'warn',
|
|
164
|
+
'drift/over-commented': 'warn',
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|
package/src/analyzer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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 {
|
|
4
5
|
Project,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
|
|
14
15
|
|
|
15
16
|
// Rules and their drift score weight
|
|
16
|
-
const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
|
|
17
|
+
export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
|
|
17
18
|
'large-file': { severity: 'error', weight: 20 },
|
|
18
19
|
'large-function': { severity: 'error', weight: 15 },
|
|
19
20
|
'debug-leftover': { severity: 'warning', weight: 10 },
|
|
@@ -45,6 +46,8 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
|
|
|
45
46
|
'inconsistent-error-handling': { severity: 'warning', weight: 8 },
|
|
46
47
|
'unnecessary-abstraction': { severity: 'warning', weight: 7 },
|
|
47
48
|
'naming-inconsistency': { severity: 'warning', weight: 6 },
|
|
49
|
+
// Phase 8: semantic duplication
|
|
50
|
+
'semantic-duplication': { severity: 'warning', weight: 12 },
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
@@ -873,6 +876,123 @@ function calculateScore(issues: DriftIssue[]): number {
|
|
|
873
876
|
return Math.min(100, raw)
|
|
874
877
|
}
|
|
875
878
|
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
// Phase 8: Semantic duplication — AST fingerprinting helpers
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
884
|
+
|
|
885
|
+
/** Normalize a function body to a canonical string (Type-2 clone detection).
|
|
886
|
+
* Variable names, parameter names, and numeric/string literals are replaced
|
|
887
|
+
* with canonical tokens so that two functions with identical logic but
|
|
888
|
+
* different identifiers produce the same fingerprint.
|
|
889
|
+
*/
|
|
890
|
+
function normalizeFunctionBody(fn: FunctionLikeNode): string {
|
|
891
|
+
// Build a substitution map: localName → canonical token
|
|
892
|
+
const subst = new Map<string, string>()
|
|
893
|
+
|
|
894
|
+
// Map parameters first
|
|
895
|
+
for (const [i, param] of fn.getParameters().entries()) {
|
|
896
|
+
const name = param.getName()
|
|
897
|
+
if (name && name !== '_') subst.set(name, `P${i}`)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Map locally declared variables (VariableDeclaration)
|
|
901
|
+
let varIdx = 0
|
|
902
|
+
fn.forEachDescendant(node => {
|
|
903
|
+
if (node.getKind() === SyntaxKind.VariableDeclaration) {
|
|
904
|
+
const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
|
|
905
|
+
// Support destructuring — getNameNode() may be a BindingPattern
|
|
906
|
+
if (nameNode.getKind() === SyntaxKind.Identifier) {
|
|
907
|
+
const name = nameNode.getText()
|
|
908
|
+
if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
function serializeNode(node: Node): string {
|
|
914
|
+
const kind = node.getKindName()
|
|
915
|
+
|
|
916
|
+
switch (node.getKind()) {
|
|
917
|
+
case SyntaxKind.Identifier: {
|
|
918
|
+
const text = node.getText()
|
|
919
|
+
return subst.get(text) ?? text // external refs (Math, console) kept as-is
|
|
920
|
+
}
|
|
921
|
+
case SyntaxKind.NumericLiteral:
|
|
922
|
+
return 'NL'
|
|
923
|
+
case SyntaxKind.StringLiteral:
|
|
924
|
+
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
925
|
+
return 'SL'
|
|
926
|
+
case SyntaxKind.TrueKeyword:
|
|
927
|
+
return 'TRUE'
|
|
928
|
+
case SyntaxKind.FalseKeyword:
|
|
929
|
+
return 'FALSE'
|
|
930
|
+
case SyntaxKind.NullKeyword:
|
|
931
|
+
return 'NULL'
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const children = node.getChildren()
|
|
935
|
+
if (children.length === 0) return kind
|
|
936
|
+
|
|
937
|
+
const childStr = children.map(serializeNode).join('|')
|
|
938
|
+
return `${kind}(${childStr})`
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const body = fn.getBody()
|
|
942
|
+
if (!body) return ''
|
|
943
|
+
return serializeNode(body)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Return a SHA-256 fingerprint for a function body (normalized). */
|
|
947
|
+
function fingerprintFunction(fn: FunctionLikeNode): string {
|
|
948
|
+
const normalized = normalizeFunctionBody(fn)
|
|
949
|
+
return crypto.createHash('sha256').update(normalized).digest('hex')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Return all function-like nodes from a SourceFile that are worth comparing:
|
|
953
|
+
* - At least MIN_LINES lines in their body
|
|
954
|
+
* - Not test helpers (describe/it/test/beforeEach/afterEach)
|
|
955
|
+
*/
|
|
956
|
+
const MIN_LINES = 8
|
|
957
|
+
|
|
958
|
+
function collectFunctions(sf: SourceFile): Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> {
|
|
959
|
+
const results: Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> = []
|
|
960
|
+
|
|
961
|
+
const kinds = [
|
|
962
|
+
SyntaxKind.FunctionDeclaration,
|
|
963
|
+
SyntaxKind.FunctionExpression,
|
|
964
|
+
SyntaxKind.ArrowFunction,
|
|
965
|
+
SyntaxKind.MethodDeclaration,
|
|
966
|
+
] as const
|
|
967
|
+
|
|
968
|
+
for (const kind of kinds) {
|
|
969
|
+
for (const node of sf.getDescendantsOfKind(kind)) {
|
|
970
|
+
const body = (node as FunctionLikeNode).getBody()
|
|
971
|
+
if (!body) continue
|
|
972
|
+
|
|
973
|
+
const start = body.getStartLineNumber()
|
|
974
|
+
const end = body.getEndLineNumber()
|
|
975
|
+
if (end - start + 1 < MIN_LINES) continue
|
|
976
|
+
|
|
977
|
+
// Skip test-framework helpers
|
|
978
|
+
const name = node.getKind() === SyntaxKind.FunctionDeclaration
|
|
979
|
+
? (node as FunctionDeclaration).getName() ?? '<anonymous>'
|
|
980
|
+
: node.getKind() === SyntaxKind.MethodDeclaration
|
|
981
|
+
? (node as MethodDeclaration).getName()
|
|
982
|
+
: '<anonymous>'
|
|
983
|
+
|
|
984
|
+
if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name)) continue
|
|
985
|
+
|
|
986
|
+
const pos = node.getStart()
|
|
987
|
+
const lineInfo = sf.getLineAndColumnAtPos(pos)
|
|
988
|
+
|
|
989
|
+
results.push({ fn: node as FunctionLikeNode, name, line: lineInfo.line, col: lineInfo.column })
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return results
|
|
994
|
+
}
|
|
995
|
+
|
|
876
996
|
// ---------------------------------------------------------------------------
|
|
877
997
|
// Public API
|
|
878
998
|
// ---------------------------------------------------------------------------
|
|
@@ -1284,5 +1404,48 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
|
|
|
1284
1404
|
}
|
|
1285
1405
|
}
|
|
1286
1406
|
|
|
1407
|
+
// ── Phase 8: semantic-duplication ────────────────────────────────────────
|
|
1408
|
+
// Build a fingerprint → [{filePath, fnName, line, col}] map across all files
|
|
1409
|
+
const fingerprintMap = new Map<string, Array<{ filePath: string; name: string; line: number; col: number }>>()
|
|
1410
|
+
|
|
1411
|
+
for (const sf of sourceFiles) {
|
|
1412
|
+
const sfPath = sf.getFilePath()
|
|
1413
|
+
for (const { fn, name, line, col } of collectFunctions(sf)) {
|
|
1414
|
+
const fp = fingerprintFunction(fn)
|
|
1415
|
+
if (!fingerprintMap.has(fp)) fingerprintMap.set(fp, [])
|
|
1416
|
+
fingerprintMap.get(fp)!.push({ filePath: sfPath, name, line, col })
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// For each fingerprint with 2+ functions: report each as a duplicate of the others
|
|
1421
|
+
for (const [, entries] of fingerprintMap) {
|
|
1422
|
+
if (entries.length < 2) continue
|
|
1423
|
+
|
|
1424
|
+
for (const entry of entries) {
|
|
1425
|
+
const report = reportByPath.get(entry.filePath)
|
|
1426
|
+
if (!report) continue
|
|
1427
|
+
|
|
1428
|
+
// Build the "duplicated in" list (all other locations)
|
|
1429
|
+
const others = entries
|
|
1430
|
+
.filter(e => e !== entry)
|
|
1431
|
+
.map(e => {
|
|
1432
|
+
const rel = path.relative(targetPath, e.filePath).replace(/\\/g, '/')
|
|
1433
|
+
return `${rel}:${e.line} (${e.name})`
|
|
1434
|
+
})
|
|
1435
|
+
.join(', ')
|
|
1436
|
+
|
|
1437
|
+
const weight = RULE_WEIGHTS['semantic-duplication']?.weight ?? 12
|
|
1438
|
+
report.issues.push({
|
|
1439
|
+
rule: 'semantic-duplication',
|
|
1440
|
+
severity: 'warning',
|
|
1441
|
+
message: `Function '${entry.name}' is semantically identical to: ${others}`,
|
|
1442
|
+
line: entry.line,
|
|
1443
|
+
column: entry.col,
|
|
1444
|
+
snippet: `function ${entry.name} — duplicated in ${entries.length - 1} other location${entries.length > 2 ? 's' : ''}`,
|
|
1445
|
+
})
|
|
1446
|
+
report.score = Math.min(100, report.score + weight)
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1287
1450
|
return reports
|
|
1288
1451
|
}
|
package/src/index.ts
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
|
-
export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js'
|
|
4
|
+
export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js'
|
package/src/printer.ts
CHANGED
|
@@ -118,66 +118,66 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export function printConsole(report: DriftReport, options?: { showFix?: boolean }): void {
|
|
121
|
-
const sep = kleur.gray(' ' + '─'.repeat(50))
|
|
122
|
-
|
|
123
|
-
console.log()
|
|
124
|
-
console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
|
|
125
|
-
console.log(sep)
|
|
126
|
-
console.log()
|
|
127
|
-
|
|
128
|
-
const grade = scoreToGrade(report.totalScore)
|
|
129
|
-
const scoreColor = report.totalScore === 0
|
|
130
|
-
? kleur.green
|
|
131
|
-
: report.totalScore < 45
|
|
132
|
-
? kleur.yellow
|
|
133
|
-
: kleur.red
|
|
134
|
-
|
|
135
|
-
const bar = scoreBar(report.totalScore)
|
|
136
|
-
console.log(
|
|
137
|
-
` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
const cleanFiles = report.totalFiles - report.files.length
|
|
141
|
-
console.log(
|
|
142
|
-
kleur.gray(
|
|
143
|
-
` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
|
|
144
|
-
)
|
|
145
|
-
)
|
|
146
|
-
console.log()
|
|
147
|
-
|
|
148
|
-
// Top issues in header
|
|
149
|
-
const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
150
|
-
if (topRules.length > 0) {
|
|
151
|
-
const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
|
|
152
|
-
console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
|
|
153
|
-
console.log()
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
console.log(sep)
|
|
157
|
-
console.log()
|
|
158
|
-
|
|
159
|
-
if (report.files.length === 0) {
|
|
160
|
-
console.log(kleur.green(' No drift detected. Clean codebase.'))
|
|
161
|
-
console.log()
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const file of report.files) {
|
|
166
|
-
const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
|
|
167
|
-
console.log(
|
|
168
|
-
kleur.bold().white(` ${rel}`) +
|
|
169
|
-
kleur.gray(` (score ${file.score}/100)`)
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
for (const issue of file.issues) {
|
|
173
|
-
const icon = severityIcon(issue.severity)
|
|
174
|
-
const colorFn = (s: string) =>
|
|
175
|
-
issue.severity === 'error'
|
|
176
|
-
? kleur.red(s)
|
|
177
|
-
: issue.severity === 'warning'
|
|
178
|
-
? kleur.yellow(s)
|
|
179
|
-
: kleur.cyan(s)
|
|
180
|
-
|
|
121
|
+
const sep = kleur.gray(' ' + '─'.repeat(50))
|
|
122
|
+
|
|
123
|
+
console.log()
|
|
124
|
+
console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
|
|
125
|
+
console.log(sep)
|
|
126
|
+
console.log()
|
|
127
|
+
|
|
128
|
+
const grade = scoreToGrade(report.totalScore)
|
|
129
|
+
const scoreColor = report.totalScore === 0
|
|
130
|
+
? kleur.green
|
|
131
|
+
: report.totalScore < 45
|
|
132
|
+
? kleur.yellow
|
|
133
|
+
: kleur.red
|
|
134
|
+
|
|
135
|
+
const bar = scoreBar(report.totalScore)
|
|
136
|
+
console.log(
|
|
137
|
+
` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const cleanFiles = report.totalFiles - report.files.length
|
|
141
|
+
console.log(
|
|
142
|
+
kleur.gray(
|
|
143
|
+
` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
console.log()
|
|
147
|
+
|
|
148
|
+
// Top issues in header
|
|
149
|
+
const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
150
|
+
if (topRules.length > 0) {
|
|
151
|
+
const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
|
|
152
|
+
console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
|
|
153
|
+
console.log()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(sep)
|
|
157
|
+
console.log()
|
|
158
|
+
|
|
159
|
+
if (report.files.length === 0) {
|
|
160
|
+
console.log(kleur.green(' No drift detected. Clean codebase.'))
|
|
161
|
+
console.log()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const file of report.files) {
|
|
166
|
+
const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
|
|
167
|
+
console.log(
|
|
168
|
+
kleur.bold().white(` ${rel}`) +
|
|
169
|
+
kleur.gray(` (score ${file.score}/100)`)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for (const issue of file.issues) {
|
|
173
|
+
const icon = severityIcon(issue.severity)
|
|
174
|
+
const colorFn = (s: string) =>
|
|
175
|
+
issue.severity === 'error'
|
|
176
|
+
? kleur.red(s)
|
|
177
|
+
: issue.severity === 'warning'
|
|
178
|
+
? kleur.yellow(s)
|
|
179
|
+
: kleur.cyan(s)
|
|
180
|
+
|
|
181
181
|
console.log(
|
|
182
182
|
` ${colorFn(icon)} ` +
|
|
183
183
|
kleur.gray(`L${issue.line}`) +
|
package/src/types.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
export interface DriftIssue {
|
|
2
|
-
rule: string
|
|
3
|
-
severity: 'error' | 'warning' | 'info'
|
|
4
|
-
message: string
|
|
5
|
-
line: number
|
|
6
|
-
column: number
|
|
7
|
-
snippet: string
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface FileReport {
|
|
11
|
-
path: string
|
|
12
|
-
issues: DriftIssue[]
|
|
13
|
-
score: number // 0–100, higher = more drift
|
|
14
|
-
}
|
|
15
|
-
|
|
1
|
+
export interface DriftIssue {
|
|
2
|
+
rule: string
|
|
3
|
+
severity: 'error' | 'warning' | 'info'
|
|
4
|
+
message: string
|
|
5
|
+
line: number
|
|
6
|
+
column: number
|
|
7
|
+
snippet: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FileReport {
|
|
11
|
+
path: string
|
|
12
|
+
issues: DriftIssue[]
|
|
13
|
+
score: number // 0–100, higher = more drift
|
|
14
|
+
}
|
|
15
|
+
|
|
16
16
|
export interface DriftReport {
|
|
17
17
|
scannedAt: string
|
|
18
18
|
targetPath: string
|
package/src/utils.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
import kleur from 'kleur'
|
|
2
|
-
import type { DriftIssue } from './types.js'
|
|
3
|
-
|
|
4
|
-
export interface Grade {
|
|
5
|
-
badge: string
|
|
6
|
-
label: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function scoreToGrade(score: number): Grade {
|
|
10
|
-
if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
|
|
11
|
-
if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
|
|
12
|
-
if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
|
|
13
|
-
if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
|
|
14
|
-
return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function scoreToGradeText(score: number): Grade {
|
|
18
|
-
if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
|
|
19
|
-
if (score < 20) return { badge: '◎ LOW', label: 'low' }
|
|
20
|
-
if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
|
|
21
|
-
if (score < 70) return { badge: '◉ HIGH', label: 'high' }
|
|
22
|
-
return { badge: '⬡ CRITICAL', label: 'critical' }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function severityIcon(s: DriftIssue['severity']): string {
|
|
26
|
-
if (s === 'error') return '✖'
|
|
27
|
-
if (s === 'warning') return '▲'
|
|
28
|
-
return '◦'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function scoreBar(score: number, width = 20): string {
|
|
32
|
-
const filled = Math.round((score / 100) * width)
|
|
33
|
-
const empty = width - filled
|
|
34
|
-
return '█'.repeat(filled) + '░'.repeat(empty)
|
|
35
|
-
}
|
|
1
|
+
import kleur from 'kleur'
|
|
2
|
+
import type { DriftIssue } from './types.js'
|
|
3
|
+
|
|
4
|
+
export interface Grade {
|
|
5
|
+
badge: string
|
|
6
|
+
label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function scoreToGrade(score: number): Grade {
|
|
10
|
+
if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
|
|
11
|
+
if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
|
|
12
|
+
if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
|
|
13
|
+
if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
|
|
14
|
+
return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scoreToGradeText(score: number): Grade {
|
|
18
|
+
if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
|
|
19
|
+
if (score < 20) return { badge: '◎ LOW', label: 'low' }
|
|
20
|
+
if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
|
|
21
|
+
if (score < 70) return { badge: '◉ HIGH', label: 'high' }
|
|
22
|
+
return { badge: '⬡ CRITICAL', label: 'critical' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function severityIcon(s: DriftIssue['severity']): string {
|
|
26
|
+
if (s === 'error') return '✖'
|
|
27
|
+
if (s === 'warning') return '▲'
|
|
28
|
+
return '◦'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scoreBar(score: number, width = 20): string {
|
|
32
|
+
const filled = Math.round((score / 100) * width)
|
|
33
|
+
const empty = width - filled
|
|
34
|
+
return '█'.repeat(filled) + '░'.repeat(empty)
|
|
35
|
+
}
|