@eduardbar/drift 0.9.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +78 -0
- package/AGENTS.md +83 -23
- package/README.md +69 -2
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +8 -38
- package/dist/analyzer.js +181 -1526
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +125 -4
- package/dist/config.js +1 -1
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +17 -0
- package/dist/fix.js +132 -0
- package/dist/git/blame.d.ts +22 -0
- package/dist/git/blame.js +227 -0
- package/dist/git/helpers.d.ts +36 -0
- package/dist/git/helpers.js +152 -0
- package/dist/git/trend.d.ts +21 -0
- package/dist/git/trend.js +81 -0
- package/dist/git.d.ts +0 -13
- package/dist/git.js +27 -21
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.js +103 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +654 -293
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.d.ts +11 -0
- package/dist/rules/phase0-basic.js +183 -0
- package/dist/rules/phase1-complexity.d.ts +7 -0
- package/dist/rules/phase1-complexity.js +8 -0
- package/dist/rules/phase2-crossfile.d.ts +23 -0
- package/dist/rules/phase2-crossfile.js +135 -0
- package/dist/rules/phase3-arch.d.ts +23 -0
- package/dist/rules/phase3-arch.js +151 -0
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase5-ai.d.ts +8 -0
- package/dist/rules/phase5-ai.js +262 -0
- package/dist/rules/phase8-semantic.d.ts +17 -0
- package/dist/rules/phase8-semantic.js +110 -0
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/rules/shared.d.ts +7 -0
- package/dist/rules/shared.js +27 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +69 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +208 -0
- package/package.json +8 -3
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/.vscodeignore +9 -0
- package/packages/vscode-drift/LICENSE +21 -0
- package/packages/vscode-drift/README.md +64 -0
- package/packages/vscode-drift/images/icon.png +0 -0
- package/packages/vscode-drift/images/icon.svg +30 -0
- package/packages/vscode-drift/package-lock.json +485 -0
- package/packages/vscode-drift/package.json +119 -0
- package/packages/vscode-drift/src/analyzer.ts +40 -0
- package/packages/vscode-drift/src/diagnostics.ts +55 -0
- package/packages/vscode-drift/src/extension.ts +135 -0
- package/packages/vscode-drift/src/statusbar.ts +55 -0
- package/packages/vscode-drift/src/treeview.ts +110 -0
- package/packages/vscode-drift/tsconfig.json +18 -0
- package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
- package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
- package/src/analyzer.ts +248 -1765
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +143 -4
- package/src/config.ts +1 -1
- package/src/diff.ts +36 -30
- package/src/fix.ts +178 -0
- package/src/git/blame.ts +279 -0
- package/src/git/helpers.ts +198 -0
- package/src/git/trend.ts +117 -0
- package/src/git.ts +33 -24
- package/src/index.ts +16 -1
- package/src/map.ts +117 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +666 -296
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +194 -0
- package/src/rules/phase1-complexity.ts +8 -0
- package/src/rules/phase2-crossfile.ts +177 -0
- package/src/rules/phase3-arch.ts +183 -0
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase5-ai.ts +292 -0
- package/src/rules/phase8-semantic.ts +136 -0
- package/src/rules/promise.ts +29 -0
- package/src/rules/shared.ts +39 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +75 -1
- package/src/utils.ts +3 -1
- package/tests/helpers.ts +45 -0
- package/tests/new-features.test.ts +153 -0
- package/tests/rules.test.ts +1269 -0
- package/vitest.config.ts +15 -0
|
@@ -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,136 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
3
|
+
import * as crypto from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
SourceFile,
|
|
6
|
+
SyntaxKind,
|
|
7
|
+
Node,
|
|
8
|
+
FunctionDeclaration,
|
|
9
|
+
ArrowFunction,
|
|
10
|
+
FunctionExpression,
|
|
11
|
+
MethodDeclaration,
|
|
12
|
+
} from 'ts-morph'
|
|
13
|
+
import type { DriftIssue } from '../types.js'
|
|
14
|
+
|
|
15
|
+
export type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
16
|
+
|
|
17
|
+
/** Normalize a function body to a canonical string (Type-2 clone detection).
|
|
18
|
+
* Variable names, parameter names, and numeric/string literals are replaced
|
|
19
|
+
* with canonical tokens so that two functions with identical logic but
|
|
20
|
+
* different identifiers produce the same fingerprint.
|
|
21
|
+
*/
|
|
22
|
+
function buildSubstitutionMap(fn: FunctionLikeNode): Map<string, string> {
|
|
23
|
+
const subst = new Map<string, string>()
|
|
24
|
+
|
|
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
|
+
let varIdx = 0
|
|
31
|
+
fn.forEachDescendant(node => {
|
|
32
|
+
if (node.getKind() === SyntaxKind.VariableDeclaration) {
|
|
33
|
+
const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
|
|
34
|
+
if (nameNode.getKind() === SyntaxKind.Identifier) {
|
|
35
|
+
const name = nameNode.getText()
|
|
36
|
+
if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return subst
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function serializeNode(node: Node, subst: Map<string, string>): string {
|
|
45
|
+
const kind = node.getKindName()
|
|
46
|
+
|
|
47
|
+
switch (node.getKind()) {
|
|
48
|
+
case SyntaxKind.Identifier: {
|
|
49
|
+
const text = node.getText()
|
|
50
|
+
return subst.get(text) ?? text
|
|
51
|
+
}
|
|
52
|
+
case SyntaxKind.NumericLiteral:
|
|
53
|
+
return 'NL'
|
|
54
|
+
case SyntaxKind.StringLiteral:
|
|
55
|
+
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
56
|
+
return 'SL'
|
|
57
|
+
case SyntaxKind.TrueKeyword:
|
|
58
|
+
return 'TRUE'
|
|
59
|
+
case SyntaxKind.FalseKeyword:
|
|
60
|
+
return 'FALSE'
|
|
61
|
+
case SyntaxKind.NullKeyword:
|
|
62
|
+
return 'NULL'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const children = node.getChildren()
|
|
66
|
+
if (children.length === 0) return kind
|
|
67
|
+
|
|
68
|
+
const childStr = children.map(c => serializeNode(c, subst)).join('|')
|
|
69
|
+
return `${kind}(${childStr})`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeFunctionBody(fn: FunctionLikeNode): string {
|
|
73
|
+
const subst = buildSubstitutionMap(fn)
|
|
74
|
+
|
|
75
|
+
const body = fn.getBody()
|
|
76
|
+
if (!body) return ''
|
|
77
|
+
return serializeNode(body, subst)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Return a SHA-256 fingerprint for a function body (normalized). */
|
|
81
|
+
export function fingerprintFunction(fn: FunctionLikeNode): string {
|
|
82
|
+
const normalized = normalizeFunctionBody(fn)
|
|
83
|
+
return crypto.createHash('sha256').update(normalized).digest('hex')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Return all function-like nodes from a SourceFile that are worth comparing:
|
|
87
|
+
* - At least MIN_LINES lines in their body
|
|
88
|
+
* - Not test helpers (describe/it/test/beforeEach/afterEach)
|
|
89
|
+
*/
|
|
90
|
+
const MIN_LINES = 8
|
|
91
|
+
|
|
92
|
+
export function collectFunctions(sf: SourceFile): Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> {
|
|
93
|
+
const results: Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> = []
|
|
94
|
+
|
|
95
|
+
const kinds = [
|
|
96
|
+
SyntaxKind.FunctionDeclaration,
|
|
97
|
+
SyntaxKind.FunctionExpression,
|
|
98
|
+
SyntaxKind.ArrowFunction,
|
|
99
|
+
SyntaxKind.MethodDeclaration,
|
|
100
|
+
] as const
|
|
101
|
+
|
|
102
|
+
for (const kind of kinds) {
|
|
103
|
+
for (const node of sf.getDescendantsOfKind(kind)) {
|
|
104
|
+
const body = (node as FunctionLikeNode).getBody()
|
|
105
|
+
if (!body) continue
|
|
106
|
+
|
|
107
|
+
const start = body.getStartLineNumber()
|
|
108
|
+
const end = body.getEndLineNumber()
|
|
109
|
+
if (end - start + 1 < MIN_LINES) continue
|
|
110
|
+
|
|
111
|
+
// Skip test-framework helpers
|
|
112
|
+
const name = node.getKind() === SyntaxKind.FunctionDeclaration
|
|
113
|
+
? (node as FunctionDeclaration).getName() ?? '<anonymous>'
|
|
114
|
+
: node.getKind() === SyntaxKind.MethodDeclaration
|
|
115
|
+
? (node as MethodDeclaration).getName()
|
|
116
|
+
: '<anonymous>'
|
|
117
|
+
|
|
118
|
+
if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name)) continue
|
|
119
|
+
|
|
120
|
+
const pos = node.getStart()
|
|
121
|
+
const lineInfo = sf.getLineAndColumnAtPos(pos)
|
|
122
|
+
|
|
123
|
+
results.push({ fn: node as FunctionLikeNode, name, line: lineInfo.line, col: lineInfo.column })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function calculateScore(issues: DriftIssue[], ruleWeights: Record<string, { severity: DriftIssue['severity']; weight: number }>): number {
|
|
131
|
+
let raw = 0
|
|
132
|
+
for (const issue of issues) {
|
|
133
|
+
raw += ruleWeights[issue.rule]?.weight ?? 5
|
|
134
|
+
}
|
|
135
|
+
return Math.min(100, raw)
|
|
136
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export function detectPromiseStyleMix(file: SourceFile): DriftIssue[] {
|
|
5
|
+
const text = file.getFullText()
|
|
6
|
+
|
|
7
|
+
const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
|
|
8
|
+
const name = node.getName()
|
|
9
|
+
return name === 'then' || name === 'catch'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const hasAsync =
|
|
13
|
+
file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
|
|
14
|
+
/\bawait\b/.test(text)
|
|
15
|
+
|
|
16
|
+
if (hasThen && hasAsync) {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
rule: 'promise-style-mix',
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
|
|
22
|
+
line: 1,
|
|
23
|
+
column: 1,
|
|
24
|
+
snippet: `// mixed promise styles detected`,
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import kleur from 'kleur'
|
|
4
|
+
import type { DriftReport } from './types.js'
|
|
5
|
+
import { scoreToGradeText } from './utils.js'
|
|
6
|
+
|
|
7
|
+
export interface SnapshotEntry {
|
|
8
|
+
timestamp: string
|
|
9
|
+
label: string
|
|
10
|
+
score: number
|
|
11
|
+
grade: string
|
|
12
|
+
totalIssues: number
|
|
13
|
+
files: number
|
|
14
|
+
byRule: Record<string, number>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SnapshotHistory {
|
|
18
|
+
project: string
|
|
19
|
+
snapshots: SnapshotEntry[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HISTORY_FILE = 'drift-history.json'
|
|
23
|
+
|
|
24
|
+
const HEADER_PAD = {
|
|
25
|
+
INDEX: 4,
|
|
26
|
+
DATE: 26,
|
|
27
|
+
LABEL: 20,
|
|
28
|
+
SCORE: 8,
|
|
29
|
+
GRADE: 12,
|
|
30
|
+
ISSUES: 8,
|
|
31
|
+
DELTA: 6,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GRADE_THRESHOLDS = {
|
|
35
|
+
LOW: 20,
|
|
36
|
+
MODERATE: 45,
|
|
37
|
+
HIGH: 70,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadHistory(targetPath: string): SnapshotHistory {
|
|
41
|
+
const filePath = path.join(targetPath, HISTORY_FILE)
|
|
42
|
+
if (fs.existsSync(filePath)) {
|
|
43
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as SnapshotHistory
|
|
44
|
+
}
|
|
45
|
+
return { project: targetPath, snapshots: [] }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveSnapshot(
|
|
49
|
+
targetPath: string,
|
|
50
|
+
report: DriftReport,
|
|
51
|
+
label?: string,
|
|
52
|
+
): SnapshotEntry {
|
|
53
|
+
const history = loadHistory(targetPath)
|
|
54
|
+
|
|
55
|
+
const entry: SnapshotEntry = {
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
label: label ?? '',
|
|
58
|
+
score: report.totalScore,
|
|
59
|
+
grade: scoreToGradeText(report.totalScore).label.toUpperCase(),
|
|
60
|
+
totalIssues: report.totalIssues,
|
|
61
|
+
files: report.totalFiles,
|
|
62
|
+
byRule: { ...report.summary.byRule },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
history.snapshots.push(entry)
|
|
66
|
+
|
|
67
|
+
const filePath = path.join(targetPath, HISTORY_FILE)
|
|
68
|
+
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8')
|
|
69
|
+
|
|
70
|
+
return entry
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatDelta(current: SnapshotEntry, prev: SnapshotEntry | null): string {
|
|
74
|
+
if (!prev) return '—'
|
|
75
|
+
const delta = current.score - prev.score
|
|
76
|
+
if (delta > 0) return kleur.red(`+${delta}`)
|
|
77
|
+
if (delta < 0) return kleur.green(String(delta))
|
|
78
|
+
return kleur.gray('0')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function printHistory(history: SnapshotHistory): void {
|
|
82
|
+
const { snapshots } = history
|
|
83
|
+
|
|
84
|
+
if (snapshots.length === 0) {
|
|
85
|
+
process.stdout.write('\n No snapshots recorded yet.\n\n')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.stdout.write('\n')
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
kleur.bold(
|
|
92
|
+
` ${'#'.padEnd(HEADER_PAD.INDEX)} ${'Date'.padEnd(HEADER_PAD.DATE)} ${'Label'.padEnd(HEADER_PAD.LABEL)} ${'Score'.padEnd(HEADER_PAD.SCORE)} ${'Grade'.padEnd(HEADER_PAD.GRADE)} ${'Issues'.padEnd(HEADER_PAD.ISSUES)} ${'Delta'}\n`,
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
process.stdout.write(
|
|
96
|
+
` ${'─'.repeat(HEADER_PAD.INDEX)} ${'─'.repeat(HEADER_PAD.DATE)} ${'─'.repeat(HEADER_PAD.LABEL)} ${'─'.repeat(HEADER_PAD.SCORE)} ${'─'.repeat(HEADER_PAD.GRADE)} ${'─'.repeat(HEADER_PAD.ISSUES)} ${'─'.repeat(HEADER_PAD.DELTA)}\n`,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
100
|
+
const s = snapshots[i]
|
|
101
|
+
const date = new Date(s.timestamp).toLocaleString('en-US', {
|
|
102
|
+
year: 'numeric',
|
|
103
|
+
month: 'short',
|
|
104
|
+
day: '2-digit',
|
|
105
|
+
hour: '2-digit',
|
|
106
|
+
minute: '2-digit',
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const deltaStr = formatDelta(s, i > 0 ? snapshots[i - 1] : null)
|
|
110
|
+
const gradeColored = colorGrade(s.grade, s.score)
|
|
111
|
+
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
` ${String(i + 1).padEnd(HEADER_PAD.INDEX)} ${date.padEnd(HEADER_PAD.DATE)} ${(s.label || '—').padEnd(HEADER_PAD.LABEL)} ${String(s.score).padEnd(HEADER_PAD.SCORE)} ${gradeColored.padEnd(HEADER_PAD.GRADE)} ${String(s.totalIssues).padEnd(HEADER_PAD.ISSUES)} ${deltaStr}\n`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
process.stdout.write('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function printSnapshotDiff(
|
|
121
|
+
history: SnapshotHistory,
|
|
122
|
+
currentScore: number,
|
|
123
|
+
): void {
|
|
124
|
+
const { snapshots } = history
|
|
125
|
+
|
|
126
|
+
if (snapshots.length === 0) {
|
|
127
|
+
process.stdout.write('\n No previous snapshot to compare against.\n\n')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const last = snapshots[snapshots.length - 1]
|
|
132
|
+
const delta = currentScore - last.score
|
|
133
|
+
|
|
134
|
+
const lastDate = new Date(last.timestamp).toLocaleString('en-US', {
|
|
135
|
+
year: 'numeric',
|
|
136
|
+
month: 'short',
|
|
137
|
+
day: '2-digit',
|
|
138
|
+
hour: '2-digit',
|
|
139
|
+
minute: '2-digit',
|
|
140
|
+
})
|
|
141
|
+
const lastLabel = last.label ? ` (${last.label})` : ''
|
|
142
|
+
|
|
143
|
+
process.stdout.write('\n')
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
` Last snapshot: ${kleur.bold(lastDate)}${lastLabel} — score ${kleur.bold(String(last.score))}\n`,
|
|
146
|
+
)
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
` Current score: ${kleur.bold(String(currentScore))}\n`,
|
|
149
|
+
)
|
|
150
|
+
process.stdout.write('\n')
|
|
151
|
+
|
|
152
|
+
if (delta > 0) {
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
` Delta: ${kleur.bold().red(`+${delta}`)} — technical debt increased\n`,
|
|
155
|
+
)
|
|
156
|
+
} else if (delta < 0) {
|
|
157
|
+
process.stdout.write(
|
|
158
|
+
` Delta: ${kleur.bold().green(String(delta))} — technical debt decreased\n`,
|
|
159
|
+
)
|
|
160
|
+
} else {
|
|
161
|
+
process.stdout.write(
|
|
162
|
+
` Delta: ${kleur.gray('0')} — no change since last snapshot\n`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
process.stdout.write('\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function colorGrade(grade: string, score: number): string {
|
|
170
|
+
if (score === 0) return kleur.green(grade)
|
|
171
|
+
if (score < GRADE_THRESHOLDS.LOW) return kleur.green(grade)
|
|
172
|
+
if (score < GRADE_THRESHOLDS.MODERATE) return kleur.yellow(grade)
|
|
173
|
+
if (score < GRADE_THRESHOLDS.HIGH) return kleur.red(grade)
|
|
174
|
+
return kleur.bold().red(grade)
|
|
175
|
+
}
|