@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
package/dist/badge.js
CHANGED
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
const LEFT_WIDTH = 47;
|
|
2
2
|
const CHAR_WIDTH = 7;
|
|
3
3
|
const PADDING = 16;
|
|
4
|
+
const SVG_SCALE = 10;
|
|
5
|
+
const GRADE_THRESHOLDS = {
|
|
6
|
+
LOW: 20,
|
|
7
|
+
MODERATE: 45,
|
|
8
|
+
HIGH: 70,
|
|
9
|
+
};
|
|
10
|
+
const GRADE_COLORS = {
|
|
11
|
+
LOW: '#4c1',
|
|
12
|
+
MODERATE: '#dfb317',
|
|
13
|
+
HIGH: '#fe7d37',
|
|
14
|
+
CRITICAL: '#e05d44',
|
|
15
|
+
};
|
|
16
|
+
const GRADE_LABELS = {
|
|
17
|
+
LOW: 'LOW',
|
|
18
|
+
MODERATE: 'MODERATE',
|
|
19
|
+
HIGH: 'HIGH',
|
|
20
|
+
CRITICAL: 'CRITICAL',
|
|
21
|
+
};
|
|
4
22
|
function scoreColor(score) {
|
|
5
|
-
if (score <
|
|
6
|
-
return
|
|
7
|
-
if (score <
|
|
8
|
-
return
|
|
9
|
-
if (score <
|
|
10
|
-
return
|
|
11
|
-
return
|
|
23
|
+
if (score < GRADE_THRESHOLDS.LOW)
|
|
24
|
+
return GRADE_COLORS.LOW;
|
|
25
|
+
if (score < GRADE_THRESHOLDS.MODERATE)
|
|
26
|
+
return GRADE_COLORS.MODERATE;
|
|
27
|
+
if (score < GRADE_THRESHOLDS.HIGH)
|
|
28
|
+
return GRADE_COLORS.HIGH;
|
|
29
|
+
return GRADE_COLORS.CRITICAL;
|
|
12
30
|
}
|
|
13
31
|
function scoreLabel(score) {
|
|
14
|
-
if (score <
|
|
15
|
-
return
|
|
16
|
-
if (score <
|
|
17
|
-
return
|
|
18
|
-
if (score <
|
|
19
|
-
return
|
|
20
|
-
return
|
|
32
|
+
if (score < GRADE_THRESHOLDS.LOW)
|
|
33
|
+
return GRADE_LABELS.LOW;
|
|
34
|
+
if (score < GRADE_THRESHOLDS.MODERATE)
|
|
35
|
+
return GRADE_LABELS.MODERATE;
|
|
36
|
+
if (score < GRADE_THRESHOLDS.HIGH)
|
|
37
|
+
return GRADE_LABELS.HIGH;
|
|
38
|
+
return GRADE_LABELS.CRITICAL;
|
|
21
39
|
}
|
|
22
40
|
function rightWidth(text) {
|
|
23
41
|
return text.length * CHAR_WIDTH + PADDING;
|
|
@@ -29,10 +47,10 @@ export function generateBadge(score) {
|
|
|
29
47
|
const totalWidth = LEFT_WIDTH + rWidth;
|
|
30
48
|
const leftCenterX = LEFT_WIDTH / 2;
|
|
31
49
|
const rightCenterX = LEFT_WIDTH + rWidth / 2;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
50
|
+
const leftTextWidth = (LEFT_WIDTH - PADDING) * SVG_SCALE;
|
|
51
|
+
const rightTextWidth = (rWidth - PADDING) * SVG_SCALE;
|
|
52
|
+
const leftCenterXScaled = leftCenterX * SVG_SCALE;
|
|
53
|
+
const rightCenterXScaled = rightCenterX * SVG_SCALE;
|
|
36
54
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
|
|
37
55
|
<linearGradient id="s" x2="0" y2="100%">
|
|
38
56
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
@@ -47,10 +65,10 @@ export function generateBadge(score) {
|
|
|
47
65
|
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
48
66
|
</g>
|
|
49
67
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
|
50
|
-
<text x="${
|
|
51
|
-
<text x="${
|
|
52
|
-
<text x="${
|
|
53
|
-
<text x="${
|
|
68
|
+
<text x="${leftCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
|
|
69
|
+
<text x="${leftCenterXScaled}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
|
|
70
|
+
<text x="${rightCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
|
|
71
|
+
<text x="${rightCenterXScaled}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
|
|
54
72
|
</g>
|
|
55
73
|
</svg>`;
|
|
56
74
|
}
|
package/dist/ci.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { relative } from 'node:path';
|
|
3
|
+
const GRADE_THRESHOLDS = {
|
|
4
|
+
A: 80,
|
|
5
|
+
B: 60,
|
|
6
|
+
C: 40,
|
|
7
|
+
D: 20,
|
|
8
|
+
};
|
|
9
|
+
const TOP_FILES_LIMIT = 10;
|
|
3
10
|
function encodeMessage(msg) {
|
|
4
11
|
return msg
|
|
5
12
|
.replace(/%/g, '%25')
|
|
@@ -16,13 +23,13 @@ function severityToAnnotation(s) {
|
|
|
16
23
|
return 'notice';
|
|
17
24
|
}
|
|
18
25
|
function scoreLabel(score) {
|
|
19
|
-
if (score >=
|
|
26
|
+
if (score >= GRADE_THRESHOLDS.A)
|
|
20
27
|
return 'A';
|
|
21
|
-
if (score >=
|
|
28
|
+
if (score >= GRADE_THRESHOLDS.B)
|
|
22
29
|
return 'B';
|
|
23
|
-
if (score >=
|
|
30
|
+
if (score >= GRADE_THRESHOLDS.C)
|
|
24
31
|
return 'C';
|
|
25
|
-
if (score >=
|
|
32
|
+
if (score >= GRADE_THRESHOLDS.D)
|
|
26
33
|
return 'D';
|
|
27
34
|
return 'F';
|
|
28
35
|
}
|
|
@@ -38,28 +45,35 @@ export function emitCIAnnotations(report) {
|
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
}
|
|
48
|
+
function countIssuesBySeverity(report) {
|
|
49
|
+
let errors = 0;
|
|
50
|
+
let warnings = 0;
|
|
51
|
+
let info = 0;
|
|
52
|
+
for (const file of report.files) {
|
|
53
|
+
countFileIssues(file, { errors: () => errors++, warnings: () => warnings++, info: () => info++ });
|
|
54
|
+
}
|
|
55
|
+
return { errors, warnings, info };
|
|
56
|
+
}
|
|
57
|
+
function countFileIssues(file, counters) {
|
|
58
|
+
for (const issue of file.issues) {
|
|
59
|
+
if (issue.severity === 'error')
|
|
60
|
+
counters.errors();
|
|
61
|
+
else if (issue.severity === 'warning')
|
|
62
|
+
counters.warnings();
|
|
63
|
+
else
|
|
64
|
+
counters.info();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
41
67
|
export function printCISummary(report) {
|
|
42
68
|
const summaryPath = process.env['GITHUB_STEP_SUMMARY'];
|
|
43
69
|
if (!summaryPath)
|
|
44
70
|
return;
|
|
45
71
|
const score = report.totalScore;
|
|
46
72
|
const grade = scoreLabel(score);
|
|
47
|
-
|
|
48
|
-
let warnings = 0;
|
|
49
|
-
let info = 0;
|
|
50
|
-
for (const file of report.files) {
|
|
51
|
-
for (const issue of file.issues) {
|
|
52
|
-
if (issue.severity === 'error')
|
|
53
|
-
errors++;
|
|
54
|
-
else if (issue.severity === 'warning')
|
|
55
|
-
warnings++;
|
|
56
|
-
else
|
|
57
|
-
info++;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
73
|
+
const { errors, warnings, info } = countIssuesBySeverity(report);
|
|
60
74
|
const sorted = [...report.files]
|
|
61
75
|
.sort((a, b) => b.issues.length - a.issues.length)
|
|
62
|
-
.slice(0,
|
|
76
|
+
.slice(0, TOP_FILES_LIMIT);
|
|
63
77
|
const rows = sorted
|
|
64
78
|
.map((f) => {
|
|
65
79
|
const relPath = relative(process.cwd(), f.path).replace(/\\/g, '/');
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// drift-ignore-file
|
|
2
3
|
import { Command } from 'commander';
|
|
3
4
|
import { writeFileSync } from 'node:fs';
|
|
4
5
|
import { resolve } from 'node:path';
|
|
5
6
|
import { createRequire } from 'node:module';
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { version: VERSION } = require('../package.json');
|
|
8
|
-
import { analyzeProject } from './analyzer.js';
|
|
9
|
+
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
|
|
9
10
|
import { buildReport, formatMarkdown, formatAIOutput } from './reporter.js';
|
|
10
11
|
import { printConsole, printDiff } from './printer.js';
|
|
11
12
|
import { loadConfig } from './config.js';
|
|
@@ -14,7 +15,10 @@ import { computeDiff } from './diff.js';
|
|
|
14
15
|
import { generateHtmlReport } from './report.js';
|
|
15
16
|
import { generateBadge } from './badge.js';
|
|
16
17
|
import { emitCIAnnotations, printCISummary } from './ci.js';
|
|
17
|
-
import {
|
|
18
|
+
import { applyFixes } from './fix.js';
|
|
19
|
+
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
|
|
20
|
+
import { generateReview } from './review.js';
|
|
21
|
+
import { generateArchitectureMap } from './map.js';
|
|
18
22
|
const program = new Command();
|
|
19
23
|
program
|
|
20
24
|
.name('drift')
|
|
@@ -102,6 +106,43 @@ program
|
|
|
102
106
|
cleanupTempDir(tempDir);
|
|
103
107
|
}
|
|
104
108
|
});
|
|
109
|
+
program
|
|
110
|
+
.command('review')
|
|
111
|
+
.description('Review drift against a base ref and output PR markdown')
|
|
112
|
+
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
113
|
+
.option('--json', 'Output structured review JSON')
|
|
114
|
+
.option('--comment', 'Output markdown comment body')
|
|
115
|
+
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
116
|
+
.action(async (options) => {
|
|
117
|
+
try {
|
|
118
|
+
const review = await generateReview(resolve('.'), options.base);
|
|
119
|
+
if (options.json) {
|
|
120
|
+
process.stdout.write(JSON.stringify(review, null, 2) + '\n');
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n');
|
|
124
|
+
}
|
|
125
|
+
const failOn = options.failOn ? Number(options.failOn) : undefined;
|
|
126
|
+
if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
132
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
program
|
|
137
|
+
.command('map [path]')
|
|
138
|
+
.description('Generate architecture.svg with simple layer dependencies')
|
|
139
|
+
.option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
|
|
140
|
+
.action(async (targetPath, options) => {
|
|
141
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
142
|
+
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`);
|
|
143
|
+
const out = generateArchitectureMap(resolvedPath, options.output);
|
|
144
|
+
process.stderr.write(` Architecture map saved to ${out}\n\n`);
|
|
145
|
+
});
|
|
105
146
|
program
|
|
106
147
|
.command('report [path]')
|
|
107
148
|
.description('Generate a self-contained HTML report')
|
|
@@ -159,7 +200,7 @@ program
|
|
|
159
200
|
const resolvedPath = resolve('.');
|
|
160
201
|
process.stderr.write(`\nAnalyzing trend in ${resolvedPath}...\n`);
|
|
161
202
|
const config = await loadConfig(resolvedPath);
|
|
162
|
-
const analyzer = new TrendAnalyzer(resolvedPath, config);
|
|
203
|
+
const analyzer = new TrendAnalyzer(resolvedPath, analyzeProject, config);
|
|
163
204
|
const trendData = await analyzer.analyzeTrend({
|
|
164
205
|
period: period,
|
|
165
206
|
since: options.since,
|
|
@@ -176,7 +217,7 @@ program
|
|
|
176
217
|
const resolvedPath = resolve('.');
|
|
177
218
|
process.stderr.write(`\nAnalyzing blame in ${resolvedPath}...\n`);
|
|
178
219
|
const config = await loadConfig(resolvedPath);
|
|
179
|
-
const analyzer = new BlameAnalyzer(resolvedPath, config);
|
|
220
|
+
const analyzer = new BlameAnalyzer(resolvedPath, analyzeProject, analyzeFile, config);
|
|
180
221
|
const blameData = await analyzer.analyzeBlame({
|
|
181
222
|
target: target,
|
|
182
223
|
top: Number(options.top)
|
|
@@ -184,5 +225,85 @@ program
|
|
|
184
225
|
process.stderr.write(`\nBlame analysis complete:\n`);
|
|
185
226
|
process.stdout.write(JSON.stringify(blameData, null, 2) + '\n');
|
|
186
227
|
});
|
|
228
|
+
program
|
|
229
|
+
.command('fix [path]')
|
|
230
|
+
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
231
|
+
.option('--rule <rule>', 'Fix only a specific rule')
|
|
232
|
+
.option('--preview', 'Preview changes without writing files')
|
|
233
|
+
.option('--write', 'Write fixes to disk')
|
|
234
|
+
.option('--dry-run', 'Show what would change without writing files')
|
|
235
|
+
.action(async (targetPath, options) => {
|
|
236
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
237
|
+
const config = await loadConfig(resolvedPath);
|
|
238
|
+
const previewMode = Boolean(options.preview || options.dryRun);
|
|
239
|
+
const writeMode = options.write ?? !previewMode;
|
|
240
|
+
const results = await applyFixes(resolvedPath, config, {
|
|
241
|
+
rule: options.rule,
|
|
242
|
+
dryRun: previewMode,
|
|
243
|
+
preview: previewMode,
|
|
244
|
+
write: writeMode,
|
|
245
|
+
});
|
|
246
|
+
if (results.length === 0) {
|
|
247
|
+
console.log('No fixable issues found.');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const applied = results.filter(r => r.applied);
|
|
251
|
+
if (previewMode) {
|
|
252
|
+
console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
console.log(`\ndrift fix: ${applied.length} fixes applied\n`);
|
|
256
|
+
}
|
|
257
|
+
// Group by file for clean output
|
|
258
|
+
const byFile = new Map();
|
|
259
|
+
for (const r of results) {
|
|
260
|
+
if (!byFile.has(r.file))
|
|
261
|
+
byFile.set(r.file, []);
|
|
262
|
+
byFile.get(r.file).push(r);
|
|
263
|
+
}
|
|
264
|
+
for (const [file, fileResults] of byFile) {
|
|
265
|
+
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '');
|
|
266
|
+
console.log(` ${relPath}`);
|
|
267
|
+
for (const r of fileResults) {
|
|
268
|
+
const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped';
|
|
269
|
+
console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`);
|
|
270
|
+
if (r.before || r.after) {
|
|
271
|
+
console.log(` before: ${r.before ?? '(empty)'}`);
|
|
272
|
+
console.log(` after : ${r.after ?? '(empty)'}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!previewMode && applied.length > 0) {
|
|
277
|
+
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
program
|
|
281
|
+
.command('snapshot [path]')
|
|
282
|
+
.description('Record a score snapshot to drift-history.json')
|
|
283
|
+
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
284
|
+
.option('--history', 'show all recorded snapshots')
|
|
285
|
+
.option('--diff', 'compare current score vs last snapshot')
|
|
286
|
+
.action(async (targetPath, opts) => {
|
|
287
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
288
|
+
if (opts.history) {
|
|
289
|
+
const history = loadHistory(resolvedPath);
|
|
290
|
+
printHistory(history);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
294
|
+
const config = await loadConfig(resolvedPath);
|
|
295
|
+
const files = analyzeProject(resolvedPath, config);
|
|
296
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
297
|
+
const report = buildReport(resolvedPath, files);
|
|
298
|
+
if (opts.diff) {
|
|
299
|
+
const history = loadHistory(resolvedPath);
|
|
300
|
+
printSnapshotDiff(history, report.totalScore);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const entry = saveSnapshot(resolvedPath, report, opts.label);
|
|
304
|
+
const labelStr = entry.label ? ` [${entry.label}]` : '';
|
|
305
|
+
process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
|
|
306
|
+
process.stdout.write(` Saved to drift-history.json\n\n`);
|
|
307
|
+
});
|
|
187
308
|
program.parse();
|
|
188
309
|
//# sourceMappingURL=cli.js.map
|
package/dist/config.js
CHANGED
package/dist/diff.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
1
|
import type { DriftReport, DriftDiff } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Compute the diff between two DriftReports.
|
|
4
|
-
*
|
|
5
|
-
* Issues are matched by (rule + line + column) as a unique key within a file.
|
|
6
|
-
* A "new" issue exists in `current` but not in `base`.
|
|
7
|
-
* A "resolved" issue exists in `base` but not in `current`.
|
|
8
|
-
*/
|
|
9
2
|
export declare function computeDiff(base: DriftReport, current: DriftReport, baseRef: string): DriftDiff;
|
|
10
3
|
//# sourceMappingURL=diff.d.ts.map
|
package/dist/diff.js
CHANGED
|
@@ -5,12 +5,33 @@
|
|
|
5
5
|
* A "new" issue exists in `current` but not in `base`.
|
|
6
6
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
7
7
|
*/
|
|
8
|
+
function computeFileDiff(filePath, baseFile, currentFile) {
|
|
9
|
+
const scoreBefore = baseFile?.score ?? 0;
|
|
10
|
+
const scoreAfter = currentFile?.score ?? 0;
|
|
11
|
+
const scoreDelta = scoreAfter - scoreBefore;
|
|
12
|
+
const baseIssues = baseFile?.issues ?? [];
|
|
13
|
+
const currentIssues = currentFile?.issues ?? [];
|
|
14
|
+
const issueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
|
|
15
|
+
const baseKeys = new Set(baseIssues.map(issueKey));
|
|
16
|
+
const currentKeys = new Set(currentIssues.map(issueKey));
|
|
17
|
+
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)));
|
|
18
|
+
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)));
|
|
19
|
+
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
20
|
+
return {
|
|
21
|
+
path: filePath,
|
|
22
|
+
scoreBefore,
|
|
23
|
+
scoreAfter,
|
|
24
|
+
scoreDelta,
|
|
25
|
+
newIssues,
|
|
26
|
+
resolvedIssues,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
8
31
|
export function computeDiff(base, current, baseRef) {
|
|
9
32
|
const fileDiffs = [];
|
|
10
|
-
// Build a map of base files by path for O(1) lookup
|
|
11
33
|
const baseByPath = new Map(base.files.map(f => [f.path, f]));
|
|
12
34
|
const currentByPath = new Map(current.files.map(f => [f.path, f]));
|
|
13
|
-
// All unique paths across both reports
|
|
14
35
|
const allPaths = new Set([
|
|
15
36
|
...base.files.map(f => f.path),
|
|
16
37
|
...current.files.map(f => f.path),
|
|
@@ -18,30 +39,10 @@ export function computeDiff(base, current, baseRef) {
|
|
|
18
39
|
for (const filePath of allPaths) {
|
|
19
40
|
const baseFile = baseByPath.get(filePath);
|
|
20
41
|
const currentFile = currentByPath.get(filePath);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const baseIssues = baseFile?.issues ?? [];
|
|
25
|
-
const currentIssues = currentFile?.issues ?? [];
|
|
26
|
-
// Issue identity key: rule + line + column
|
|
27
|
-
const issueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
|
|
28
|
-
const baseKeys = new Set(baseIssues.map(issueKey));
|
|
29
|
-
const currentKeys = new Set(currentIssues.map(issueKey));
|
|
30
|
-
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)));
|
|
31
|
-
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)));
|
|
32
|
-
// Only include files that have actual changes
|
|
33
|
-
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
34
|
-
fileDiffs.push({
|
|
35
|
-
path: filePath,
|
|
36
|
-
scoreBefore,
|
|
37
|
-
scoreAfter,
|
|
38
|
-
scoreDelta,
|
|
39
|
-
newIssues,
|
|
40
|
-
resolvedIssues,
|
|
41
|
-
});
|
|
42
|
-
}
|
|
42
|
+
const diff = computeFileDiff(filePath, baseFile, currentFile);
|
|
43
|
+
if (diff)
|
|
44
|
+
fileDiffs.push(diff);
|
|
43
45
|
}
|
|
44
|
-
// Sort: most regressed first, then most improved last
|
|
45
46
|
fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta);
|
|
46
47
|
return {
|
|
47
48
|
baseRef,
|
package/dist/fix.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DriftConfig } from './types.js';
|
|
2
|
+
export interface FixResult {
|
|
3
|
+
file: string;
|
|
4
|
+
rule: string;
|
|
5
|
+
line: number;
|
|
6
|
+
description: string;
|
|
7
|
+
applied: boolean;
|
|
8
|
+
before?: string;
|
|
9
|
+
after?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function applyFixes(targetPath: string, config?: DriftConfig, options?: {
|
|
12
|
+
rule?: string;
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
write?: boolean;
|
|
15
|
+
preview?: boolean;
|
|
16
|
+
}): Promise<FixResult[]>;
|
|
17
|
+
//# sourceMappingURL=fix.d.ts.map
|
package/dist/fix.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { analyzeProject, analyzeFile } from './analyzer.js';
|
|
4
|
+
import { Project } from 'ts-morph';
|
|
5
|
+
const FIXABLE_RULES = new Set(['debug-leftover', 'catch-swallow']);
|
|
6
|
+
function isConsoleDebug(issue) {
|
|
7
|
+
// debug-leftover for console.* has messages like "console.log left in production code."
|
|
8
|
+
// Unresolved markers start with "Unresolved marker"
|
|
9
|
+
return issue.rule === 'debug-leftover' && !issue.message.startsWith('Unresolved marker');
|
|
10
|
+
}
|
|
11
|
+
function isFixable(issue) {
|
|
12
|
+
if (issue.rule === 'debug-leftover')
|
|
13
|
+
return isConsoleDebug(issue);
|
|
14
|
+
return FIXABLE_RULES.has(issue.rule);
|
|
15
|
+
}
|
|
16
|
+
function fixDebugLeftover(lines, line) {
|
|
17
|
+
// line is 1-based, lines is 0-based
|
|
18
|
+
return [...lines.slice(0, line - 1), ...lines.slice(line)];
|
|
19
|
+
}
|
|
20
|
+
function fixCatchSwallow(lines, line) {
|
|
21
|
+
// line is 1-based — points to the catch (...) line
|
|
22
|
+
let openBraceLine = line - 1; // convert to 0-based index
|
|
23
|
+
// Find the opening { of the catch block (same line or next few lines)
|
|
24
|
+
for (let i = openBraceLine; i < Math.min(openBraceLine + 3, lines.length); i++) { // drift-ignore
|
|
25
|
+
if (lines[i].includes('{')) {
|
|
26
|
+
openBraceLine = i;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const indentMatch = lines[openBraceLine].match(/^(\s*)/);
|
|
31
|
+
const indent = indentMatch ? indentMatch[1] + ' ' : ' ';
|
|
32
|
+
return [
|
|
33
|
+
...lines.slice(0, openBraceLine + 1),
|
|
34
|
+
`${indent}// TODO: handle error`, // drift-ignore
|
|
35
|
+
...lines.slice(openBraceLine + 1),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
function applyFixToLines(lines, issue) {
|
|
39
|
+
if (issue.rule === 'debug-leftover' && isConsoleDebug(issue)) {
|
|
40
|
+
return {
|
|
41
|
+
newLines: fixDebugLeftover(lines, issue.line),
|
|
42
|
+
description: `remove ${issue.message.split(' ')[0]} statement`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (issue.rule === 'catch-swallow') {
|
|
46
|
+
return {
|
|
47
|
+
newLines: fixCatchSwallow(lines, issue.line),
|
|
48
|
+
description: 'add TODO comment to empty catch block',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function collectFixableIssues(fileReports, options) {
|
|
54
|
+
const fixableByFile = new Map();
|
|
55
|
+
for (const report of fileReports) {
|
|
56
|
+
const fixableIssues = report.issues.filter(issue => {
|
|
57
|
+
if (!isFixable(issue))
|
|
58
|
+
return false;
|
|
59
|
+
if (options?.rule && issue.rule !== options.rule)
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
63
|
+
if (fixableIssues.length > 0) {
|
|
64
|
+
fixableByFile.set(report.path, fixableIssues);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return fixableByFile;
|
|
68
|
+
}
|
|
69
|
+
function processFile(filePath, issues, dryRun) {
|
|
70
|
+
const content = readFileSync(filePath, 'utf8');
|
|
71
|
+
let lines = content.split('\n');
|
|
72
|
+
const results = [];
|
|
73
|
+
const sortedIssues = [...issues].sort((a, b) => b.line - a.line);
|
|
74
|
+
for (const issue of sortedIssues) {
|
|
75
|
+
const before = lines[issue.line - 1]?.trim() ?? '';
|
|
76
|
+
const fixResult = applyFixToLines(lines, issue);
|
|
77
|
+
if (fixResult) {
|
|
78
|
+
const after = fixResult.newLines[issue.line - 1]?.trim() ?? '';
|
|
79
|
+
results.push({
|
|
80
|
+
file: filePath,
|
|
81
|
+
rule: issue.rule,
|
|
82
|
+
line: issue.line,
|
|
83
|
+
description: fixResult.description,
|
|
84
|
+
applied: true,
|
|
85
|
+
before,
|
|
86
|
+
after,
|
|
87
|
+
});
|
|
88
|
+
lines = fixResult.newLines;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
results.push({
|
|
92
|
+
file: filePath,
|
|
93
|
+
rule: issue.rule,
|
|
94
|
+
line: issue.line,
|
|
95
|
+
description: 'no fix available',
|
|
96
|
+
applied: false,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!dryRun) {
|
|
101
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
export async function applyFixes(targetPath, config, options) {
|
|
106
|
+
const resolvedPath = resolve(targetPath);
|
|
107
|
+
const dryRun = options?.write
|
|
108
|
+
? false
|
|
109
|
+
: options?.preview || options?.dryRun
|
|
110
|
+
? true
|
|
111
|
+
: false;
|
|
112
|
+
let fileReports;
|
|
113
|
+
const stat = statSync(resolvedPath);
|
|
114
|
+
if (stat.isFile()) {
|
|
115
|
+
const project = new Project({
|
|
116
|
+
skipAddingFilesFromTsConfig: true,
|
|
117
|
+
compilerOptions: { allowJs: true, jsx: 1 },
|
|
118
|
+
});
|
|
119
|
+
const sourceFile = project.addSourceFileAtPath(resolvedPath);
|
|
120
|
+
fileReports = [analyzeFile(sourceFile)];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
fileReports = analyzeProject(resolvedPath, config);
|
|
124
|
+
}
|
|
125
|
+
const fixableByFile = collectFixableIssues(fileReports, options);
|
|
126
|
+
const results = [];
|
|
127
|
+
for (const [filePath, issues] of fixableByFile) {
|
|
128
|
+
results.push(...processFile(filePath, issues, dryRun));
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=fix.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SourceFile } from 'ts-morph';
|
|
2
|
+
import type { FileReport, DriftConfig, BlameAttribution, DriftBlameReport } from '../types.js';
|
|
3
|
+
export declare class BlameAnalyzer {
|
|
4
|
+
private readonly projectPath;
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly analyzeProjectFn;
|
|
7
|
+
private readonly analyzeFileFn;
|
|
8
|
+
constructor(projectPath: string, analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[], analyzeFileFn: (sf: SourceFile) => FileReport, config?: DriftConfig);
|
|
9
|
+
/** Blame a single file: returns per-author attribution. */
|
|
10
|
+
static analyzeFileBlame(filePath: string, analyzeFileFn: (sf: SourceFile) => FileReport): Promise<BlameAttribution[]>;
|
|
11
|
+
/** Blame for a specific rule across all files in targetPath. */
|
|
12
|
+
static analyzeRuleBlame(rule: string, targetPath: string, analyzeFileFn: (sf: SourceFile) => FileReport): Promise<BlameAttribution[]>;
|
|
13
|
+
/** Overall blame across all files and rules. */
|
|
14
|
+
static analyzeOverallBlame(targetPath: string, analyzeFileFn: (sf: SourceFile) => FileReport): Promise<BlameAttribution[]>;
|
|
15
|
+
analyzeBlame(options: {
|
|
16
|
+
target?: 'file' | 'rule' | 'overall';
|
|
17
|
+
top?: number;
|
|
18
|
+
filePath?: string;
|
|
19
|
+
rule?: string;
|
|
20
|
+
}): Promise<DriftBlameReport>;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=blame.d.ts.map
|