@eduardbar/drift 0.3.0 → 0.5.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/src/cli.ts CHANGED
@@ -4,7 +4,10 @@ import { writeFileSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
5
5
  import { analyzeProject } from './analyzer.js'
6
6
  import { buildReport, formatMarkdown, formatAIOutput } from './reporter.js'
7
- import { printConsole } from './printer.js'
7
+ import { printConsole, printDiff } from './printer.js'
8
+ import { loadConfig } from './config.js'
9
+ import { extractFilesAtRef, cleanupTempDir } from './git.js'
10
+ import { computeDiff } from './diff.js'
8
11
 
9
12
  const program = new Command()
10
13
 
@@ -21,11 +24,12 @@ program
21
24
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
22
25
  .option('--fix', 'Show fix suggestions for each issue')
23
26
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
24
- .action((targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string }) => {
27
+ .action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string }) => {
25
28
  const resolvedPath = resolve(targetPath ?? '.')
26
29
 
27
30
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
28
- const files = analyzeProject(resolvedPath)
31
+ const config = await loadConfig(resolvedPath)
32
+ const files = analyzeProject(resolvedPath, config)
29
33
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
30
34
  const report = buildReport(resolvedPath, files)
31
35
 
@@ -56,4 +60,53 @@ program
56
60
  }
57
61
  })
58
62
 
