@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.
Files changed (129) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +78 -0
  4. package/AGENTS.md +83 -23
  5. package/README.md +69 -2
  6. package/ROADMAP.md +130 -98
  7. package/dist/analyzer.d.ts +8 -38
  8. package/dist/analyzer.js +181 -1526
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +125 -4
  12. package/dist/config.js +1 -1
  13. package/dist/diff.d.ts +0 -7
  14. package/dist/diff.js +26 -25
  15. package/dist/fix.d.ts +17 -0
  16. package/dist/fix.js +132 -0
  17. package/dist/git/blame.d.ts +22 -0
  18. package/dist/git/blame.js +227 -0
  19. package/dist/git/helpers.d.ts +36 -0
  20. package/dist/git/helpers.js +152 -0
  21. package/dist/git/trend.d.ts +21 -0
  22. package/dist/git/trend.js +81 -0
  23. package/dist/git.d.ts +0 -13
  24. package/dist/git.js +27 -21
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +3 -0
  27. package/dist/map.d.ts +3 -0
  28. package/dist/map.js +103 -0
  29. package/dist/metrics.d.ts +4 -0
  30. package/dist/metrics.js +176 -0
  31. package/dist/plugins.d.ts +6 -0
  32. package/dist/plugins.js +74 -0
  33. package/dist/printer.js +20 -0
  34. package/dist/report.js +654 -293
  35. package/dist/reporter.js +85 -2
  36. package/dist/review.d.ts +15 -0
  37. package/dist/review.js +80 -0
  38. package/dist/rules/comments.d.ts +4 -0
  39. package/dist/rules/comments.js +45 -0
  40. package/dist/rules/complexity.d.ts +4 -0
  41. package/dist/rules/complexity.js +51 -0
  42. package/dist/rules/coupling.d.ts +4 -0
  43. package/dist/rules/coupling.js +19 -0
  44. package/dist/rules/magic.d.ts +4 -0
  45. package/dist/rules/magic.js +33 -0
  46. package/dist/rules/nesting.d.ts +5 -0
  47. package/dist/rules/nesting.js +82 -0
  48. package/dist/rules/phase0-basic.d.ts +11 -0
  49. package/dist/rules/phase0-basic.js +183 -0
  50. package/dist/rules/phase1-complexity.d.ts +7 -0
  51. package/dist/rules/phase1-complexity.js +8 -0
  52. package/dist/rules/phase2-crossfile.d.ts +23 -0
  53. package/dist/rules/phase2-crossfile.js +135 -0
  54. package/dist/rules/phase3-arch.d.ts +23 -0
  55. package/dist/rules/phase3-arch.js +151 -0
  56. package/dist/rules/phase3-configurable.d.ts +6 -0
  57. package/dist/rules/phase3-configurable.js +97 -0
  58. package/dist/rules/phase5-ai.d.ts +8 -0
  59. package/dist/rules/phase5-ai.js +262 -0
  60. package/dist/rules/phase8-semantic.d.ts +17 -0
  61. package/dist/rules/phase8-semantic.js +110 -0
  62. package/dist/rules/promise.d.ts +4 -0
  63. package/dist/rules/promise.js +24 -0
  64. package/dist/rules/shared.d.ts +7 -0
  65. package/dist/rules/shared.js +27 -0
  66. package/dist/snapshot.d.ts +19 -0
  67. package/dist/snapshot.js +119 -0
  68. package/dist/types.d.ts +69 -0
  69. package/dist/utils.d.ts +2 -1
  70. package/dist/utils.js +1 -0
  71. package/docs/AGENTS.md +146 -0
  72. package/docs/PRD.md +208 -0
  73. package/package.json +8 -3
  74. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  75. package/packages/vscode-drift/.vscodeignore +9 -0
  76. package/packages/vscode-drift/LICENSE +21 -0
  77. package/packages/vscode-drift/README.md +64 -0
  78. package/packages/vscode-drift/images/icon.png +0 -0
  79. package/packages/vscode-drift/images/icon.svg +30 -0
  80. package/packages/vscode-drift/package-lock.json +485 -0
  81. package/packages/vscode-drift/package.json +119 -0
  82. package/packages/vscode-drift/src/analyzer.ts +40 -0
  83. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  84. package/packages/vscode-drift/src/extension.ts +135 -0
  85. package/packages/vscode-drift/src/statusbar.ts +55 -0
  86. package/packages/vscode-drift/src/treeview.ts +110 -0
  87. package/packages/vscode-drift/tsconfig.json +18 -0
  88. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  89. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  90. package/src/analyzer.ts +248 -1765
  91. package/src/badge.ts +38 -16
  92. package/src/ci.ts +38 -17
  93. package/src/cli.ts +143 -4
  94. package/src/config.ts +1 -1
  95. package/src/diff.ts +36 -30
  96. package/src/fix.ts +178 -0
  97. package/src/git/blame.ts +279 -0
  98. package/src/git/helpers.ts +198 -0
  99. package/src/git/trend.ts +117 -0
  100. package/src/git.ts +33 -24
  101. package/src/index.ts +16 -1
  102. package/src/map.ts +117 -0
  103. package/src/metrics.ts +200 -0
  104. package/src/plugins.ts +76 -0
  105. package/src/printer.ts +20 -0
  106. package/src/report.ts +666 -296
  107. package/src/reporter.ts +95 -2
  108. package/src/review.ts +98 -0
  109. package/src/rules/comments.ts +56 -0
  110. package/src/rules/complexity.ts +57 -0
  111. package/src/rules/coupling.ts +23 -0
  112. package/src/rules/magic.ts +38 -0
  113. package/src/rules/nesting.ts +88 -0
  114. package/src/rules/phase0-basic.ts +194 -0
  115. package/src/rules/phase1-complexity.ts +8 -0
  116. package/src/rules/phase2-crossfile.ts +177 -0
  117. package/src/rules/phase3-arch.ts +183 -0
  118. package/src/rules/phase3-configurable.ts +132 -0
  119. package/src/rules/phase5-ai.ts +292 -0
  120. package/src/rules/phase8-semantic.ts +136 -0
  121. package/src/rules/promise.ts +29 -0
  122. package/src/rules/shared.ts +39 -0
  123. package/src/snapshot.ts +175 -0
  124. package/src/types.ts +75 -1
  125. package/src/utils.ts +3 -1
  126. package/tests/helpers.ts +45 -0
  127. package/tests/new-features.test.ts +153 -0
  128. package/tests/rules.test.ts +1269 -0
  129. 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 < 20)
