@eduardbar/drift 1.0.0 → 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 +3 -1
- package/AGENTS.md +53 -11
- package/README.md +68 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +83 -5
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- 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 +34 -0
- 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.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -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 +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/extension.ts +87 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +96 -6
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- 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 +35 -0
- 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 +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +75 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +153 -0
package/src/printer.ts
CHANGED
|
@@ -113,6 +113,26 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
113
113
|
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
|
|
114
114
|
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
|
|
115
115
|
],
|
|
116
|
+
'controller-no-db': [
|
|
117
|
+
'Move DB access to a service/repository and inject it into the controller',
|
|
118
|
+
'Keep controllers focused on transport and orchestration only',
|
|
119
|
+
],
|
|
120
|
+
'service-no-http': [
|
|
121
|
+
'Move HTTP concerns to adapters/clients and keep services framework-agnostic',
|
|
122
|
+
'Inject interfaces for outbound calls instead of calling fetch/express directly',
|
|
123
|
+
],
|
|
124
|
+
'max-function-lines': [
|
|
125
|
+
'Split the function into smaller units with clear responsibilities',
|
|
126
|
+
'Extract branch-heavy chunks into dedicated helpers',
|
|
127
|
+
],
|
|
128
|
+
'ai-code-smell': [
|
|
129
|
+
'Address the listed AI-smell signals in this file before adding more code',
|
|
130
|
+
'Prioritize consistency: naming, error handling, and abstraction level',
|
|
131
|
+
],
|
|
132
|
+
'plugin-error': [
|
|
133
|
+
'Fix or remove the failing plugin in drift.config.*',
|
|
134
|
+
'Validate plugin contract: export { name, rules[] } and detector functions',
|
|
135
|
+
],
|
|
116
136
|
}
|
|
117
137
|
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
118
138
|
}
|
package/src/report.ts
CHANGED
|
@@ -644,6 +644,8 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
644
644
|
const projColor = scoreColor(report.totalScore)
|
|
645
645
|
const projLabel = scoreLabel(report.totalScore)
|
|
646
646
|
const projGrade = scoreGrade(report.totalScore)
|
|
647
|
+
const quality = report.quality
|
|
648
|
+
const risk = report.maintenanceRisk
|
|
647
649
|
|
|
648
650
|
const filesWithIssues = report.files.filter(f => f.issues.length > 0).length
|
|
649
651
|
|
|
@@ -658,6 +660,17 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
658
660
|
<span class="rule-count">${count}</span>
|
|
659
661
|
</button>`).join('')
|
|
660
662
|
|
|
663
|
+
const hotspotsHtml = risk.hotspots.length === 0
|
|
664
|
+
? '<div class="empty-state" style="padding:0.8rem">No hotspots detected.</div>'
|
|
665
|
+
: risk.hotspots.slice(0, 5).map((hotspot) => `
|
|
666
|
+
<div class="issue-row" style="grid-template-columns: 62px 1fr;grid-template-rows:auto auto;">
|
|
667
|
+
<span class="issue-line">R${hotspot.risk}</span>
|
|
668
|
+
<div class="issue-rule-msg">
|
|
669
|
+
<span class="issue-rule">hotspot</span>
|
|
670
|
+
<span class="issue-msg">${escapeHtml(hotspot.file)} (${hotspot.reasons.join(', ')})</span>
|
|
671
|
+
</div>
|
|
672
|
+
</div>`).join('')
|
|
673
|
+
|
|
661
674
|
// ── File sections ──────────────────────────────────────────────────────
|
|
662
675
|
const fileSections = report.files
|
|
663
676
|
.filter(f => f.issues.length > 0)
|
|
@@ -770,6 +783,21 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
770
783
|
</div>
|
|
771
784
|
</div>
|
|
772
785
|
|
|
786
|
+
<div class="sidebar-block">
|
|
787
|
+
<div class="sidebar-label">Repo Quality</div>
|
|
788
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(100 - quality.overall)}">${quality.overall}/100</div>
|
|
789
|
+
<div style="font-size:0.72rem;color:var(--muted)">Architecture ${quality.dimensions.architecture} · Complexity ${quality.dimensions.complexity}</div>
|
|
790
|
+
<div style="font-size:0.72rem;color:var(--muted)">AI patterns ${quality.dimensions['ai-patterns']} · Testing ${quality.dimensions.testing}</div>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div class="sidebar-block">
|
|
794
|
+
<div class="sidebar-label">Maintenance Risk</div>
|
|
795
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(risk.score)}">${risk.score}/100 (${risk.level.toUpperCase()})</div>
|
|
796
|
+
<div style="font-size:0.72rem;color:var(--muted)">High complexity: ${risk.signals.highComplexityFiles}</div>
|
|
797
|
+
<div style="font-size:0.72rem;color:var(--muted)">No tests: ${risk.signals.filesWithoutNearbyTests}</div>
|
|
798
|
+
<div style="font-size:0.72rem;color:var(--muted)">Frequent changes: ${risk.signals.frequentChangeFiles}</div>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
773
801
|
<!-- Severity filters -->
|
|
774
802
|
<div class="sidebar-block">
|
|
775
803
|
<div class="sidebar-label">Severity</div>
|
|
@@ -819,6 +847,13 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
819
847
|
<div class="main-header">
|
|
820
848
|
<span id="issue-counter" style="color:var(--muted);font-size:0.75rem">Loading…</span>
|
|
821
849
|
</div>
|
|
850
|
+
<section class="file-section" open>
|
|
851
|
+
<summary>
|
|
852
|
+
<span class="file-name">Risk hotspots</span>
|
|
853
|
+
<span class="file-score" style="color:${scoreColor(risk.score)}">${risk.score}/100</span>
|
|
854
|
+
</summary>
|
|
855
|
+
<div class="issues-list">${hotspotsHtml}</div>
|
|
856
|
+
</section>
|
|
822
857
|
${fileSections || noIssues}
|
|
823
858
|
</main>
|
|
824
859
|
|
package/src/reporter.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FileReport, DriftReport, DriftIssue, AIOutput, AIIssue } from './types.js'
|
|
2
2
|
import { scoreToGradeText, severityIcon } from './utils.js'
|
|
3
|
+
import { computeRepoQuality, computeMaintenanceRisk } from './metrics.js'
|
|
3
4
|
|
|
4
5
|
const FIX_SUGGESTIONS: Record<string, string> = {
|
|
5
6
|
'large-file': 'Consider splitting this file into smaller modules with single responsibility',
|
|
@@ -25,6 +26,17 @@ const RULE_EFFORT: Record<string, 'low' | 'medium' | 'high'> = {
|
|
|
25
26
|
|
|
26
27
|
const SEVERITY_ORDER: Record<string, number> = { error: 0, warning: 1, info: 2 }
|
|
27
28
|
const EFFORT_ORDER: Record<string, number> = { low: 0, medium: 1, high: 2 }
|
|
29
|
+
const AI_SIGNAL_RULES = new Set([
|
|
30
|
+
'over-commented',
|
|
31
|
+
'hardcoded-config',
|
|
32
|
+
'inconsistent-error-handling',
|
|
33
|
+
'unnecessary-abstraction',
|
|
34
|
+
'naming-inconsistency',
|
|
35
|
+
'comment-contradiction',
|
|
36
|
+
'promise-style-mix',
|
|
37
|
+
'any-abuse',
|
|
38
|
+
'ai-code-smell',
|
|
39
|
+
])
|
|
28
40
|
|
|
29
41
|
export function buildReport(targetPath: string, files: FileReport[]): DriftReport {
|
|
30
42
|
const allIssues = files.flatMap((f) => f.issues)
|
|
@@ -39,10 +51,12 @@ export function buildReport(targetPath: string, files: FileReport[]): DriftRepor
|
|
|
39
51
|
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
40
52
|
: 0
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
const sortedFiles = files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score)
|
|
55
|
+
|
|
56
|
+
const baseReport: DriftReport = {
|
|
43
57
|
scannedAt: new Date().toISOString(),
|
|
44
58
|
targetPath,
|
|
45
|
-
files:
|
|
59
|
+
files: sortedFiles,
|
|
46
60
|
totalIssues: allIssues.length,
|
|
47
61
|
totalScore,
|
|
48
62
|
totalFiles: files.length,
|
|
@@ -52,7 +66,31 @@ export function buildReport(targetPath: string, files: FileReport[]): DriftRepor
|
|
|
52
66
|
infos: allIssues.filter((i) => i.severity === 'info').length,
|
|
53
67
|
byRule,
|
|
54
68
|
},
|
|
69
|
+
quality: {
|
|
70
|
+
overall: 100,
|
|
71
|
+
dimensions: {
|
|
72
|
+
architecture: 100,
|
|
73
|
+
complexity: 100,
|
|
74
|
+
'ai-patterns': 100,
|
|
75
|
+
testing: 100,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
maintenanceRisk: {
|
|
79
|
+
score: 0,
|
|
80
|
+
level: 'low',
|
|
81
|
+
hotspots: [],
|
|
82
|
+
signals: {
|
|
83
|
+
highComplexityFiles: 0,
|
|
84
|
+
filesWithoutNearbyTests: 0,
|
|
85
|
+
frequentChangeFiles: 0,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
55
88
|
}
|
|
89
|
+
|
|
90
|
+
baseReport.quality = computeRepoQuality(targetPath, files)
|
|
91
|
+
baseReport.maintenanceRisk = computeMaintenanceRisk(baseReport)
|
|
92
|
+
|
|
93
|
+
return baseReport
|
|
56
94
|
}
|
|
57
95
|
|
|
58
96
|
function formatHeader(report: DriftReport, grade: { badge: string }): string[] {
|
|
@@ -163,12 +201,62 @@ function buildRecommendedAction(priorityOrder: AIIssue[]): string {
|
|
|
163
201
|
return 'Start with the highest priority issue and work through them in order.'
|
|
164
202
|
}
|
|
165
203
|
|
|
204
|
+
function fileAILikelihood(fileIssues: DriftIssue[]): { score: number; triggers: string[] } {
|
|
205
|
+
if (fileIssues.length === 0) return { score: 0, triggers: [] }
|
|
206
|
+
const triggerCounts = new Map<string, number>()
|
|
207
|
+
for (const issue of fileIssues) {
|
|
208
|
+
if (!AI_SIGNAL_RULES.has(issue.rule)) continue
|
|
209
|
+
triggerCounts.set(issue.rule, (triggerCounts.get(issue.rule) ?? 0) + 1)
|
|
210
|
+
}
|
|
211
|
+
const triggerTotal = [...triggerCounts.values()].reduce((sum, count) => sum + count, 0)
|
|
212
|
+
const smellBoost = fileIssues.some((issue) => issue.rule === 'ai-code-smell') ? 20 : 0
|
|
213
|
+
const ratioScore = Math.round((triggerTotal / Math.max(fileIssues.length, 1)) * 100)
|
|
214
|
+
const score = Math.max(0, Math.min(100, ratioScore + smellBoost))
|
|
215
|
+
const triggers = [...triggerCounts.entries()]
|
|
216
|
+
.sort((a, b) => b[1] - a[1])
|
|
217
|
+
.slice(0, 4)
|
|
218
|
+
.map(([rule]) => rule)
|
|
219
|
+
return { score, triggers }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function computeAILikelihood(report: DriftReport): {
|
|
223
|
+
overall: number
|
|
224
|
+
files: Array<{ path: string; ai_likelihood: number; triggers: string[] }>
|
|
225
|
+
smellScore: number
|
|
226
|
+
} {
|
|
227
|
+
const suspected = report.files
|
|
228
|
+
.map((file) => {
|
|
229
|
+
const likelihood = fileAILikelihood(file.issues)
|
|
230
|
+
return {
|
|
231
|
+
path: file.path,
|
|
232
|
+
ai_likelihood: likelihood.score,
|
|
233
|
+
triggers: likelihood.triggers,
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.filter((entry) => entry.ai_likelihood >= 35)
|
|
237
|
+
.sort((a, b) => b.ai_likelihood - a.ai_likelihood)
|
|
238
|
+
|
|
239
|
+
const overall = suspected.length === 0
|
|
240
|
+
? 0
|
|
241
|
+
: Math.round(suspected.reduce((sum, entry) => sum + entry.ai_likelihood, 0) / suspected.length)
|
|
242
|
+
|
|
243
|
+
const smellCount = report.files.flatMap((file) => file.issues).filter((issue) => issue.rule === 'ai-code-smell').length
|
|
244
|
+
const smellScore = Math.min(100, smellCount * 15)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
overall,
|
|
248
|
+
files: suspected.slice(0, 10),
|
|
249
|
+
smellScore,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
166
253
|
export function formatAIOutput(report: DriftReport): AIOutput {
|
|
167
254
|
const allIssues = collectAllIssues(report)
|
|
168
255
|
const sortedIssues = sortIssues(allIssues)
|
|
169
256
|
const priorityOrder = sortedIssues.map((item, i) => buildAIIssue(item, i + 1))
|
|
170
257
|
const rulesDetected = [...new Set(allIssues.map((i) => i.issue.rule))]
|
|
171
258
|
const grade = scoreToGradeText(report.totalScore)
|
|
259
|
+
const aiLikelihood = computeAILikelihood(report)
|
|
172
260
|
|
|
173
261
|
return {
|
|
174
262
|
summary: {
|
|
@@ -177,8 +265,13 @@ export function formatAIOutput(report: DriftReport): AIOutput {
|
|
|
177
265
|
total_issues: report.totalIssues,
|
|
178
266
|
files_affected: report.files.length,
|
|
179
267
|
files_clean: report.totalFiles - report.files.length,
|
|
268
|
+
ai_likelihood: aiLikelihood.overall,
|
|
269
|
+
ai_code_smell_score: aiLikelihood.smellScore,
|
|
180
270
|
},
|
|
271
|
+
files_suspected: aiLikelihood.files,
|
|
181
272
|
priority_order: priorityOrder,
|
|
273
|
+
maintenance_risk: report.maintenanceRisk,
|
|
274
|
+
quality: report.quality,
|
|
182
275
|
context_for_ai: {
|
|
183
276
|
project_type: 'typescript',
|
|
184
277
|
scan_path: report.targetPath,
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { analyzeProject } from './analyzer.js'
|
|
3
|
+
import { loadConfig } from './config.js'
|
|
4
|
+
import { buildReport } from './reporter.js'
|
|
5
|
+
import { cleanupTempDir, extractFilesAtRef } from './git.js'
|
|
6
|
+
import { computeDiff } from './diff.js'
|
|
7
|
+
import type { DriftDiff } from './types.js'
|
|
8
|
+
|
|
9
|
+
export interface DriftReview {
|
|
10
|
+
baseRef: string
|
|
11
|
+
scannedAt: string
|
|
12
|
+
totalDelta: number
|
|
13
|
+
newIssues: number
|
|
14
|
+
resolvedIssues: number
|
|
15
|
+
status: 'clean' | 'improved' | 'regressed'
|
|
16
|
+
summary: string
|
|
17
|
+
markdown: string
|
|
18
|
+
diff: DriftDiff
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatReviewMarkdown(review: DriftReview): string {
|
|
22
|
+
const trendIcon = review.status === 'regressed' ? '⚠️' : review.status === 'improved' ? '✅' : 'ℹ️'
|
|
23
|
+
const topFiles = review.diff.files
|
|
24
|
+
.slice(0, 8)
|
|
25
|
+
.map((file) => {
|
|
26
|
+
const sign = file.scoreDelta > 0 ? '+' : ''
|
|
27
|
+
return `- \`${file.path}\`: ${file.scoreBefore} -> ${file.scoreAfter} (${sign}${file.scoreDelta}), +${file.newIssues.length} new / -${file.resolvedIssues.length} resolved`
|
|
28
|
+
})
|
|
29
|
+
.join('\n')
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
'## drift review',
|
|
33
|
+
'',
|
|
34
|
+
`${trendIcon} ${review.summary}`,
|
|
35
|
+
'',
|
|
36
|
+
`- Base ref: \`${review.baseRef}\``,
|
|
37
|
+
`- Score delta: **${review.totalDelta >= 0 ? '+' : ''}${review.totalDelta}**`,
|
|
38
|
+
`- New issues: **${review.newIssues}**`,
|
|
39
|
+
`- Resolved issues: **${review.resolvedIssues}**`,
|
|
40
|
+
'',
|
|
41
|
+
'### File breakdown',
|
|
42
|
+
topFiles || '- No file-level deltas detected',
|
|
43
|
+
].join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getStatus(totalDelta: number, newIssues: number): 'clean' | 'improved' | 'regressed' {
|
|
47
|
+
if (totalDelta > 0 || newIssues > 0) return 'regressed'
|
|
48
|
+
if (totalDelta < 0) return 'improved'
|
|
49
|
+
return 'clean'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function generateReview(projectPath: string, baseRef: string): Promise<DriftReview> {
|
|
53
|
+
const resolvedPath = resolve(projectPath)
|
|
54
|
+
const config = await loadConfig(resolvedPath)
|
|
55
|
+
|
|
56
|
+
const currentFiles = analyzeProject(resolvedPath, config)
|
|
57
|
+
const currentReport = buildReport(resolvedPath, currentFiles)
|
|
58
|
+
|
|
59
|
+
let tempDir: string | undefined
|
|
60
|
+
try {
|
|
61
|
+
tempDir = extractFilesAtRef(resolvedPath, baseRef)
|
|
62
|
+
const baseFiles = analyzeProject(tempDir, config)
|
|
63
|
+
const baseReport = buildReport(tempDir, baseFiles)
|
|
64
|
+
|
|
65
|
+
const remappedBase = {
|
|
66
|
+
...baseReport,
|
|
67
|
+
files: baseReport.files.map((file) => ({
|
|
68
|
+
...file,
|
|
69
|
+
path: file.path.replace(tempDir!, resolvedPath),
|
|
70
|
+
})),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const diff = computeDiff(remappedBase, currentReport, baseRef)
|
|
74
|
+
const status = getStatus(diff.totalDelta, diff.newIssuesCount)
|
|
75
|
+
const summary = status === 'regressed'
|
|
76
|
+
? `Drift regressed: +${diff.totalDelta} score and ${diff.newIssuesCount} new issue(s).`
|
|
77
|
+
: status === 'improved'
|
|
78
|
+
? `Drift improved: ${diff.totalDelta} score delta and ${diff.resolvedIssuesCount} issue(s) resolved.`
|
|
79
|
+
: 'No drift changes detected against base ref.'
|
|
80
|
+
|
|
81
|
+
const review: DriftReview = {
|
|
82
|
+
baseRef,
|
|
83
|
+
scannedAt: new Date().toISOString(),
|
|
84
|
+
totalDelta: diff.totalDelta,
|
|
85
|
+
newIssues: diff.newIssuesCount,
|
|
86
|
+
resolvedIssues: diff.resolvedIssuesCount,
|
|
87
|
+
status,
|
|
88
|
+
summary,
|
|
89
|
+
markdown: '',
|
|
90
|
+
diff,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
review.markdown = formatReviewMarkdown(review)
|
|
94
|
+
return review
|
|
95
|
+
} finally {
|
|
96
|
+
if (tempDir) cleanupTempDir(tempDir)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
import { hasIgnoreComment } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const TRIVIAL_COMMENT_PATTERNS = [
|
|
6
|
+
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
7
|
+
{ comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
|
|
8
|
+
{ comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
|
|
9
|
+
{ comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
|
|
10
|
+
{ comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
|
|
11
|
+
{ comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
|
|
12
|
+
{ comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
|
|
13
|
+
{ comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
|
|
14
|
+
{ comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
|
|
15
|
+
{ comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const SNIPPET_TRUNCATE = 60
|
|
19
|
+
|
|
20
|
+
function checkLineForContradiction(
|
|
21
|
+
commentLine: string,
|
|
22
|
+
nextLine: string,
|
|
23
|
+
lineNumber: number,
|
|
24
|
+
file: SourceFile,
|
|
25
|
+
): DriftIssue | null {
|
|
26
|
+
for (const { comment, code } of TRIVIAL_COMMENT_PATTERNS) {
|
|
27
|
+
if (comment.test(commentLine) && code.test(nextLine)) {
|
|
28
|
+
if (hasIgnoreComment(file, lineNumber)) return null
|
|
29
|
+
return {
|
|
30
|
+
rule: 'comment-contradiction',
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
|
|
33
|
+
line: lineNumber,
|
|
34
|
+
column: 1,
|
|
35
|
+
snippet: `${commentLine.slice(0, SNIPPET_TRUNCATE)}\n${nextLine.trim().slice(0, SNIPPET_TRUNCATE)}`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
|
|
43
|
+
const issues: DriftIssue[] = []
|
|
44
|
+
const lines = file.getFullText().split('\n')
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
47
|
+
const commentLine = lines[i].trim()
|
|
48
|
+
const nextLine = lines[i + 1]
|
|
49
|
+
const issue = checkLineForContradiction(commentLine, nextLine, i + 1, file)
|
|
50
|
+
if (issue) {
|
|
51
|
+
issues.push(issue)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return issues
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const COMPLEXITY_THRESHOLD = 10
|
|
6
|
+
|
|
7
|
+
const INCREMENT_KINDS = [
|
|
8
|
+
SyntaxKind.IfStatement,
|
|
9
|
+
SyntaxKind.ForStatement,
|
|
10
|
+
SyntaxKind.ForInStatement,
|
|
11
|
+
SyntaxKind.ForOfStatement,
|
|
12
|
+
SyntaxKind.WhileStatement,
|
|
13
|
+
SyntaxKind.DoStatement,
|
|
14
|
+
SyntaxKind.CaseClause,
|
|
15
|
+
SyntaxKind.CatchClause,
|
|
16
|
+
SyntaxKind.ConditionalExpression,
|
|
17
|
+
SyntaxKind.AmpersandAmpersandToken,
|
|
18
|
+
SyntaxKind.BarBarToken,
|
|
19
|
+
SyntaxKind.QuestionQuestionToken,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
function getCyclomaticComplexity(fn: FunctionLike): number {
|
|
23
|
+
let complexity = 1
|
|
24
|
+
|
|
25
|
+
for (const kind of INCREMENT_KINDS) {
|
|
26
|
+
complexity += fn.getDescendantsOfKind(kind).length
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return complexity
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function detectHighComplexity(file: SourceFile): DriftIssue[] {
|
|
33
|
+
const issues: DriftIssue[] = []
|
|
34
|
+
const fns: FunctionLike[] = [
|
|
35
|
+
...file.getFunctions(),
|
|
36
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
37
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
38
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for (const fn of fns) {
|
|
42
|
+
const complexity = getCyclomaticComplexity(fn)
|
|
43
|
+
if (complexity > COMPLEXITY_THRESHOLD) {
|
|
44
|
+
const startLine = fn.getStartLineNumber()
|
|
45
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
46
|
+
issues.push({
|
|
47
|
+
rule: 'high-complexity',
|
|
48
|
+
severity: 'error',
|
|
49
|
+
message: `Cyclomatic complexity is ${complexity} (threshold: ${COMPLEXITY_THRESHOLD}). AI generates correct code, not simple code.`,
|
|
50
|
+
line: startLine,
|
|
51
|
+
column: fn.getStartLinePos(),
|
|
52
|
+
snippet: getSnippet(fn, file),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return issues
|
|
57
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
|
|
4
|
+
const COUPLING_THRESHOLD = 10
|
|
5
|
+
|
|
6
|
+
export function detectHighCoupling(file: SourceFile): DriftIssue[] {
|
|
7
|
+
const imports = file.getImportDeclarations()
|
|
8
|
+
const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()))
|
|
9
|
+
|
|
10
|
+
if (sources.size > COUPLING_THRESHOLD) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: 'high-coupling',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
message: `File imports from ${sources.size} distinct modules (threshold: ${COUPLING_THRESHOLD}). High coupling makes refactoring dangerous.`,
|
|
16
|
+
line: 1,
|
|
17
|
+
column: 1,
|
|
18
|
+
snippet: `// ${sources.size} import sources`,
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
import { hasIgnoreComment, getSnippet } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const ALLOWED_NUMBERS = new Set([0, 1, -1, 2, 100])
|
|
6
|
+
|
|
7
|
+
export function detectMagicNumbers(file: SourceFile): DriftIssue[] {
|
|
8
|
+
const issues: DriftIssue[] = []
|
|
9
|
+
|
|
10
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
|
|
11
|
+
const value = Number(node.getLiteralValue())
|
|
12
|
+
if (ALLOWED_NUMBERS.has(value)) continue
|
|
13
|
+
|
|
14
|
+
const parent = node.getParent()
|
|
15
|
+
if (!parent) continue
|
|
16
|
+
|
|
17
|
+
const parentKind = parent.getKind()
|
|
18
|
+
if (
|
|
19
|
+
parentKind === SyntaxKind.VariableDeclaration ||
|
|
20
|
+
parentKind === SyntaxKind.PropertyAssignment ||
|
|
21
|
+
parentKind === SyntaxKind.EnumMember ||
|
|
22
|
+
parentKind === SyntaxKind.Parameter
|
|
23
|
+
) continue
|
|
24
|
+
|
|
25
|
+
const line = node.getStartLineNumber()
|
|
26
|
+
if (hasIgnoreComment(file, line)) continue
|
|
27
|
+
|
|
28
|
+
issues.push({
|
|
29
|
+
rule: 'magic-number',
|
|
30
|
+
severity: 'info',
|
|
31
|
+
message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
|
|
32
|
+
line,
|
|
33
|
+
column: node.getStartLinePos(),
|
|
34
|
+
snippet: getSnippet(node, file),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
return issues
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { SourceFile, SyntaxKind, Node } from 'ts-morph'
|
|
2
|
+
import type { DriftIssue } from '../types.js'
|
|
3
|
+
import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const NESTING_THRESHOLD = 3
|
|
6
|
+
const PARAMS_THRESHOLD = 4
|
|
7
|
+
|
|
8
|
+
const NESTING_KINDS = new Set([
|
|
9
|
+
SyntaxKind.IfStatement,
|
|
10
|
+
SyntaxKind.ForStatement,
|
|
11
|
+
SyntaxKind.ForInStatement,
|
|
12
|
+
SyntaxKind.ForOfStatement,
|
|
13
|
+
SyntaxKind.WhileStatement,
|
|
14
|
+
SyntaxKind.DoStatement,
|
|
15
|
+
SyntaxKind.TryStatement,
|
|
16
|
+
SyntaxKind.SwitchStatement,
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
function getMaxNestingDepth(fn: FunctionLike): number {
|
|
20
|
+
let maxDepth = 0
|
|
21
|
+
|
|
22
|
+
function walk(node: Node, depth: number): void {
|
|
23
|
+
if (NESTING_KINDS.has(node.getKind())) {
|
|
24
|
+
depth++
|
|
25
|
+
if (depth > maxDepth) maxDepth = depth
|
|
26
|
+
}
|
|
27
|
+
for (const child of node.getChildren()) {
|
|
28
|
+
walk(child, depth)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
walk(fn, 0)
|
|
33
|
+
return maxDepth
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detectDeepNesting(file: SourceFile): DriftIssue[] {
|
|
37
|
+
const issues: DriftIssue[] = []
|
|
38
|
+
const fns: FunctionLike[] = [
|
|
39
|
+
...file.getFunctions(),
|
|
40
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
41
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
42
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (const fn of fns) {
|
|
46
|
+
const depth = getMaxNestingDepth(fn)
|
|
47
|
+
if (depth > NESTING_THRESHOLD) {
|
|
48
|
+
const startLine = fn.getStartLineNumber()
|
|
49
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
50
|
+
issues.push({
|
|
51
|
+
rule: 'deep-nesting',
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
message: `Maximum nesting depth is ${depth} (threshold: ${NESTING_THRESHOLD}). Deep nesting is the #1 readability killer.`,
|
|
54
|
+
line: startLine,
|
|
55
|
+
column: fn.getStartLinePos(),
|
|
56
|
+
snippet: getSnippet(fn, file),
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return issues
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function detectTooManyParams(file: SourceFile): DriftIssue[] {
|
|
64
|
+
const issues: DriftIssue[] = []
|
|
65
|
+
const fns: FunctionLike[] = [
|
|
66
|
+
...file.getFunctions(),
|
|
67
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
68
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
69
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for (const fn of fns) {
|
|
73
|
+
const paramCount = fn.getParameters().length
|
|
74
|
+
if (paramCount > PARAMS_THRESHOLD) {
|
|
75
|
+
const startLine = fn.getStartLineNumber()
|
|
76
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
77
|
+
issues.push({
|
|
78
|
+
rule: 'too-many-params',
|
|
79
|
+
severity: 'warning',
|
|
80
|
+
message: `Function has ${paramCount} parameters (threshold: ${PARAMS_THRESHOLD}). AI avoids refactoring into options objects.`,
|
|
81
|
+
line: startLine,
|
|
82
|
+
column: fn.getStartLinePos(),
|
|
83
|
+
snippet: getSnippet(fn, file),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return issues
|
|
88
|
+
}
|
|
@@ -2,14 +2,19 @@ import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
3
|
import { hasIgnoreComment, getSnippet, getFunctionLikeLines, type FunctionLike } from './shared.js'
|
|
4
4
|
|
|
5
|
+
const LARGE_FILE_THRESHOLD = 300
|
|
6
|
+
const LARGE_FUNCTION_THRESHOLD = 50
|
|
7
|
+
const SNIPPET_TRUNCATE_SHORT = 60
|
|
8
|
+
const SNIPPET_TRUNCATE_LONG = 120
|
|
9
|
+
|
|
5
10
|
export function detectLargeFile(file: SourceFile): DriftIssue[] {
|
|
6
11
|
const lineCount = file.getEndLineNumber()
|
|
7
|
-
if (lineCount >
|
|
12
|
+
if (lineCount > LARGE_FILE_THRESHOLD) {
|
|
8
13
|
return [
|
|
9
14
|
{
|
|
10
15
|
rule: 'large-file',
|
|
11
16
|
severity: 'error',
|
|
12
|
-
message: `File has ${lineCount} lines (threshold:
|
|
17
|
+
message: `File has ${lineCount} lines (threshold: ${LARGE_FILE_THRESHOLD}). Large files are the #1 sign of AI-generated structural drift.`,
|
|
13
18
|
line: 1,
|
|
14
19
|
column: 1,
|
|
15
20
|
snippet: `// ${lineCount} lines total`,
|
|
@@ -31,12 +36,12 @@ export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
|
|
|
31
36
|
for (const fn of fns) {
|
|
32
37
|
const lines = getFunctionLikeLines(fn)
|
|
33
38
|
const startLine = fn.getStartLineNumber()
|
|
34
|
-
if (lines >
|
|
39
|
+
if (lines > LARGE_FUNCTION_THRESHOLD) {
|
|
35
40
|
if (hasIgnoreComment(file, startLine)) continue
|
|
36
41
|
issues.push({
|
|
37
42
|
rule: 'large-function',
|
|
38
43
|
severity: 'error',
|
|
39
|
-
message: `Function spans ${lines} lines (threshold:
|
|
44
|
+
message: `Function spans ${lines} lines (threshold: ${LARGE_FUNCTION_THRESHOLD}). AI tends to dump logic into single functions.`,
|
|
40
45
|
line: startLine,
|
|
41
46
|
column: fn.getStartLinePos(),
|
|
42
47
|
snippet: getSnippet(fn, file),
|
|
@@ -72,10 +77,10 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
|
|
|
72
77
|
issues.push({
|
|
73
78
|
rule: 'debug-leftover',
|
|
74
79
|
severity: 'warning',
|
|
75
|
-
message: `Unresolved marker found: ${lineContent.trim().slice(0,
|
|
80
|
+
message: `Unresolved marker found: ${lineContent.trim().slice(0, SNIPPET_TRUNCATE_SHORT)}`,
|
|
76
81
|
line: i + 1,
|
|
77
82
|
column: 1,
|
|
78
|
-
snippet: lineContent.trim().slice(0,
|
|
83
|
+
snippet: lineContent.trim().slice(0, SNIPPET_TRUNCATE_LONG),
|
|
79
84
|
})
|
|
80
85
|
}
|
|
81
86
|
})
|
|
@@ -156,11 +161,13 @@ export function detectCatchSwallow(file: SourceFile): DriftIssue[] {
|
|
|
156
161
|
const block = catchClause.getBlock()
|
|
157
162
|
const stmts = block.getStatements()
|
|
158
163
|
if (stmts.length === 0) {
|
|
164
|
+
const line = catchClause.getStartLineNumber()
|
|
165
|
+
if (hasIgnoreComment(file, line)) continue
|
|
159
166
|
issues.push({
|
|
160
167
|
rule: 'catch-swallow',
|
|
161
168
|
severity: 'warning',
|
|
162
169
|
message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
|
|
163
|
-
line
|
|
170
|
+
line,
|
|
164
171
|
column: catchClause.getStartLinePos(),
|
|
165
172
|
snippet: getSnippet(catchClause, file),
|
|
166
173
|
})
|