@eduardbar/drift 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +3 -1
- package/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +179 -6
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +34 -0
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +206 -7
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +31 -1
- package/src/map.ts +219 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -0
package/src/cli.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// drift-ignore-file
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
import { writeFileSync } from 'node:fs'
|
|
5
|
-
import { resolve } from 'node:path'
|
|
5
|
+
import { basename, resolve } from 'node:path'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
|
+
import { createInterface } from 'node:readline/promises'
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
7
9
|
const require = createRequire(import.meta.url)
|
|
8
10
|
const { version: VERSION } = require('../package.json') as { version: string }
|
|
9
11
|
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
|
|
@@ -16,6 +18,10 @@ import { generateHtmlReport } from './report.js'
|
|
|
16
18
|
import { generateBadge } from './badge.js'
|
|
17
19
|
import { emitCIAnnotations, printCISummary } from './ci.js'
|
|
18
20
|
import { applyFixes, type FixResult } from './fix.js'
|
|
21
|
+
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
|
|
22
|
+
import { generateReview } from './review.js'
|
|
23
|
+
import { generateArchitectureMap } from './map.js'
|
|
24
|
+
import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js'
|
|
19
25
|
|
|
20
26
|
const program = new Command()
|
|
21
27
|
|
|
@@ -117,6 +123,46 @@ program
|
|
|
117
123
|
}
|
|
118
124
|
})
|
|
119
125
|
|
|
126
|
+
program
|
|
127
|
+
.command('review')
|
|
128
|
+
.description('Review drift against a base ref and output PR markdown')
|
|
129
|
+
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
130
|
+
.option('--json', 'Output structured review JSON')
|
|
131
|
+
.option('--comment', 'Output markdown comment body')
|
|
132
|
+
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
133
|
+
.action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
|
|
134
|
+
try {
|
|
135
|
+
const review = await generateReview(resolve('.'), options.base)
|
|
136
|
+
|
|
137
|
+
if (options.json) {
|
|
138
|
+
process.stdout.write(JSON.stringify(review, null, 2) + '\n')
|
|
139
|
+
} else {
|
|
140
|
+
process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const failOn = options.failOn ? Number(options.failOn) : undefined
|
|
144
|
+
if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
149
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
150
|
+
process.exit(1)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
program
|
|
155
|
+
.command('map [path]')
|
|
156
|
+
.description('Generate architecture.svg with simple layer dependencies')
|
|
157
|
+
.option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
|
|
158
|
+
.action(async (targetPath: string | undefined, options: { output: string }) => {
|
|
159
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
160
|
+
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
|
|
161
|
+
const config = await loadConfig(resolvedPath)
|
|
162
|
+
const out = generateArchitectureMap(resolvedPath, options.output, config)
|
|
163
|
+
process.stderr.write(` Architecture map saved to ${out}\n\n`)
|
|
164
|
+
})
|
|
165
|
+
|
|
120
166
|
program
|
|
121
167
|
.command('report [path]')
|
|
122
168
|
.description('Generate a self-contained HTML report')
|
|
@@ -214,14 +260,46 @@ program
|
|
|
214
260
|
.command('fix [path]')
|
|
215
261
|
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
216
262
|
.option('--rule <rule>', 'Fix only a specific rule')
|
|
263
|
+
.option('--preview', 'Preview changes without writing files')
|
|
264
|
+
.option('--write', 'Write fixes to disk')
|
|
217
265
|
.option('--dry-run', 'Show what would change without writing files')
|
|
218
|
-
.
|
|
266
|
+
.option('-y, --yes', 'Skip interactive confirmation for --write')
|
|
267
|
+
.action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean; yes?: boolean }) => {
|
|
219
268
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
220
269
|
const config = await loadConfig(resolvedPath)
|
|
270
|
+
const previewMode = Boolean(options.preview || options.dryRun)
|
|
271
|
+
const writeMode = options.write ?? !previewMode
|
|
272
|
+
|
|
273
|
+
if (writeMode && !options.yes) {
|
|
274
|
+
const previewResults = await applyFixes(resolvedPath, config, {
|
|
275
|
+
rule: options.rule,
|
|
276
|
+
dryRun: true,
|
|
277
|
+
preview: true,
|
|
278
|
+
write: false,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (previewResults.length === 0) {
|
|
282
|
+
console.log('No fixable issues found.')
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const files = new Set(previewResults.map((result) => result.file)).size
|
|
287
|
+
const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `
|
|
288
|
+
const rl = createInterface({ input, output })
|
|
289
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase()
|
|
290
|
+
rl.close()
|
|
291
|
+
|
|
292
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
293
|
+
console.log('Aborted. No files were modified.')
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
}
|
|
221
297
|
|
|
222
298
|
const results = await applyFixes(resolvedPath, config, {
|
|
223
299
|
rule: options.rule,
|
|
224
|
-
dryRun:
|
|
300
|
+
dryRun: previewMode,
|
|
301
|
+
preview: previewMode,
|
|
302
|
+
write: writeMode,
|
|
225
303
|
})
|
|
226
304
|
|
|
227
305
|
if (results.length === 0) {
|
|
@@ -231,8 +309,8 @@ program
|
|
|
231
309
|
|
|
232
310
|
const applied = results.filter(r => r.applied)
|
|
233
311
|
|
|
234
|
-
if (
|
|
235
|
-
console.log(`\ndrift fix --
|
|
312
|
+
if (previewMode) {
|
|
313
|
+
console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`)
|
|
236
314
|
} else {
|
|
237
315
|
console.log(`\ndrift fix: ${applied.length} fixes applied\n`)
|
|
238
316
|
}
|
|
@@ -248,14 +326,135 @@ program
|
|
|
248
326
|
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '')
|
|
249
327
|
console.log(` ${relPath}`)
|
|
250
328
|
for (const r of fileResults) {
|
|
251
|
-
const status = r.applied ? (
|
|
329
|
+
const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped'
|
|
252
330
|
console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`)
|
|
331
|
+
if (r.before || r.after) {
|
|
332
|
+
console.log(` before: ${r.before ?? '(empty)'}`)
|
|
333
|
+
console.log(` after : ${r.after ?? '(empty)'}`)
|
|
334
|
+
}
|
|
253
335
|
}
|
|
254
336
|
}
|
|
255
337
|
|
|
256
|
-
if (!
|
|
338
|
+
if (!previewMode && applied.length > 0) {
|
|
257
339
|
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`)
|
|
258
340
|
}
|
|
259
341
|
})
|
|
260
342
|
|
|
343
|
+
program
|
|
344
|
+
.command('snapshot [path]')
|
|
345
|
+
.description('Record a score snapshot to drift-history.json')
|
|
346
|
+
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
347
|
+
.option('--history', 'show all recorded snapshots')
|
|
348
|
+
.option('--diff', 'compare current score vs last snapshot')
|
|
349
|
+
.action(async (
|
|
350
|
+
targetPath: string | undefined,
|
|
351
|
+
opts: { label?: string; history?: boolean; diff?: boolean },
|
|
352
|
+
) => {
|
|
353
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
354
|
+
|
|
355
|
+
if (opts.history) {
|
|
356
|
+
const history = loadHistory(resolvedPath)
|
|
357
|
+
printHistory(history)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
362
|
+
const config = await loadConfig(resolvedPath)
|
|
363
|
+
const files = analyzeProject(resolvedPath, config)
|
|
364
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
365
|
+
const report = buildReport(resolvedPath, files)
|
|
366
|
+
|
|
367
|
+
if (opts.diff) {
|
|
368
|
+
const history = loadHistory(resolvedPath)
|
|
369
|
+
printSnapshotDiff(history, report.totalScore)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const entry = saveSnapshot(resolvedPath, report, opts.label)
|
|
374
|
+
const labelStr = entry.label ? ` [${entry.label}]` : ''
|
|
375
|
+
process.stdout.write(
|
|
376
|
+
` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
|
|
377
|
+
)
|
|
378
|
+
process.stdout.write(` Saved to drift-history.json\n\n`)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const cloud = program
|
|
382
|
+
.command('cloud')
|
|
383
|
+
.description('Local SaaS foundations: ingest, summary, and dashboard')
|
|
384
|
+
|
|
385
|
+
cloud
|
|
386
|
+
.command('ingest [path]')
|
|
387
|
+
.description('Scan path, build report, and store cloud snapshot')
|
|
388
|
+
.requiredOption('--workspace <id>', 'Workspace id')
|
|
389
|
+
.requiredOption('--user <id>', 'User id')
|
|
390
|
+
.option('--repo <name>', 'Repo name (default: basename of scanned path)')
|
|
391
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
392
|
+
.action(async (targetPath: string | undefined, options: { workspace: string; user: string; repo?: string; store?: string }) => {
|
|
393
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
394
|
+
process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
|
|
395
|
+
const config = await loadConfig(resolvedPath)
|
|
396
|
+
const files = analyzeProject(resolvedPath, config)
|
|
397
|
+
const report = buildReport(resolvedPath, files)
|
|
398
|
+
|
|
399
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
400
|
+
workspaceId: options.workspace,
|
|
401
|
+
userId: options.user,
|
|
402
|
+
repoName: options.repo ?? basename(resolvedPath),
|
|
403
|
+
storeFile: options.store,
|
|
404
|
+
policy: config?.saas,
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
|
|
408
|
+
process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
|
|
409
|
+
process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
cloud
|
|
413
|
+
.command('summary')
|
|
414
|
+
.description('Show SaaS usage metrics and free threshold status')
|
|
415
|
+
.option('--json', 'Output raw JSON summary')
|
|
416
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
417
|
+
.action((options: { json?: boolean; store?: string }) => {
|
|
418
|
+
const summary = getSaasSummary({ storeFile: options.store })
|
|
419
|
+
|
|
420
|
+
if (options.json) {
|
|
421
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
process.stdout.write('\n')
|
|
426
|
+
process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
|
|
427
|
+
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
|
|
428
|
+
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
|
|
429
|
+
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
|
|
430
|
+
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
|
|
431
|
+
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
|
|
432
|
+
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
|
|
433
|
+
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
|
|
434
|
+
process.stdout.write('Runs per month:\n')
|
|
435
|
+
|
|
436
|
+
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
|
|
437
|
+
if (monthly.length === 0) {
|
|
438
|
+
process.stdout.write(' - none\n\n')
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const [month, runs] of monthly) {
|
|
443
|
+
process.stdout.write(` - ${month}: ${runs}\n`)
|
|
444
|
+
}
|
|
445
|
+
process.stdout.write('\n')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
cloud
|
|
449
|
+
.command('dashboard')
|
|
450
|
+
.description('Generate an HTML dashboard with trends and hotspots')
|
|
451
|
+
.option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
|
|
452
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
453
|
+
.action((options: { output: string; store?: string }) => {
|
|
454
|
+
const html = generateSaasDashboardHtml({ storeFile: options.store })
|
|
455
|
+
const outPath = resolve(options.output)
|
|
456
|
+
writeFileSync(outPath, html, 'utf8')
|
|
457
|
+
process.stdout.write(`Dashboard saved to ${outPath}\n`)
|
|
458
|
+
})
|
|
459
|
+
|
|
261
460
|
program.parse()
|
package/src/diff.ts
CHANGED
|
@@ -7,6 +7,40 @@ import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
|
|
|
7
7
|
* A "new" issue exists in `current` but not in `base`.
|
|
8
8
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
9
|
*/
|
|
10
|
+
function computeFileDiff(
|
|
11
|
+
filePath: string,
|
|
12
|
+
baseFile: { score: number; issues: DriftIssue[] } | undefined,
|
|
13
|
+
currentFile: { score: number; issues: DriftIssue[] } | undefined,
|
|
14
|
+
): FileDiff | null {
|
|
15
|
+
const scoreBefore = baseFile?.score ?? 0
|
|
16
|
+
const scoreAfter = currentFile?.score ?? 0
|
|
17
|
+
const scoreDelta = scoreAfter - scoreBefore
|
|
18
|
+
|
|
19
|
+
const baseIssues = baseFile?.issues ?? []
|
|
20
|
+
const currentIssues = currentFile?.issues ?? []
|
|
21
|
+
|
|
22
|
+
const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
23
|
+
|
|
24
|
+
const baseKeys = new Set(baseIssues.map(issueKey))
|
|
25
|
+
const currentKeys = new Set(currentIssues.map(issueKey))
|
|
26
|
+
|
|
27
|
+
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
|
|
28
|
+
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
|
|
29
|
+
|
|
30
|
+
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
31
|
+
return {
|
|
32
|
+
path: filePath,
|
|
33
|
+
scoreBefore,
|
|
34
|
+
scoreAfter,
|
|
35
|
+
scoreDelta,
|
|
36
|
+
newIssues,
|
|
37
|
+
resolvedIssues,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
export function computeDiff(
|
|
11
45
|
base: DriftReport,
|
|
12
46
|
current: DriftReport,
|
|
@@ -14,11 +48,9 @@ export function computeDiff(
|
|
|
14
48
|
): DriftDiff {
|
|
15
49
|
const fileDiffs: FileDiff[] = []
|
|
16
50
|
|
|
17
|
-
// Build a map of base files by path for O(1) lookup
|
|
18
51
|
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
19
52
|
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
20
53
|
|
|
21
|
-
// All unique paths across both reports
|
|
22
54
|
const allPaths = new Set([
|
|
23
55
|
...base.files.map(f => f.path),
|
|
24
56
|
...current.files.map(f => f.path),
|
|
@@ -28,36 +60,10 @@ export function computeDiff(
|
|
|
28
60
|
const baseFile = baseByPath.get(filePath)
|
|
29
61
|
const currentFile = currentByPath.get(filePath)
|
|
30
62
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const scoreDelta = scoreAfter - scoreBefore
|
|
34
|
-
|
|
35
|
-
const baseIssues = baseFile?.issues ?? []
|
|
36
|
-
const currentIssues = currentFile?.issues ?? []
|
|
37
|
-
|
|
38
|
-
// Issue identity key: rule + line + column
|
|
39
|
-
const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
-
|
|
41
|
-
const baseKeys = new Set(baseIssues.map(issueKey))
|
|
42
|
-
const currentKeys = new Set(currentIssues.map(issueKey))
|
|
43
|
-
|
|
44
|
-
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
|
|
45
|
-
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
|
|
46
|
-
|
|
47
|
-
// Only include files that have actual changes
|
|
48
|
-
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
49
|
-
fileDiffs.push({
|
|
50
|
-
path: filePath,
|
|
51
|
-
scoreBefore,
|
|
52
|
-
scoreAfter,
|
|
53
|
-
scoreDelta,
|
|
54
|
-
newIssues,
|
|
55
|
-
resolvedIssues,
|
|
56
|
-
})
|
|
57
|
-
}
|
|
63
|
+
const diff = computeFileDiff(filePath, baseFile, currentFile)
|
|
64
|
+
if (diff) fileDiffs.push(diff)
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
// Sort: most regressed first, then most improved last
|
|
61
67
|
fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta)
|
|
62
68
|
|
|
63
69
|
return {
|
package/src/fix.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { analyzeProject, analyzeFile } from './analyzer.js'
|
|
4
|
-
import type { DriftIssue, DriftConfig } from './types.js'
|
|
4
|
+
import type { DriftIssue, DriftConfig, FileReport } from './types.js'
|
|
5
5
|
import { Project } from 'ts-morph'
|
|
6
6
|
|
|
7
7
|
export interface FixResult {
|
|
@@ -10,6 +10,8 @@ export interface FixResult {
|
|
|
10
10
|
line: number
|
|
11
11
|
description: string
|
|
12
12
|
applied: boolean
|
|
13
|
+
before?: string
|
|
14
|
+
after?: string
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
const FIXABLE_RULES = new Set(['debug-leftover', 'catch-swallow'])
|
|
@@ -73,15 +75,84 @@ function applyFixToLines(
|
|
|
73
75
|
return null
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function collectFixableIssues(
|
|
79
|
+
fileReports: FileReport[],
|
|
80
|
+
options?: { rule?: string }
|
|
81
|
+
): Map<string, DriftIssue[]> {
|
|
82
|
+
const fixableByFile = new Map<string, DriftIssue[]>()
|
|
83
|
+
|
|
84
|
+
for (const report of fileReports) {
|
|
85
|
+
const fixableIssues = report.issues.filter(issue => {
|
|
86
|
+
if (!isFixable(issue)) return false
|
|
87
|
+
if (options?.rule && issue.rule !== options.rule) return false
|
|
88
|
+
return true
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (fixableIssues.length > 0) {
|
|
92
|
+
fixableByFile.set(report.path, fixableIssues)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return fixableByFile
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function processFile(
|
|
100
|
+
filePath: string,
|
|
101
|
+
issues: DriftIssue[],
|
|
102
|
+
dryRun: boolean
|
|
103
|
+
): FixResult[] {
|
|
104
|
+
const content = readFileSync(filePath, 'utf8')
|
|
105
|
+
let lines = content.split('\n')
|
|
106
|
+
const results: FixResult[] = []
|
|
107
|
+
|
|
108
|
+
const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
|
|
109
|
+
|
|
110
|
+
for (const issue of sortedIssues) {
|
|
111
|
+
const before = lines[issue.line - 1]?.trim() ?? ''
|
|
112
|
+
const fixResult = applyFixToLines(lines, issue)
|
|
113
|
+
|
|
114
|
+
if (fixResult) {
|
|
115
|
+
const after = fixResult.newLines[issue.line - 1]?.trim() ?? ''
|
|
116
|
+
results.push({
|
|
117
|
+
file: filePath,
|
|
118
|
+
rule: issue.rule,
|
|
119
|
+
line: issue.line,
|
|
120
|
+
description: fixResult.description,
|
|
121
|
+
applied: true,
|
|
122
|
+
before,
|
|
123
|
+
after,
|
|
124
|
+
})
|
|
125
|
+
lines = fixResult.newLines
|
|
126
|
+
} else {
|
|
127
|
+
results.push({
|
|
128
|
+
file: filePath,
|
|
129
|
+
rule: issue.rule,
|
|
130
|
+
line: issue.line,
|
|
131
|
+
description: 'no fix available',
|
|
132
|
+
applied: false,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!dryRun) {
|
|
138
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return results
|
|
142
|
+
}
|
|
143
|
+
|
|
76
144
|
export async function applyFixes(
|
|
77
145
|
targetPath: string,
|
|
78
146
|
config?: DriftConfig,
|
|
79
|
-
options?: { rule?: string; dryRun?: boolean }
|
|
147
|
+
options?: { rule?: string; dryRun?: boolean; write?: boolean; preview?: boolean }
|
|
80
148
|
): Promise<FixResult[]> {
|
|
81
149
|
const resolvedPath = resolve(targetPath)
|
|
82
|
-
const dryRun = options?.
|
|
150
|
+
const dryRun = options?.write
|
|
151
|
+
? false
|
|
152
|
+
: options?.preview || options?.dryRun
|
|
153
|
+
? true
|
|
154
|
+
: false
|
|
83
155
|
|
|
84
|
-
// Determine if target is a file or directory
|
|
85
156
|
let fileReports
|
|
86
157
|
const stat = statSync(resolvedPath)
|
|
87
158
|
|
|
@@ -96,58 +167,11 @@ export async function applyFixes(
|
|
|
96
167
|
fileReports = analyzeProject(resolvedPath, config)
|
|
97
168
|
}
|
|
98
169
|
|
|
99
|
-
|
|
100
|
-
const fixableByFile = new Map<string, DriftIssue[]>()
|
|
101
|
-
|
|
102
|
-
for (const report of fileReports) {
|
|
103
|
-
const fixableIssues = report.issues.filter(issue => {
|
|
104
|
-
if (!isFixable(issue)) return false
|
|
105
|
-
if (options?.rule && issue.rule !== options.rule) return false
|
|
106
|
-
return true
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
if (fixableIssues.length > 0) {
|
|
110
|
-
fixableByFile.set(report.path, fixableIssues)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
170
|
+
const fixableByFile = collectFixableIssues(fileReports, options)
|
|
114
171
|
const results: FixResult[] = []
|
|
115
172
|
|
|
116
173
|
for (const [filePath, issues] of fixableByFile) {
|
|
117
|
-
|
|
118
|
-
let lines = content.split('\n')
|
|
119
|
-
|
|
120
|
-
// Sort issues by line descending to avoid line number drift after fixes
|
|
121
|
-
const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
|
|
122
|
-
|
|
123
|
-
// Track line offset caused by deletions (debug-leftover removes lines)
|
|
124
|
-
// We process top-to-bottom after sorting descending, so no offset needed per issue
|
|
125
|
-
for (const issue of sortedIssues) {
|
|
126
|
-
const fixResult = applyFixToLines(lines, issue)
|
|
127
|
-
|
|
128
|
-
if (fixResult) {
|
|
129
|
-
results.push({
|
|
130
|
-
file: filePath,
|
|
131
|
-
rule: issue.rule,
|
|
132
|
-
line: issue.line,
|
|
133
|
-
description: fixResult.description,
|
|
134
|
-
applied: true,
|
|
135
|
-
})
|
|
136
|
-
lines = fixResult.newLines
|
|
137
|
-
} else {
|
|
138
|
-
results.push({
|
|
139
|
-
file: filePath,
|
|
140
|
-
rule: issue.rule,
|
|
141
|
-
line: issue.line,
|
|
142
|
-
description: 'no fix available',
|
|
143
|
-
applied: false,
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (!dryRun) {
|
|
149
|
-
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
150
|
-
}
|
|
174
|
+
results.push(...processFile(filePath, issues, dryRun))
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
return results
|
package/src/git/trend.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
3
|
+
import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport } from '../types.js'
|
|
3
4
|
import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js'
|
|
4
5
|
import { buildReport } from '../reporter.js'
|
|
5
6
|
|
package/src/git.ts
CHANGED
|
@@ -13,22 +13,23 @@ import { randomUUID } from 'node:crypto'
|
|
|
13
13
|
*
|
|
14
14
|
* Throws if the directory is not a git repo or the ref is invalid.
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
|
-
// Verify git repo
|
|
16
|
+
function verifyGitRepo(projectPath: string): void {
|
|
18
17
|
try {
|
|
19
18
|
execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
|
|
20
19
|
} catch {
|
|
21
20
|
throw new Error(`Not a git repository: ${projectPath}`)
|
|
22
21
|
}
|
|
22
|
+
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function verifyRefExists(projectPath: string, ref: string): void {
|
|
25
25
|
try {
|
|
26
26
|
execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
|
|
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
|
|
|
31
|
-
|
|
32
|
+
function listTsFilesAtRef(projectPath: string, ref: string): string[] {
|
|
32
33
|
let fileList: string
|
|
33
34
|
try {
|
|
34
35
|
fileList = execSync(
|
|
@@ -39,36 +40,44 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
|
39
40
|
throw new Error(`Failed to list files at ref '${ref}'`)
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
return fileList
|
|
43
44
|
.split('\n')
|
|
44
45
|
.map(f => f.trim())
|
|
45
46
|
.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractFile(projectPath: string, ref: string, filePath: string, tempDir: string): void {
|
|
50
|
+
let content: string
|
|
51
|
+
try {
|
|
52
|
+
content = execSync(
|
|
53
|
+
`git show ${ref}:${filePath}`,
|
|
54
|
+
{ cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
|
|
55
|
+
)
|
|
56
|
+
} catch {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const destPath = join(tempDir, filePath.split('/').join(sep))
|
|
61
|
+
const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
|
|
62
|
+
mkdirSync(destDir, { recursive: true })
|
|
63
|
+
writeFileSync(destPath, content, 'utf-8')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
67
|
+
verifyGitRepo(projectPath)
|
|
68
|
+
verifyRefExists(projectPath, ref)
|
|
69
|
+
|
|
70
|
+
const tsFiles = listTsFilesAtRef(projectPath, ref)
|
|
46
71
|
|
|
47
72
|
if (tsFiles.length === 0) {
|
|
48
73
|
throw new Error(`No TypeScript files found at ref '${ref}'`)
|
|
49
74
|
}
|
|
50
75
|
|
|
51
|
-
// Create temp directory
|
|
52
76
|
const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
|
|
53
77
|
mkdirSync(tempDir, { recursive: true })
|
|
54
78
|
|
|
55
|
-
// Extract each file
|
|
56
79
|
for (const filePath of tsFiles) {
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
content = execSync(
|
|
60
|
-
`git show ${ref}:${filePath}`,
|
|
61
|
-
{ cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
|
|
62
|
-
)
|
|
63
|
-
} catch {
|
|
64
|
-
// File may not exist at this ref — skip
|
|
65
|
-
continue
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const destPath = join(tempDir, filePath.split('/').join(sep))
|
|
69
|
-
const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
|
|
70
|
-
mkdirSync(destDir, { recursive: true })
|
|
71
|
-
writeFileSync(destPath, content, 'utf-8')
|
|
80
|
+
extractFile(projectPath, ref, filePath, tempDir)
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
return tempDir
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
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 {
|
|
7
|
+
DriftReport,
|
|
8
|
+
FileReport,
|
|
9
|
+
DriftIssue,
|
|
10
|
+
DriftDiff,
|
|
11
|
+
FileDiff,
|
|
12
|
+
DriftConfig,
|
|
13
|
+
RepoQualityScore,
|
|
14
|
+
MaintenanceRiskMetrics,
|
|
15
|
+
DriftPlugin,
|
|
16
|
+
DriftPluginRule,
|
|
17
|
+
} from './types.js'
|
|
18
|
+
export { loadHistory, saveSnapshot } from './snapshot.js'
|
|
19
|
+
export type { SnapshotEntry, SnapshotHistory } from './snapshot.js'
|
|
20
|
+
export {
|
|
21
|
+
DEFAULT_SAAS_POLICY,
|
|
22
|
+
defaultSaasStorePath,
|
|
23
|
+
resolveSaasPolicy,
|
|
24
|
+
ingestSnapshotFromReport,
|
|
25
|
+
getSaasSummary,
|
|
26
|
+
generateSaasDashboardHtml,
|
|
27
|
+
} from './saas.js'
|
|
28
|
+
export type {
|
|
29
|
+
SaasPolicy,
|
|
30
|
+
SaasStore,
|
|
31
|
+
SaasSummary,
|
|
32
|
+
SaasSnapshot,
|
|
33
|
+
IngestOptions,
|
|
34
|
+
} from './saas.js'
|