6
- return '#4c1';
7
- if (score < 45)
8
- return '#dfb317';
9
- if (score < 70)
10
- return '#fe7d37';
11
- return '#e05d44';
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 < 20)
15
- return 'LOW';
16
- if (score < 45)
17
- return 'MODERATE';
18
- if (score < 70)
19
- return 'HIGH';
20
- return 'CRITICAL';
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
- // shields.io pattern: font-size="110" + scale(.1) = effective 11px
33
- // all X/Y coords are ×10
34
- const leftTextWidth = (LEFT_WIDTH - 10) * 10;
35
- const rightTextWidth = (rWidth - PADDING) * 10;
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="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
51
- <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
52
- <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
53
- <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
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 >= 80)
26
+ if (score >= GRADE_THRESHOLDS.A)
20
27
  return 'A';
21
- if (score >= 60)
28
+ if (score >= GRADE_THRESHOLDS.B)
22
29
  return 'B';
23
- if (score >= 40)
30
+ if (score >= GRADE_THRESHOLDS.C)
24
31
  return 'C';
25
- if (score >= 20)
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
- let errors = 0;
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, 10);
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 { TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
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
@@ -31,7 +31,7 @@ export async function loadConfig(projectRoot) {
31
31
  const config = mod.default ?? mod;
32
32
  return config;
33
33
  }
34
- catch {
34
+ catch { // drift-ignore
35
35
  // drift-ignore: catch-swallow — config is optional; load failure is non-fatal
36
36
  }
37
37
  }
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 scoreBefore = baseFile?.score ?? 0;
22
- const scoreAfter = currentFile?.score ?? 0;
23
- const scoreDelta = scoreAfter - scoreBefore;
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