@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/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
|
@@ -16,6 +16,9 @@ import { generateHtmlReport } from './report.js';
|
|
|
16
16
|
import { generateBadge } from './badge.js';
|
|
17
17
|
import { emitCIAnnotations, printCISummary } from './ci.js';
|
|
18
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';
|
|
19
22
|
const program = new Command();
|
|
20
23
|
program
|
|
21
24
|
.name('drift')
|
|
@@ -103,6 +106,43 @@ program
|
|
|
103
106
|
cleanupTempDir(tempDir);
|
|
104
107
|
}
|
|
105
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
|
+
});
|
|
106
146
|
program
|
|
107
147
|
.command('report [path]')
|
|
108
148
|
.description('Generate a self-contained HTML report')
|
|
@@ -189,21 +229,27 @@ program
|
|
|
189
229
|
.command('fix [path]')
|
|
190
230
|
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
191
231
|
.option('--rule <rule>', 'Fix only a specific rule')
|
|
232
|
+
.option('--preview', 'Preview changes without writing files')
|
|
233
|
+
.option('--write', 'Write fixes to disk')
|
|
192
234
|
.option('--dry-run', 'Show what would change without writing files')
|
|
193
235
|
.action(async (targetPath, options) => {
|
|
194
236
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
195
237
|
const config = await loadConfig(resolvedPath);
|
|
238
|
+
const previewMode = Boolean(options.preview || options.dryRun);
|
|
239
|
+
const writeMode = options.write ?? !previewMode;
|
|
196
240
|
const results = await applyFixes(resolvedPath, config, {
|
|
197
241
|
rule: options.rule,
|
|
198
|
-
dryRun:
|
|
242
|
+
dryRun: previewMode,
|
|
243
|
+
preview: previewMode,
|
|
244
|
+
write: writeMode,
|
|
199
245
|
});
|
|
200
246
|
if (results.length === 0) {
|
|
201
247
|
console.log('No fixable issues found.');
|
|
202
248
|
return;
|
|
203
249
|
}
|
|
204
250
|
const applied = results.filter(r => r.applied);
|
|
205
|
-
if (
|
|
206
|
-
console.log(`\ndrift fix --
|
|
251
|
+
if (previewMode) {
|
|
252
|
+
console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`);
|
|
207
253
|
}
|
|
208
254
|
else {
|
|
209
255
|
console.log(`\ndrift fix: ${applied.length} fixes applied\n`);
|
|
@@ -219,13 +265,45 @@ program
|
|
|
219
265
|
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '');
|
|
220
266
|
console.log(` ${relPath}`);
|
|
221
267
|
for (const r of fileResults) {
|
|
222
|
-
const status = r.applied ? (
|
|
268
|
+
const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped';
|
|
223
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
|
+
}
|
|
224
274
|
}
|
|
225
275
|
}
|
|
226
|
-
if (!
|
|
276
|
+
if (!previewMode && applied.length > 0) {
|
|
227
277
|
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
|
|
228
278
|
}
|
|
229
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
|
+
});
|
|
230
308
|
program.parse();
|
|
231
309
|
//# 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.
|
package/dist/git.js
CHANGED
|
@@ -12,22 +12,23 @@ import { randomUUID } from 'node:crypto';
|
|
|
12
12
|
*
|
|
13
13
|
* Throws if the directory is not a git repo or the ref is invalid.
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
|
-
// Verify git repo
|
|
15
|
+
function verifyGitRepo(projectPath) {
|
|
17
16
|
try {
|
|
18
17
|
execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' });
|
|
19
18
|
}
|
|
20
19
|
catch {
|
|
21
20
|
throw new Error(`Not a git repository: ${projectPath}`);
|
|
22
21
|
}
|
|
23
|
-
|
|
22
|
+
}
|
|
23
|
+
function verifyRefExists(projectPath, ref) {
|
|
24
24
|
try {
|
|
25
25
|
execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' });
|
|
26
26
|
}
|
|
27
27
|
catch {
|
|
28
28
|
throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`);
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
}
|
|
31
|
+
function listTsFilesAtRef(projectPath, ref) {
|
|
31
32
|
let fileList;
|
|
32
33
|
try {
|
|
33
34
|
fileList = execSync(`git ls-tree -r --name-only ${ref}`, { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' });
|
|
@@ -35,30 +36,35 @@ export function extractFilesAtRef(projectPath, ref) {
|
|
|
35
36
|
catch {
|
|
36
37
|
throw new Error(`Failed to list files at ref '${ref}'`);
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
return fileList
|
|
39
40
|
.split('\n')
|
|
40
41
|
.map(f => f.trim())
|
|
41
42
|
.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'));
|
|
43
|
+
}
|
|
44
|
+
function extractFile(projectPath, ref, filePath, tempDir) {
|
|
45
|
+
let content;
|
|
46
|
+
try {
|
|
47
|
+
content = execSync(`git show ${ref}:${filePath}`, { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const destPath = join(tempDir, filePath.split('/').join(sep));
|
|
53
|
+
const destDir = destPath.substring(0, destPath.lastIndexOf(sep));
|
|
54
|
+
mkdirSync(destDir, { recursive: true });
|
|
55
|
+
writeFileSync(destPath, content, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
export function extractFilesAtRef(projectPath, ref) {
|
|
58
|
+
verifyGitRepo(projectPath);
|
|
59
|
+
verifyRefExists(projectPath, ref);
|
|
60
|
+
const tsFiles = listTsFilesAtRef(projectPath, ref);
|
|
42
61
|
if (tsFiles.length === 0) {
|
|
43
62
|
throw new Error(`No TypeScript files found at ref '${ref}'`);
|
|
44
63
|
}
|
|
45
|
-
// Create temp directory
|
|
46
64
|
const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`);
|
|
47
65
|
mkdirSync(tempDir, { recursive: true });
|
|
48
|
-
// Extract each file
|
|
49
66
|
for (const filePath of tsFiles) {
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
content = execSync(`git show ${ref}:${filePath}`, { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' });
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// File may not exist at this ref — skip
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
const destPath = join(tempDir, filePath.split('/').join(sep));
|
|
59
|
-
const destDir = destPath.substring(0, destPath.lastIndexOf(sep));
|
|
60
|
-
mkdirSync(destDir, { recursive: true });
|
|
61
|
-
writeFileSync(destPath, content, 'utf-8');
|
|
67
|
+
extractFile(projectPath, ref, filePath, tempDir);
|
|
62
68
|
}
|
|
63
69
|
return tempDir;
|
|
64
70
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js';
|
|
2
2
|
export { buildReport, formatMarkdown } from './reporter.js';
|
|
3
3
|
export { computeDiff } from './diff.js';
|
|
4
|
-
export
|
|
4
|
+
export { generateReview, formatReviewMarkdown } from './review.js';
|
|
5
|
+
export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
|
|
6
|
+
export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig, RepoQualityScore, MaintenanceRiskMetrics, DriftPlugin, DriftPluginRule, } from './types.js';
|
|
7
|
+
export { loadHistory, saveSnapshot } from './snapshot.js';
|
|
8
|
+
export type { SnapshotEntry, SnapshotHistory } from './snapshot.js';
|
|
5
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js';
|
|
2
2
|
export { buildReport, formatMarkdown } from './reporter.js';
|
|
3
3
|
export { computeDiff } from './diff.js';
|
|
4
|
+
export { generateReview, formatReviewMarkdown } from './review.js';
|
|
5
|
+
export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
|
|
6
|
+
export { loadHistory, saveSnapshot } from './snapshot.js';
|
|
4
7
|
//# sourceMappingURL=index.js.map
|
package/dist/map.d.ts
ADDED
package/dist/map.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, relative } from 'node:path';
|
|
3
|
+
import { Project } from 'ts-morph';
|
|
4
|
+
function detectLayer(relPath) {
|
|
5
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
6
|
+
const first = normalized.split('/')[0] || 'root';
|
|
7
|
+
return first;
|
|
8
|
+
}
|
|
9
|
+
function esc(value) {
|
|
10
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
|
+
}
|
|
12
|
+
export function generateArchitectureSvg(targetPath) {
|
|
13
|
+
const project = new Project({
|
|
14
|
+
skipAddingFilesFromTsConfig: true,
|
|
15
|
+
compilerOptions: { allowJs: true, jsx: 1 },
|
|
16
|
+
});
|
|
17
|
+
project.addSourceFilesAtPaths([
|
|
18
|
+
`${targetPath}/**/*.ts`,
|
|
19
|
+
`${targetPath}/**/*.tsx`,
|
|
20
|
+
`${targetPath}/**/*.js`,
|
|
21
|
+
`${targetPath}/**/*.jsx`,
|
|
22
|
+
`!${targetPath}/**/node_modules/**`,
|
|
23
|
+
`!${targetPath}/**/dist/**`,
|
|
24
|
+
`!${targetPath}/**/.next/**`,
|
|
25
|
+
`!${targetPath}/**/*.d.ts`,
|
|
26
|
+
]);
|
|
27
|
+
const layers = new Map();
|
|
28
|
+
const edges = new Map();
|
|
29
|
+
for (const file of project.getSourceFiles()) {
|
|
30
|
+
const rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/');
|
|
31
|
+
const layerName = detectLayer(rel);
|
|
32
|
+
if (!layers.has(layerName))
|
|
33
|
+
layers.set(layerName, { name: layerName, files: new Set() });
|
|
34
|
+
layers.get(layerName).files.add(rel);
|
|
35
|
+
for (const decl of file.getImportDeclarations()) {
|
|
36
|
+
const imported = decl.getModuleSpecifierSourceFile();
|
|
37
|
+
if (!imported)
|
|
38
|
+
continue;
|
|
39
|
+
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/');
|
|
40
|
+
const importedLayer = detectLayer(importedRel);
|
|
41
|
+
if (importedLayer === layerName)
|
|
42
|
+
continue;
|
|
43
|
+
const key = `${layerName}->${importedLayer}`;
|
|
44
|
+
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
const width = 960;
|
|
49
|
+
const rowHeight = 90;
|
|
50
|
+
const height = Math.max(180, layerList.length * rowHeight + 120);
|
|
51
|
+
const boxWidth = 240;
|
|
52
|
+
const boxHeight = 50;
|
|
53
|
+
const left = 100;
|
|
54
|
+
const boxes = layerList.map((layer, index) => {
|
|
55
|
+
const y = 60 + index * rowHeight;
|
|
56
|
+
return {
|
|
57
|
+
...layer,
|
|
58
|
+
x: left,
|
|
59
|
+
y,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
const boxByName = new Map(boxes.map((box) => [box.name, box]));
|
|
63
|
+
const lines = [...edges.entries()].map(([key, count]) => {
|
|
64
|
+
const [from, to] = key.split('->');
|
|
65
|
+
const a = boxByName.get(from);
|
|
66
|
+
const b = boxByName.get(to);
|
|
67
|
+
if (!a || !b)
|
|
68
|
+
return '';
|
|
69
|
+
const startX = a.x + boxWidth;
|
|
70
|
+
const startY = a.y + boxHeight / 2;
|
|
71
|
+
const endX = b.x;
|
|
72
|
+
const endY = b.y + boxHeight / 2;
|
|
73
|
+
return `
|
|
74
|
+
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
|
|
75
|
+
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`;
|
|
76
|
+
}).join('');
|
|
77
|
+
const nodes = boxes.map((box) => `
|
|
78
|
+
<g>
|
|
79
|
+
<rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
80
|
+
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
81
|
+
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
82
|
+
</g>`).join('');
|
|
83
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
84
|
+
<defs>
|
|
85
|
+
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
86
|
+
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
87
|
+
</marker>
|
|
88
|
+
</defs>
|
|
89
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
90
|
+
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
91
|
+
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
92
|
+
${lines}
|
|
93
|
+
${nodes}
|
|
94
|
+
</svg>`;
|
|
95
|
+
}
|
|
96
|
+
export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg') {
|
|
97
|
+
const resolvedTarget = resolve(targetPath);
|
|
98
|
+
const svg = generateArchitectureSvg(resolvedTarget);
|
|
99
|
+
const outPath = resolve(outputFile);
|
|
100
|
+
writeFileSync(outPath, svg, 'utf8');
|
|
101
|
+
return outPath;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=map.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DriftReport, FileReport, MaintenanceRiskMetrics, RepoQualityScore } from './types.js';
|
|
2
|
+
export declare function computeRepoQuality(targetPath: string, files: FileReport[]): RepoQualityScore;
|
|
3
|
+
export declare function computeMaintenanceRisk(report: DriftReport): MaintenanceRiskMetrics;
|
|
4
|
+
//# sourceMappingURL=metrics.d.ts.map
|