63
+ program
64
+ .command('diff [ref]')
65
+ .description('Compare current state against a git ref (default: HEAD~1)')
66
+ .option('--json', 'Output raw JSON diff')
67
+ .action(async (ref: string | undefined, options: { json?: boolean }) => {
68
+ const baseRef = ref ?? 'HEAD~1'
69
+ const projectPath = resolve('.')
70
+
71
+ let tempDir: string | undefined
72
+
73
+ try {
74
+ process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`)
75
+
76
+ // Scan current state
77
+ const config = await loadConfig(projectPath)
78
+ const currentFiles = analyzeProject(projectPath, config)
79
+ const currentReport = buildReport(projectPath, currentFiles)
80
+
81
+ // Extract base state from git
82
+ tempDir = extractFilesAtRef(projectPath, baseRef)
83
+ const baseFiles = analyzeProject(tempDir, config)
84
+
85
+ // Remap base file paths to match current project paths
86
+ // (temp dir paths → project paths for accurate comparison)
87
+ const baseReport = buildReport(tempDir, baseFiles)
88
+ const remappedBase = {
89
+ ...baseReport,
90
+ files: baseReport.files.map(f => ({
91
+ ...f,
92
+ path: f.path.replace(tempDir!, projectPath),
93
+ })),
94
+ }
95
+
96
+ const diff = computeDiff(remappedBase, currentReport, baseRef)
97
+
98
+ if (options.json) {
99
+ process.stdout.write(JSON.stringify(diff, null, 2) + '\n')
100
+ } else {
101
+ printDiff(diff)
102
+ }
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err)
105
+ process.stderr.write(`\n Error: ${message}\n\n`)
106
+ process.exit(1)
107
+ } finally {
108
+ if (tempDir) cleanupTempDir(tempDir)
109
+ }
110
+ })
111
+
59
112
  program.parse()
package/src/config.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { DriftConfig } from './types.js'
5
+
6
+ /**
7
+ * Load drift.config.ts / .js / .json from the given project root.
8
+ * Returns undefined if no config file is found.
9
+ *
10
+ * Search order (first match wins):
11
+ * 1. drift.config.ts
12
+ * 2. drift.config.js
13
+ * 3. drift.config.json
14
+ */
15
+ export async function loadConfig(projectRoot: string): Promise<DriftConfig | undefined> {
16
+ const candidates = [
17
+ join(projectRoot, 'drift.config.ts'),
18
+ join(projectRoot, 'drift.config.js'),
19
+ join(projectRoot, 'drift.config.json'),
20
+ ]
21
+
22
+ for (const candidate of candidates) {
23
+ if (!existsSync(candidate)) continue
24
+
25
+ try {
26
+ const ext = candidate.split('.').pop()
27
+
28
+ if (ext === 'json') {
29
+ const { readFileSync } = await import('node:fs')
30
+ return JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
31
+ }
32
+
33
+ // .ts / .js — dynamic import via file URL
34
+ const fileUrl = pathToFileURL(resolve(candidate)).href
35
+ const mod = await import(fileUrl)
36
+ const config: DriftConfig = mod.default ?? mod
37
+
38
+ return config
39
+ } catch {
40
+ // drift-ignore: catch-swallow — config is optional; load failure is non-fatal
41
+ }
42
+ }
43
+
44
+ return undefined
45
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
2
+
3
+ /**
4
+ * Compute the diff between two DriftReports.
5
+ *
6
+ * Issues are matched by (rule + line + column) as a unique key within a file.
7
+ * A "new" issue exists in `current` but not in `base`.
8
+ * A "resolved" issue exists in `base` but not in `current`.
9
+ */
10
+ export function computeDiff(
11
+ base: DriftReport,
12
+ current: DriftReport,
13
+ baseRef: string,
14
+ ): DriftDiff {
15
+ const fileDiffs: FileDiff[] = []
16
+
17
+ // Build a map of base files by path for O(1) lookup
18
+ const baseByPath = new Map(base.files.map(f => [f.path, f]))
19
+ const currentByPath = new Map(current.files.map(f => [f.path, f]))
20
+
21
+ // All unique paths across both reports
22
+ const allPaths = new Set([
23
+ ...base.files.map(f => f.path),
24
+ ...current.files.map(f => f.path),
25
+ ])
26
+
27
+ for (const filePath of allPaths) {
28
+ const baseFile = baseByPath.get(filePath)
29
+ const currentFile = currentByPath.get(filePath)
30
+
31
+ const scoreBefore = baseFile?.score ?? 0
32
+ const scoreAfter = currentFile?.score ?? 0
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
+ }
58
+ }
59
+
60
+ // Sort: most regressed first, then most improved last
61
+ fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta)
62
+
63
+ return {
64
+ baseRef,
65
+ projectPath: current.targetPath,
66
+ scannedAt: new Date().toISOString(),
67
+ files: fileDiffs,
68
+ totalScoreBefore: base.totalScore,
69
+ totalScoreAfter: current.totalScore,
70
+ totalDelta: current.totalScore - base.totalScore,
71
+ newIssuesCount: fileDiffs.reduce((sum, f) => sum + f.newIssues.length, 0),
72
+ resolvedIssuesCount: fileDiffs.reduce((sum, f) => sum + f.resolvedIssues.length, 0),
73
+ }
74
+ }
package/src/git.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
3
+ import { join, sep } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ /**
8
+ * Extract all TypeScript files from the project at a given git ref into a
9
+ * temporary directory. Returns the temp directory path.
10
+ *
11
+ * Uses `git ls-tree` to list files and `git show <ref>:<path>` to read each
12
+ * file — no checkout, no stash, no repo state mutation.
13
+ *
14
+ * Throws if the directory is not a git repo or the ref is invalid.
15
+ */
16
+ export function extractFilesAtRef(projectPath: string, ref: string): string {
17
+ // Verify git repo
18
+ try {
19
+ execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
20
+ } catch {
21
+ throw new Error(`Not a git repository: ${projectPath}`)
22
+ }
23
+
24
+ // Verify ref exists
25
+ try {
26
+ execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
27
+ } catch {
28
+ throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`)
29
+ }
30
+
31
+ // List all .ts files tracked at this ref (excluding .d.ts)
32
+ let fileList: string
33
+ try {
34
+ fileList = execSync(
35
+ `git ls-tree -r --name-only ${ref}`,
36
+ { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
37
+ )
38
+ } catch {
39
+ throw new Error(`Failed to list files at ref '${ref}'`)
40
+ }
41
+
42
+ const tsFiles = fileList
43
+ .split('\n')
44
+ .map(f => f.trim())
45
+ .filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'))
46
+
47
+ if (tsFiles.length === 0) {
48
+ throw new Error(`No TypeScript files found at ref '${ref}'`)
49
+ }
50
+
51
+ // Create temp directory
52
+ const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
53
+ mkdirSync(tempDir, { recursive: true })
54
+
55
+ // Extract each file
56
+ for (const filePath of tsFiles) {
57
+ let content: string
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')
72
+ }
73
+
74
+ return tempDir
75
+ }
76
+
77
+ /**
78
+ * Clean up a temporary directory created by extractFilesAtRef.
79
+ */
80
+ export function cleanupTempDir(tempDir: string): void {
81
+ if (existsSync(tempDir)) {
82
+ rmSync(tempDir, { recursive: true, force: true })
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the short hash of a git ref for display purposes.
88
+ */
89
+ export function resolveRefHash(projectPath: string, ref: string): string {
90
+ try {
91
+ return execSync(
92
+ `git rev-parse --short ${ref}`,
93
+ { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
94
+ ).trim()
95
+ } catch {
96
+ return ref
97
+ }
98
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { analyzeProject, analyzeFile } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
- export type { DriftReport, FileReport, DriftIssue } from './types.js'
3
+ export { computeDiff } from './diff.js'
4
+ export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js'
package/src/printer.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // drift-ignore-file
2
2
  import kleur from 'kleur'
3
- import type { DriftIssue, DriftReport } from './types.js'
3
+ import type { DriftIssue, DriftReport, DriftDiff } from './types.js'
4
4
  import { scoreToGrade, severityIcon, scoreBar } from './utils.js'
5
5
 
6
6
  function formatFixSuggestion(issue: DriftIssue): string[] {
@@ -35,6 +35,84 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
35
35
  'Consolidate with existing function',
36
36
  'Or rename to clarify different behavior',
37
37
  ],
38
+ 'high-complexity': [
39
+ 'Extract each branch into a named function',
40
+ 'Use early returns to reduce nesting and branching',
41
+ 'Consider a strategy pattern or lookup table for switch-heavy logic',
42
+ ],
43
+ 'deep-nesting': [
44
+ 'Invert conditions and return early instead of nesting',
45
+ 'Extract inner blocks into separate functions',
46
+ 'Flatten promise chains with async/await',
47
+ ],
48
+ 'too-many-params': [
49
+ 'Group related params into an options object: foo({ a, b, c, d, e })',
50
+ 'Consider if this function is doing too many things',
51
+ ],
52
+ 'high-coupling': [
53
+ 'Group related imports into a single module',
54
+ 'Consider if this file has too many responsibilities',
55
+ 'Extract a sub-module that encapsulates some of these dependencies',
56
+ ],
57
+ 'promise-style-mix': [
58
+ 'Pick one style and use it consistently: async/await is preferred',
59
+ 'Convert .then()/.catch() chains to async/await',
60
+ ],
61
+ 'magic-number': [
62
+ 'Extract to a named constant: const MAX_RETRIES = 3',
63
+ 'Use an enum for related numeric values',
64
+ ],
65
+ 'comment-contradiction': [
66
+ 'Remove the comment — the code already says what it does',
67
+ 'Replace with a comment explaining WHY, not what: // retry because upstream is flaky',
68
+ ],
69
+ 'unused-export': [
70
+ "Remove the export keyword if it's only used internally",
71
+ 'Or delete the declaration entirely if it serves no purpose',
72
+ ],
73
+ 'dead-file': [
74
+ 'Delete the file if it is no longer needed',
75
+ 'Or import it from an entry point if it should be active',
76
+ ],
77
+ 'unused-dependency': [
78
+ 'Remove it from package.json: npm uninstall <pkg>',
79
+ 'Or verify it is used transitively and document why it is kept',
80
+ ],
81
+ 'circular-dependency': [
82
+ 'Introduce an abstraction (interface or shared module) that both files depend on',
83
+ 'Move shared logic to a third file that neither of the cyclic modules imports',
84
+ 'Use dependency injection to break the compile-time dependency',
85
+ ],
86
+ 'layer-violation': [
87
+ 'Move the import to a layer that is allowed to access this dependency',
88
+ 'Introduce a port/interface in the domain layer to invert the dependency (Dependency Inversion Principle)',
89
+ 'Or adjust the layer rules in drift.config.ts if this import is intentional',
90
+ ],
91
+ 'cross-boundary-import': [
92
+ "Import from the module's public API barrel (index.ts) instead of internal paths",
93
+ 'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
94
+ 'Consider using dependency injection or an event bus to decouple the modules',
95
+ ],
96
+ 'over-commented': [
97
+ 'Remove comments that restate what the code already expresses clearly',
98
+ 'Keep only comments that explain WHY, not WHAT — prefer self-documenting names',
99
+ ],
100
+ 'hardcoded-config': [
101
+ 'Move the value to an environment variable: process.env.YOUR_VAR',
102
+ 'Or extract it to a config file / constants module imported at the top',
103
+ ],
104
+ 'inconsistent-error-handling': [
105
+ 'Pick one style (async/await + try/catch is preferred) and apply it consistently',
106
+ 'Avoid mixing .then()/.catch() with await in the same file',
107
+ ],
108
+ 'unnecessary-abstraction': [
109
+ 'Inline the abstraction if it has only one implementation and is never reused',
110
+ 'Or document why the extension point exists (e.g., future plugin system)',
111
+ ],
112
+ 'naming-inconsistency': [
113
+ 'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
114
+ 'Rename snake_case identifiers to camelCase to match TypeScript conventions',
115
+ ],
38
116
  }
39
117
  return suggestions[issue.rule] ?? ['Review and fix manually']
40
118
  }
@@ -127,3 +205,70 @@ export function printConsole(report: DriftReport, options?: { showFix?: boolean
127
205
  console.log()
128
206
  }
129
207
  }
208
+
209
+ export function printDiff(diff: DriftDiff): void {
210
+ const { totalDelta, totalScoreBefore, totalScoreAfter, newIssuesCount, resolvedIssuesCount } = diff
211
+
212
+ const deltaSign = totalDelta > 0 ? '+' : ''
213
+ const deltaColor = totalDelta > 0 ? kleur.red : totalDelta < 0 ? kleur.green : kleur.white
214
+ const baseGrade = scoreToGrade(totalScoreBefore)
215
+ const headGrade = scoreToGrade(totalScoreAfter)
216
+
217
+ console.log()
218
+ console.log(kleur.bold(' drift diff') + kleur.gray(` — comparing HEAD vs ${diff.baseRef}`))
219
+ console.log(' ' + '─'.repeat(50))
220
+ console.log()
221
+ console.log(
222
+ ` Score ${kleur.bold(String(totalScoreBefore))} ${baseGrade.badge} → ` +
223
+ `${kleur.bold(String(totalScoreAfter))} ${headGrade.badge} ` +
224
+ deltaColor(`(${deltaSign}${totalDelta})`)
225
+ )
226
+ console.log()
227
+
228
+ if (newIssuesCount > 0) {
229
+ console.log(` ${kleur.red(`▲ ${newIssuesCount} new issue${newIssuesCount !== 1 ? 's' : ''} introduced`)}`)
230
+ }
231
+ if (resolvedIssuesCount > 0) {
232
+ console.log(` ${kleur.green(`▼ ${resolvedIssuesCount} issue${resolvedIssuesCount !== 1 ? 's' : ''} resolved`)}`)
233
+ }
234
+ if (newIssuesCount === 0 && resolvedIssuesCount === 0) {
235
+ console.log(` ${kleur.gray('No issue changes detected')}`)
236
+ }
237
+
238
+ if (diff.files.length === 0) {
239
+ console.log()
240
+ console.log(` ${kleur.gray('No file-level changes detected')}`)
241
+ console.log()
242
+ return
243
+ }
244
+
245
+ console.log()
246
+ console.log(' ' + '─'.repeat(50))
247
+ console.log()
248
+
249
+ for (const file of diff.files) {
250
+ const rel = file.path.replace(/\\/g, '/').split('/').pop() ?? file.path
251
+ const fileDeltaSign = file.scoreDelta > 0 ? '+' : ''
252
+ const fileDeltaColor = file.scoreDelta > 0 ? kleur.red : kleur.green
253
+
254
+ console.log(
255
+ ` ${kleur.bold(rel)}` +
256
+ ` ${kleur.gray(`${file.scoreBefore} → ${file.scoreAfter}`)}` +
257
+ ` ${fileDeltaColor(`${fileDeltaSign}${file.scoreDelta}`)}`
258
+ )
259
+
260
+ for (const issue of file.newIssues) {
261
+ console.log(
262
+ ` ${kleur.red('+')} ${severityIcon(issue.severity)} ` +
263
+ `${kleur.yellow(issue.rule)} ${kleur.gray(`L${issue.line}`)} ${issue.message}`
264
+ )
265
+ }
266
+ for (const issue of file.resolvedIssues) {
267
+ console.log(
268
+ ` ${kleur.green('-')} ${severityIcon(issue.severity)} ` +
269
+ `${kleur.yellow(issue.rule)} ${kleur.gray(`L${issue.line}`)} ${issue.message}`
270
+ )
271
+ }
272
+ console.log()
273
+ }
274
+ }
package/src/types.ts CHANGED
@@ -56,3 +56,59 @@ export interface AIIssue {
56
56
  fix_suggestion: string
57
57
  effort: 'low' | 'medium' | 'high'
58
58
  }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Configuration
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Layer definition for architectural boundary enforcement.
66
+ */
67
+ export interface LayerDefinition {
68
+ name: string
69
+ patterns: string[]
70
+ canImportFrom: string[]
71
+ }
72
+
73
+ /**
74
+ * Module boundary definition for cross-boundary enforcement.
75
+ */
76
+ export interface ModuleBoundary {
77
+ name: string
78
+ root: string
79
+ allowedExternalImports?: string[]
80
+ }
81
+
82
+ /**
83
+ * Optional project-level configuration for drift.
84
+ * Place in drift.config.ts (or .js / .json) at the project root.
85
+ */
86
+ export interface DriftConfig {
87
+ layers?: LayerDefinition[]
88
+ modules?: ModuleBoundary[]
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Diff
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export interface FileDiff {
96
+ path: string // path relativo al project root
97
+ scoreBefore: number
98
+ scoreAfter: number
99
+ scoreDelta: number // positivo = empeoró (más deuda), negativo = mejoró
100
+ newIssues: DriftIssue[]
101
+ resolvedIssues: DriftIssue[]
102
+ }
103
+
104
+ export interface DriftDiff {
105
+ baseRef: string // git ref del baseline (e.g. "HEAD~1", "main")
106
+ projectPath: string // path absoluto del proyecto
107
+ scannedAt: string // ISO timestamp
108
+ files: FileDiff[] // solo archivos con cambios (delta != 0 o issues diff != 0)
109
+ totalScoreBefore: number
110
+ totalScoreAfter: number
111
+ totalDelta: number // positivo = más deuda, negativo = menos deuda
112
+ newIssuesCount: number
113
+ resolvedIssuesCount: number
114
+ }