@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.
Files changed (99) 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 +3 -1
  4. package/AGENTS.md +53 -11
  5. package/README.md +68 -1
  6. package/dist/analyzer.d.ts +6 -2
  7. package/dist/analyzer.js +116 -3
  8. package/dist/badge.js +40 -22
  9. package/dist/ci.js +32 -18
  10. package/dist/cli.js +83 -5
  11. package/dist/diff.d.ts +0 -7
  12. package/dist/diff.js +26 -25
  13. package/dist/fix.d.ts +4 -0
  14. package/dist/fix.js +59 -47
  15. package/dist/git/trend.js +1 -0
  16. package/dist/git.d.ts +0 -9
  17. package/dist/git.js +25 -19
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +3 -0
  20. package/dist/map.d.ts +3 -0
  21. package/dist/map.js +103 -0
  22. package/dist/metrics.d.ts +4 -0
  23. package/dist/metrics.js +176 -0
  24. package/dist/plugins.d.ts +6 -0
  25. package/dist/plugins.js +74 -0
  26. package/dist/printer.js +20 -0
  27. package/dist/report.js +34 -0
  28. package/dist/reporter.js +85 -2
  29. package/dist/review.d.ts +15 -0
  30. package/dist/review.js +80 -0
  31. package/dist/rules/comments.d.ts +4 -0
  32. package/dist/rules/comments.js +45 -0
  33. package/dist/rules/complexity.d.ts +4 -0
  34. package/dist/rules/complexity.js +51 -0
  35. package/dist/rules/coupling.d.ts +4 -0
  36. package/dist/rules/coupling.js +19 -0
  37. package/dist/rules/magic.d.ts +4 -0
  38. package/dist/rules/magic.js +33 -0
  39. package/dist/rules/nesting.d.ts +5 -0
  40. package/dist/rules/nesting.js +82 -0
  41. package/dist/rules/phase0-basic.js +14 -7
  42. package/dist/rules/phase1-complexity.d.ts +6 -30
  43. package/dist/rules/phase1-complexity.js +7 -276
  44. package/dist/rules/phase2-crossfile.d.ts +0 -4
  45. package/dist/rules/phase2-crossfile.js +52 -39
  46. package/dist/rules/phase3-arch.d.ts +0 -8
  47. package/dist/rules/phase3-arch.js +26 -23
  48. package/dist/rules/phase3-configurable.d.ts +6 -0
  49. package/dist/rules/phase3-configurable.js +97 -0
  50. package/dist/rules/phase8-semantic.d.ts +0 -5
  51. package/dist/rules/phase8-semantic.js +30 -29
  52. package/dist/rules/promise.d.ts +4 -0
  53. package/dist/rules/promise.js +24 -0
  54. package/dist/snapshot.d.ts +19 -0
  55. package/dist/snapshot.js +119 -0
  56. package/dist/types.d.ts +69 -0
  57. package/dist/utils.d.ts +2 -1
  58. package/dist/utils.js +1 -0
  59. package/docs/AGENTS.md +146 -0
  60. package/docs/PRD.md +208 -0
  61. package/package.json +1 -1
  62. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  63. package/packages/vscode-drift/package.json +1 -1
  64. package/packages/vscode-drift/src/analyzer.ts +2 -0
  65. package/packages/vscode-drift/src/extension.ts +87 -63
  66. package/packages/vscode-drift/src/statusbar.ts +13 -5
  67. package/packages/vscode-drift/src/treeview.ts +2 -0
  68. package/src/analyzer.ts +144 -12
  69. package/src/badge.ts +38 -16
  70. package/src/ci.ts +38 -17
  71. package/src/cli.ts +96 -6
  72. package/src/diff.ts +36 -30
  73. package/src/fix.ts +77 -53
  74. package/src/git/trend.ts +3 -2
  75. package/src/git.ts +31 -22
  76. package/src/index.ts +16 -1
  77. package/src/map.ts +117 -0
  78. package/src/metrics.ts +200 -0
  79. package/src/plugins.ts +76 -0
  80. package/src/printer.ts +20 -0
  81. package/src/report.ts +35 -0
  82. package/src/reporter.ts +95 -2
  83. package/src/review.ts +98 -0
  84. package/src/rules/comments.ts +56 -0
  85. package/src/rules/complexity.ts +57 -0
  86. package/src/rules/coupling.ts +23 -0
  87. package/src/rules/magic.ts +38 -0
  88. package/src/rules/nesting.ts +88 -0
  89. package/src/rules/phase0-basic.ts +14 -7
  90. package/src/rules/phase1-complexity.ts +8 -302
  91. package/src/rules/phase2-crossfile.ts +68 -40
  92. package/src/rules/phase3-arch.ts +34 -30
  93. package/src/rules/phase3-configurable.ts +132 -0
  94. package/src/rules/phase8-semantic.ts +33 -29
  95. package/src/rules/promise.ts +29 -0
  96. package/src/snapshot.ts +175 -0
  97. package/src/types.ts +75 -1
  98. package/src/utils.ts +3 -1
  99. 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 >= 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
@@ -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: options.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 (options.dryRun) {
206
- console.log(`\ndrift fix --dry-run: ${results.length} fixable issues found\n`);
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 ? (options.dryRun ? 'would fix' : 'fixed') : 'skipped';
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 (!options.dryRun && applied.length > 0) {
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 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 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?.dryRun ?? false;
56
- // Determine if target is a file or directory
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
- // Collect fixable issues, optionally filtered by rule
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
- const content = readFileSync(filePath, 'utf8');
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
@@ -1,3 +1,4 @@
1
+ // drift-ignore-file
1
2
  import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js';
2
3
  import { buildReport } from '../reporter.js';
3
4
  export class TrendAnalyzer {
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
- export function extractFilesAtRef(projectPath, ref) {
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
- // Verify ref exists
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
- // List all .ts files tracked at this ref (excluding .d.ts)
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
- const tsFiles = fileList
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
- let content;
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 type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js';
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
@@ -0,0 +1,3 @@
1
+ export declare function generateArchitectureSvg(targetPath: string): string;
2
+ export declare function generateArchitectureMap(targetPath: string, outputFile?: string): string;
3
+ //# sourceMappingURL=map.d.ts.map
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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