@eduardbar/drift 1.0.0 → 1.2.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/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -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 +179 -6
- 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 +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -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/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -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/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -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 +206 -7
- 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 +31 -1
- package/src/map.ts +219 -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/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -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
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// drift-ignore-file
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { writeFileSync } from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
5
|
+
import { basename, resolve } from 'node:path';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
+
import { createInterface } from 'node:readline/promises';
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
7
9
|
const require = createRequire(import.meta.url);
|
|
8
10
|
const { version: VERSION } = require('../package.json');
|
|
9
11
|
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
|
|
@@ -16,6 +18,10 @@ import { generateHtmlReport } from './report.js';
|
|
|
16
18
|
import { generateBadge } from './badge.js';
|
|
17
19
|
import { emitCIAnnotations, printCISummary } from './ci.js';
|
|
18
20
|
import { applyFixes } from './fix.js';
|
|
21
|
+
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
|
|
22
|
+
import { generateReview } from './review.js';
|
|
23
|
+
import { generateArchitectureMap } from './map.js';
|
|
24
|
+
import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js';
|
|
19
25
|
const program = new Command();
|
|
20
26
|
program
|
|
21
27
|
.name('drift')
|
|
@@ -103,6 +109,44 @@ program
|
|
|
103
109
|
cleanupTempDir(tempDir);
|
|
104
110
|
}
|
|
105
111
|
});
|
|
112
|
+
program
|
|
113
|
+
.command('review')
|
|
114
|
+
.description('Review drift against a base ref and output PR markdown')
|
|
115
|
+
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
116
|
+
.option('--json', 'Output structured review JSON')
|
|
117
|
+
.option('--comment', 'Output markdown comment body')
|
|
118
|
+
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
119
|
+
.action(async (options) => {
|
|
120
|
+
try {
|
|
121
|
+
const review = await generateReview(resolve('.'), options.base);
|
|
122
|
+
if (options.json) {
|
|
123
|
+
process.stdout.write(JSON.stringify(review, null, 2) + '\n');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n');
|
|
127
|
+
}
|
|
128
|
+
const failOn = options.failOn ? Number(options.failOn) : undefined;
|
|
129
|
+
if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
program
|
|
140
|
+
.command('map [path]')
|
|
141
|
+
.description('Generate architecture.svg with simple layer dependencies')
|
|
142
|
+
.option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
|
|
143
|
+
.action(async (targetPath, options) => {
|
|
144
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
145
|
+
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`);
|
|
146
|
+
const config = await loadConfig(resolvedPath);
|
|
147
|
+
const out = generateArchitectureMap(resolvedPath, options.output, config);
|
|
148
|
+
process.stderr.write(` Architecture map saved to ${out}\n\n`);
|
|
149
|
+
});
|
|
106
150
|
program
|
|
107
151
|
.command('report [path]')
|
|
108
152
|
.description('Generate a self-contained HTML report')
|
|
@@ -189,21 +233,49 @@ program
|
|
|
189
233
|
.command('fix [path]')
|
|
190
234
|
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
191
235
|
.option('--rule <rule>', 'Fix only a specific rule')
|
|
236
|
+
.option('--preview', 'Preview changes without writing files')
|
|
237
|
+
.option('--write', 'Write fixes to disk')
|
|
192
238
|
.option('--dry-run', 'Show what would change without writing files')
|
|
239
|
+
.option('-y, --yes', 'Skip interactive confirmation for --write')
|
|
193
240
|
.action(async (targetPath, options) => {
|
|
194
241
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
195
242
|
const config = await loadConfig(resolvedPath);
|
|
243
|
+
const previewMode = Boolean(options.preview || options.dryRun);
|
|
244
|
+
const writeMode = options.write ?? !previewMode;
|
|
245
|
+
if (writeMode && !options.yes) {
|
|
246
|
+
const previewResults = await applyFixes(resolvedPath, config, {
|
|
247
|
+
rule: options.rule,
|
|
248
|
+
dryRun: true,
|
|
249
|
+
preview: true,
|
|
250
|
+
write: false,
|
|
251
|
+
});
|
|
252
|
+
if (previewResults.length === 0) {
|
|
253
|
+
console.log('No fixable issues found.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const files = new Set(previewResults.map((result) => result.file)).size;
|
|
257
|
+
const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `;
|
|
258
|
+
const rl = createInterface({ input, output });
|
|
259
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase();
|
|
260
|
+
rl.close();
|
|
261
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
262
|
+
console.log('Aborted. No files were modified.');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
196
266
|
const results = await applyFixes(resolvedPath, config, {
|
|
197
267
|
rule: options.rule,
|
|
198
|
-
dryRun:
|
|
268
|
+
dryRun: previewMode,
|
|
269
|
+
preview: previewMode,
|
|
270
|
+
write: writeMode,
|
|
199
271
|
});
|
|
200
272
|
if (results.length === 0) {
|
|
201
273
|
console.log('No fixable issues found.');
|
|
202
274
|
return;
|
|
203
275
|
}
|
|
204
276
|
const applied = results.filter(r => r.applied);
|
|
205
|
-
if (
|
|
206
|
-
console.log(`\ndrift fix --
|
|
277
|
+
if (previewMode) {
|
|
278
|
+
console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`);
|
|
207
279
|
}
|
|
208
280
|
else {
|
|
209
281
|
console.log(`\ndrift fix: ${applied.length} fixes applied\n`);
|
|
@@ -219,13 +291,114 @@ program
|
|
|
219
291
|
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '');
|
|
220
292
|
console.log(` ${relPath}`);
|
|
221
293
|
for (const r of fileResults) {
|
|
222
|
-
const status = r.applied ? (
|
|
294
|
+
const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped';
|
|
223
295
|
console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`);
|
|
296
|
+
if (r.before || r.after) {
|
|
297
|
+
console.log(` before: ${r.before ?? '(empty)'}`);
|
|
298
|
+
console.log(` after : ${r.after ?? '(empty)'}`);
|
|
299
|
+
}
|
|
224
300
|
}
|
|
225
301
|
}
|
|
226
|
-
if (!
|
|
302
|
+
if (!previewMode && applied.length > 0) {
|
|
227
303
|
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
|
|
228
304
|
}
|
|
229
305
|
});
|
|
306
|
+
program
|
|
307
|
+
.command('snapshot [path]')
|
|
308
|
+
.description('Record a score snapshot to drift-history.json')
|
|
309
|
+
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
310
|
+
.option('--history', 'show all recorded snapshots')
|
|
311
|
+
.option('--diff', 'compare current score vs last snapshot')
|
|
312
|
+
.action(async (targetPath, opts) => {
|
|
313
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
314
|
+
if (opts.history) {
|
|
315
|
+
const history = loadHistory(resolvedPath);
|
|
316
|
+
printHistory(history);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
320
|
+
const config = await loadConfig(resolvedPath);
|
|
321
|
+
const files = analyzeProject(resolvedPath, config);
|
|
322
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
323
|
+
const report = buildReport(resolvedPath, files);
|
|
324
|
+
if (opts.diff) {
|
|
325
|
+
const history = loadHistory(resolvedPath);
|
|
326
|
+
printSnapshotDiff(history, report.totalScore);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const entry = saveSnapshot(resolvedPath, report, opts.label);
|
|
330
|
+
const labelStr = entry.label ? ` [${entry.label}]` : '';
|
|
331
|
+
process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
|
|
332
|
+
process.stdout.write(` Saved to drift-history.json\n\n`);
|
|
333
|
+
});
|
|
334
|
+
const cloud = program
|
|
335
|
+
.command('cloud')
|
|
336
|
+
.description('Local SaaS foundations: ingest, summary, and dashboard');
|
|
337
|
+
cloud
|
|
338
|
+
.command('ingest [path]')
|
|
339
|
+
.description('Scan path, build report, and store cloud snapshot')
|
|
340
|
+
.requiredOption('--workspace <id>', 'Workspace id')
|
|
341
|
+
.requiredOption('--user <id>', 'User id')
|
|
342
|
+
.option('--repo <name>', 'Repo name (default: basename of scanned path)')
|
|
343
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
344
|
+
.action(async (targetPath, options) => {
|
|
345
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
346
|
+
process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
|
|
347
|
+
const config = await loadConfig(resolvedPath);
|
|
348
|
+
const files = analyzeProject(resolvedPath, config);
|
|
349
|
+
const report = buildReport(resolvedPath, files);
|
|
350
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
351
|
+
workspaceId: options.workspace,
|
|
352
|
+
userId: options.user,
|
|
353
|
+
repoName: options.repo ?? basename(resolvedPath),
|
|
354
|
+
storeFile: options.store,
|
|
355
|
+
policy: config?.saas,
|
|
356
|
+
});
|
|
357
|
+
process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
|
|
358
|
+
process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
|
|
359
|
+
process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
|
|
360
|
+
});
|
|
361
|
+
cloud
|
|
362
|
+
.command('summary')
|
|
363
|
+
.description('Show SaaS usage metrics and free threshold status')
|
|
364
|
+
.option('--json', 'Output raw JSON summary')
|
|
365
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
366
|
+
.action((options) => {
|
|
367
|
+
const summary = getSaasSummary({ storeFile: options.store });
|
|
368
|
+
if (options.json) {
|
|
369
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
process.stdout.write('\n');
|
|
373
|
+
process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
|
|
374
|
+
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
|
|
375
|
+
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
|
|
376
|
+
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
|
|
377
|
+
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
|
|
378
|
+
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
|
|
379
|
+
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
|
|
380
|
+
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
|
|
381
|
+
process.stdout.write('Runs per month:\n');
|
|
382
|
+
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
|
|
383
|
+
if (monthly.length === 0) {
|
|
384
|
+
process.stdout.write(' - none\n\n');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const [month, runs] of monthly) {
|
|
388
|
+
process.stdout.write(` - ${month}: ${runs}\n`);
|
|
389
|
+
}
|
|
390
|
+
process.stdout.write('\n');
|
|
391
|
+
});
|
|
392
|
+
cloud
|
|
393
|
+
.command('dashboard')
|
|
394
|
+
.description('Generate an HTML dashboard with trends and hotspots')
|
|
395
|
+
.option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
|
|
396
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
397
|
+
.action((options) => {
|
|
398
|
+
const html = generateSaasDashboardHtml({ storeFile: options.store });
|
|
399
|
+
const outPath = resolve(options.output);
|
|
400
|
+
writeFileSync(outPath, html, 'utf8');
|
|
401
|
+
process.stdout.write(`Dashboard saved to ${outPath}\n`);
|
|
402
|
+
});
|
|
230
403
|
program.parse();
|
|
231
404
|
//# sourceMappingURL=cli.js.map
|
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
CHANGED
|
@@ -5,9 +5,13 @@ export interface FixResult {
|
|
|
5
5
|
line: number;
|
|
6
6
|
description: string;
|
|
7
7
|
applied: boolean;
|
|
8
|
+
before?: string;
|
|
9
|
+
after?: string;
|
|
8
10
|
}
|
|
9
11
|
export declare function applyFixes(targetPath: string, config?: DriftConfig, options?: {
|
|
10
12
|
rule?: string;
|
|
11
13
|
dryRun?: boolean;
|
|
14
|
+
write?: boolean;
|
|
15
|
+
preview?: boolean;
|
|
12
16
|
}): Promise<FixResult[]>;
|
|
13
17
|
//# sourceMappingURL=fix.d.ts.map
|
package/dist/fix.js
CHANGED
|
@@ -50,10 +50,65 @@ function applyFixToLines(lines, issue) {
|
|
|
50
50
|
}
|
|
51
51
|
return null;
|
|
52
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
|
+
}
|
|
53
105
|
export async function applyFixes(targetPath, config, options) {
|
|
54
106
|
const resolvedPath = resolve(targetPath);
|
|
55
|
-
const dryRun = options?.
|
|
56
|
-
|
|
107
|
+
const dryRun = options?.write
|
|
108
|
+
? false
|
|
109
|
+
: options?.preview || options?.dryRun
|
|
110
|
+
? true
|
|
111
|
+
: false;
|
|
57
112
|
let fileReports;
|
|
58
113
|
const stat = statSync(resolvedPath);
|
|
59
114
|
if (stat.isFile()) {
|
|
@@ -67,53 +122,10 @@ export async function applyFixes(targetPath, config, options) {
|
|
|
67
122
|
else {
|
|
68
123
|
fileReports = analyzeProject(resolvedPath, config);
|
|
69
124
|
}
|
|
70
|
-
|
|
71
|
-
const fixableByFile = new Map();
|
|
72
|
-
for (const report of fileReports) {
|
|
73
|
-
const fixableIssues = report.issues.filter(issue => {
|
|
74
|
-
if (!isFixable(issue))
|
|
75
|
-
return false;
|
|
76
|
-
if (options?.rule && issue.rule !== options.rule)
|
|
77
|
-
return false;
|
|
78
|
-
return true;
|
|
79
|
-
});
|
|
80
|
-
if (fixableIssues.length > 0) {
|
|
81
|
-
fixableByFile.set(report.path, fixableIssues);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
125
|
+
const fixableByFile = collectFixableIssues(fileReports, options);
|
|
84
126
|
const results = [];
|
|
85
127
|
for (const [filePath, issues] of fixableByFile) {
|
|
86
|
-
|
|
87
|
-
let lines = content.split('\n');
|
|
88
|
-
// Sort issues by line descending to avoid line number drift after fixes
|
|
89
|
-
const sortedIssues = [...issues].sort((a, b) => b.line - a.line);
|
|
90
|
-
// Track line offset caused by deletions (debug-leftover removes lines)
|
|
91
|
-
// We process top-to-bottom after sorting descending, so no offset needed per issue
|
|
92
|
-
for (const issue of sortedIssues) {
|
|
93
|
-
const fixResult = applyFixToLines(lines, issue);
|
|
94
|
-
if (fixResult) {
|
|
95
|
-
results.push({
|
|
96
|
-
file: filePath,
|
|
97
|
-
rule: issue.rule,
|
|
98
|
-
line: issue.line,
|
|
99
|
-
description: fixResult.description,
|
|
100
|
-
applied: true,
|
|
101
|
-
});
|
|
102
|
-
lines = fixResult.newLines;
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
results.push({
|
|
106
|
-
file: filePath,
|
|
107
|
-
rule: issue.rule,
|
|
108
|
-
line: issue.line,
|
|
109
|
-
description: 'no fix available',
|
|
110
|
-
applied: false,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (!dryRun) {
|
|
115
|
-
writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
116
|
-
}
|
|
128
|
+
results.push(...processFile(filePath, issues, dryRun));
|
|
117
129
|
}
|
|
118
130
|
return results;
|
|
119
131
|
}
|
package/dist/git/trend.js
CHANGED
package/dist/git.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extract all TypeScript files from the project at a given git ref into a
|
|
3
|
-
* temporary directory. Returns the temp directory path.
|
|
4
|
-
*
|
|
5
|
-
* Uses `git ls-tree` to list files and `git show <ref>:<path>` to read each
|
|
6
|
-
* file — no checkout, no stash, no repo state mutation.
|
|
7
|
-
*
|
|
8
|
-
* Throws if the directory is not a git repo or the ref is invalid.
|
|
9
|
-
*/
|
|
10
1
|
export declare function extractFilesAtRef(projectPath: string, ref: string): string;
|
|
11
2
|
/**
|
|
12
3
|
* Clean up a temporary directory created by extractFilesAtRef.
|