@eduardbar/drift 0.8.0 → 0.9.1

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/analyzer.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as fs from 'node:fs'
2
2
  import * as crypto from 'node:crypto'
3
3
  import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { execSync } from 'node:child_process'
4
6
  import {
5
7
  Project,
6
8
  SourceFile,
@@ -11,7 +13,11 @@ import {
11
13
  FunctionExpression,
12
14
  MethodDeclaration,
13
15
  } from 'ts-morph'
14
- import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
16
+ import type {
17
+ DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary,
18
+ HistoricalAnalysis, TrendDataPoint, BlameAttribution, DriftTrendReport, DriftBlameReport,
19
+ } from './types.js'
20
+ import { buildReport } from './reporter.js'
15
21
 
16
22
  // Rules and their drift score weight
17
23
  export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
@@ -1449,3 +1455,541 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1449
1455
 
1450
1456
  return reports
1451
1457
  }
1458
+
1459
+ // ---------------------------------------------------------------------------
1460
+ // Git helpers
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ /** Analyse a file given its absolute path string (wraps analyzeFile). */
1464
+ function analyzeFilePath(filePath: string): FileReport {
1465
+ const proj = new Project({
1466
+ skipAddingFilesFromTsConfig: true,
1467
+ compilerOptions: { allowJs: true },
1468
+ })
1469
+ const sf = proj.addSourceFileAtPath(filePath)
1470
+ return analyzeFile(sf)
1471
+ }
1472
+
1473
+ /**
1474
+ * Execute a git command synchronously and return stdout.
1475
+ * Throws a descriptive error if the command fails or git is not available.
1476
+ */
1477
+ function execGit(cmd: string, cwd: string): string {
1478
+ try {
1479
+ return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1480
+ } catch (err) {
1481
+ const msg = err instanceof Error ? err.message : String(err)
1482
+ throw new Error(`Git command failed: ${cmd}\n${msg}`)
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * Verify the given directory is a git repository.
1488
+ * Throws if git is not available or the directory is not a repo.
1489
+ */
1490
+ function assertGitRepo(cwd: string): void {
1491
+ try {
1492
+ execGit('git rev-parse --is-inside-work-tree', cwd)
1493
+ } catch {
1494
+ throw new Error(`Directory is not a git repository: ${cwd}`)
1495
+ }
1496
+ }
1497
+
1498
+ // ---------------------------------------------------------------------------
1499
+ // Historical analysis helpers
1500
+ // ---------------------------------------------------------------------------
1501
+
1502
+ /**
1503
+ * Analyse a single file as it existed at a given commit hash.
1504
+ * Writes the blob to a temp file, runs analyzeFile, then cleans up.
1505
+ */
1506
+ async function analyzeFileAtCommit(
1507
+ filePath: string,
1508
+ commitHash: string,
1509
+ projectRoot: string,
1510
+ ): Promise<FileReport> {
1511
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, '/')
1512
+ const blob = execGit(`git show ${commitHash}:${relPath}`, projectRoot)
1513
+
1514
+ const tmpFile = path.join(os.tmpdir(), `drift-${crypto.randomBytes(8).toString('hex')}.ts`)
1515
+ try {
1516
+ fs.writeFileSync(tmpFile, blob, 'utf8')
1517
+ const report = analyzeFilePath(tmpFile)
1518
+ // Replace temp path with original for readable output
1519
+ return { ...report, path: filePath }
1520
+ } finally {
1521
+ try { fs.unlinkSync(tmpFile) } catch { /* ignore cleanup errors */ }
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Analyse ALL TypeScript files in the project snapshot at a given commit.
1527
+ * Uses `git ls-tree` to enumerate every file in the tree, writes them to a
1528
+ * temp directory, then runs `analyzeProject` on that full snapshot so that
1529
+ * the resulting `averageScore` reflects the complete project health rather
1530
+ * than only the files touched in that diff.
1531
+ */
1532
+ async function analyzeSingleCommit(
1533
+ commitHash: string,
1534
+ targetPath: string,
1535
+ config?: DriftConfig,
1536
+ ): Promise<HistoricalAnalysis> {
1537
+ // 1. Commit metadata
1538
+ const meta = execGit(
1539
+ `git show --no-patch --format="%H|%aI|%an|%s" ${commitHash}`,
1540
+ targetPath,
1541
+ )
1542
+ const [hash, dateStr, author, ...msgParts] = meta.split('|')
1543
+ const message = msgParts.join('|').trim()
1544
+ const commitDate = new Date(dateStr ?? '')
1545
+
1546
+ // 2. All .ts/.tsx files tracked at this commit (no diffs, full tree)
1547
+ const allFiles = execGit(
1548
+ `git ls-tree -r ${commitHash} --name-only`,
1549
+ targetPath,
1550
+ )
1551
+ .split('\n')
1552
+ .filter(
1553
+ f =>
1554
+ (f.endsWith('.ts') || f.endsWith('.tsx')) &&
1555
+ !f.endsWith('.d.ts') &&
1556
+ !f.includes('node_modules') &&
1557
+ !f.startsWith('dist/'),
1558
+ )
1559
+
1560
+ if (allFiles.length === 0) {
1561
+ return {
1562
+ commitHash: hash ?? commitHash,
1563
+ commitDate,
1564
+ author: author ?? '',
1565
+ message,
1566
+ files: [],
1567
+ totalScore: 0,
1568
+ averageScore: 0,
1569
+ }
1570
+ }
1571
+
1572
+ // 3. Write snapshot to temp directory
1573
+ const tmpDir = path.join(os.tmpdir(), `drift-${(hash ?? commitHash).slice(0, 8)}`)
1574
+ fs.mkdirSync(tmpDir, { recursive: true })
1575
+
1576
+ for (const relPath of allFiles) {
1577
+ try {
1578
+ const content = execGit(`git show ${commitHash}:${relPath}`, targetPath)
1579
+ const destPath = path.join(tmpDir, relPath)
1580
+ fs.mkdirSync(path.dirname(destPath), { recursive: true })
1581
+ fs.writeFileSync(destPath, content, 'utf-8')
1582
+ } catch {
1583
+ // skip files that can't be read (binary, deleted in partial clone, etc.)
1584
+ }
1585
+ }
1586
+
1587
+ // 4. Analyse the full project snapshot
1588
+ const fileReports = analyzeProject(tmpDir, config)
1589
+ const totalScore = fileReports.reduce((sum, r) => sum + r.score, 0)
1590
+ const averageScore = fileReports.length > 0 ? totalScore / fileReports.length : 0
1591
+
1592
+ // 5. Cleanup
1593
+ try {
1594
+ fs.rmSync(tmpDir, { recursive: true, force: true })
1595
+ } catch {
1596
+ // non-fatal — temp dirs are cleaned by the OS eventually
1597
+ }
1598
+
1599
+ return {
1600
+ commitHash: hash ?? commitHash,
1601
+ commitDate,
1602
+ author: author ?? '',
1603
+ message,
1604
+ files: fileReports,
1605
+ totalScore,
1606
+ averageScore,
1607
+ }
1608
+ }
1609
+
1610
+ /**
1611
+ * Run historical analysis over all commits since a given date.
1612
+ * Returns results ordered chronologically (oldest first).
1613
+ */
1614
+ async function analyzeHistoricalCommits(
1615
+ sinceDate: Date,
1616
+ targetPath: string,
1617
+ maxCommits: number,
1618
+ config?: DriftConfig,
1619
+ maxSamples: number = 10,
1620
+ ): Promise<HistoricalAnalysis[]> {
1621
+ assertGitRepo(targetPath)
1622
+
1623
+ const isoDate = sinceDate.toISOString()
1624
+ const raw = execGit(
1625
+ `git log --since="${isoDate}" --format="%H" --max-count=${maxCommits}`,
1626
+ targetPath,
1627
+ )
1628
+
1629
+ if (!raw) return []
1630
+
1631
+ const hashes = raw.split('\n').filter(Boolean)
1632
+
1633
+ // Sample: distribute evenly across the range
1634
+ // E.g. 122 commits, maxSamples=10 → pick index 0, 13, 26, 39, 52, 65, 78, 91, 104, 121
1635
+ const sampled = hashes.length <= maxSamples
1636
+ ? hashes
1637
+ : Array.from({ length: maxSamples }, (_, i) =>
1638
+ hashes[Math.floor(i * (hashes.length - 1) / (maxSamples - 1))]
1639
+ )
1640
+
1641
+ const analyses = await Promise.all(
1642
+ sampled.map(h => analyzeSingleCommit(h, targetPath, config).catch(() => null)),
1643
+ )
1644
+
1645
+ return analyses
1646
+ .filter((a): a is HistoricalAnalysis => a !== null)
1647
+ .sort((a, b) => a.commitDate.getTime() - b.commitDate.getTime())
1648
+ }
1649
+
1650
+ // ---------------------------------------------------------------------------
1651
+ // TrendAnalyzer
1652
+ // ---------------------------------------------------------------------------
1653
+
1654
+ export class TrendAnalyzer {
1655
+ private readonly projectPath: string
1656
+ private readonly config: DriftConfig | undefined
1657
+
1658
+ constructor(projectPath: string, config?: DriftConfig) {
1659
+ this.projectPath = projectPath
1660
+ this.config = config
1661
+ }
1662
+
1663
+ // --- Static utility methods -----------------------------------------------
1664
+
1665
+ static calculateMovingAverage(data: TrendDataPoint[], windowSize: number): number[] {
1666
+ return data.map((_, i) => {
1667
+ const start = Math.max(0, i - windowSize + 1)
1668
+ const window = data.slice(start, i + 1)
1669
+ return window.reduce((s, p) => s + p.score, 0) / window.length
1670
+ })
1671
+ }
1672
+
1673
+ static linearRegression(data: TrendDataPoint[]): { slope: number; intercept: number; r2: number } {
1674
+ const n = data.length
1675
+ if (n < 2) return { slope: 0, intercept: data[0]?.score ?? 0, r2: 0 }
1676
+
1677
+ const xs = data.map((_, i) => i)
1678
+ const ys = data.map(p => p.score)
1679
+
1680
+ const xMean = xs.reduce((s, x) => s + x, 0) / n
1681
+ const yMean = ys.reduce((s, y) => s + y, 0) / n
1682
+
1683
+ const ssXX = xs.reduce((s, x) => s + (x - xMean) ** 2, 0)
1684
+ const ssXY = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i]! - yMean), 0)
1685
+ const ssYY = ys.reduce((s, y) => s + (y - yMean) ** 2, 0)
1686
+
1687
+ const slope = ssXX === 0 ? 0 : ssXY / ssXX
1688
+ const intercept = yMean - slope * xMean
1689
+ const r2 = ssYY === 0 ? 1 : (ssXY ** 2) / (ssXX * ssYY)
1690
+
1691
+ return { slope, intercept, r2 }
1692
+ }
1693
+
1694
+ /** Generate a simple horizontal ASCII bar chart (one bar per data point). */
1695
+ static generateTrendChart(data: TrendDataPoint[]): string {
1696
+ if (data.length === 0) return '(no data)'
1697
+
1698
+ const maxScore = Math.max(...data.map(p => p.score), 1)
1699
+ const chartWidth = 40
1700
+
1701
+ const lines = data.map(p => {
1702
+ const barLen = Math.round((p.score / maxScore) * chartWidth)
1703
+ const bar = '█'.repeat(barLen)
1704
+ const dateStr = p.date.toISOString().slice(0, 10)
1705
+ return `${dateStr} │${bar.padEnd(chartWidth)} ${p.score.toFixed(1)}`
1706
+ })
1707
+
1708
+ return lines.join('\n')
1709
+ }
1710
+
1711
+ // --- Instance method -------------------------------------------------------
1712
+
1713
+ async analyzeTrend(options: {
1714
+ period?: 'week' | 'month' | 'quarter' | 'year'
1715
+ since?: string
1716
+ until?: string
1717
+ }): Promise<DriftTrendReport> {
1718
+ assertGitRepo(this.projectPath)
1719
+
1720
+ const periodDays: Record<string, number> = {
1721
+ week: 7, month: 30, quarter: 90, year: 365,
1722
+ }
1723
+ const days = periodDays[options.period ?? 'month'] ?? 30
1724
+ const sinceDate = options.since
1725
+ ? new Date(options.since)
1726
+ : new Date(Date.now() - days * 24 * 60 * 60 * 1000)
1727
+
1728
+ const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100, this.config, 10)
1729
+
1730
+ const trendPoints: TrendDataPoint[] = historicalAnalyses.map(h => ({
1731
+ date: h.commitDate,
1732
+ score: h.averageScore,
1733
+ fileCount: h.files.length,
1734
+ avgIssuesPerFile: h.files.length > 0
1735
+ ? h.files.reduce((s, f) => s + f.issues.length, 0) / h.files.length
1736
+ : 0,
1737
+ }))
1738
+
1739
+ const regression = TrendAnalyzer.linearRegression(trendPoints)
1740
+
1741
+ // Current state report
1742
+ const currentFiles = analyzeProject(this.projectPath, this.config)
1743
+ const baseReport = buildReport(this.projectPath, currentFiles)
1744
+
1745
+ return {
1746
+ ...baseReport,
1747
+ trend: trendPoints,
1748
+ regression,
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ // ---------------------------------------------------------------------------
1754
+ // BlameAnalyzer
1755
+ // ---------------------------------------------------------------------------
1756
+
1757
+ interface GitBlameEntry {
1758
+ hash: string
1759
+ author: string
1760
+ email: string
1761
+ line: string
1762
+ }
1763
+
1764
+ function parseGitBlame(blameOutput: string): GitBlameEntry[] {
1765
+ const entries: GitBlameEntry[] = []
1766
+ const lines = blameOutput.split('\n')
1767
+ let i = 0
1768
+
1769
+ while (i < lines.length) {
1770
+ const headerLine = lines[i]
1771
+ if (!headerLine || headerLine.trim() === '') { i++; continue }
1772
+
1773
+ // Porcelain blame format: first line is "<hash> <orig-line> <final-line> [<num-lines>]"
1774
+ const headerMatch = headerLine.match(/^([0-9a-f]{40})\s/)
1775
+ if (!headerMatch) { i++; continue }
1776
+
1777
+ const hash = headerMatch[1]!
1778
+ let author = ''
1779
+ let email = ''
1780
+ let codeLine = ''
1781
+ i++
1782
+
1783
+ while (i < lines.length && !lines[i]!.match(/^[0-9a-f]{40}\s/)) {
1784
+ const l = lines[i]!
1785
+ if (l.startsWith('author ')) author = l.slice(7).trim()
1786
+ else if (l.startsWith('author-mail ')) email = l.slice(12).replace(/[<>]/g, '').trim()
1787
+ else if (l.startsWith('\t')) codeLine = l.slice(1)
1788
+ i++
1789
+ }
1790
+
1791
+ entries.push({ hash, author, email, line: codeLine })
1792
+ }
1793
+
1794
+ return entries
1795
+ }
1796
+
1797
+ export class BlameAnalyzer {
1798
+ private readonly projectPath: string
1799
+ private readonly config: DriftConfig | undefined
1800
+
1801
+ constructor(projectPath: string, config?: DriftConfig) {
1802
+ this.projectPath = projectPath
1803
+ this.config = config
1804
+ }
1805
+
1806
+ /** Blame a single file: returns per-author attribution. */
1807
+ static async analyzeFileBlame(filePath: string): Promise<BlameAttribution[]> {
1808
+ const dir = path.dirname(filePath)
1809
+ assertGitRepo(dir)
1810
+
1811
+ const blameOutput = execGit(`git blame --porcelain "${filePath}"`, dir)
1812
+ const entries = parseGitBlame(blameOutput)
1813
+
1814
+ // Analyse issues in the file
1815
+ const report = analyzeFilePath(filePath)
1816
+
1817
+ // Map line numbers of issues to authors
1818
+ const issuesByLine = new Map<number, number>()
1819
+ for (const issue of report.issues) {
1820
+ issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
1821
+ }
1822
+
1823
+ // Aggregate by author
1824
+ const byAuthor = new Map<string, BlameAttribution>()
1825
+ entries.forEach((entry, idx) => {
1826
+ const key = entry.email || entry.author
1827
+ if (!byAuthor.has(key)) {
1828
+ byAuthor.set(key, {
1829
+ author: entry.author,
1830
+ email: entry.email,
1831
+ commits: 0,
1832
+ linesChanged: 0,
1833
+ issuesIntroduced: 0,
1834
+ avgScoreImpact: 0,
1835
+ })
1836
+ }
1837
+ const attr = byAuthor.get(key)!
1838
+ attr.linesChanged++
1839
+ const lineNum = idx + 1
1840
+ if (issuesByLine.has(lineNum)) {
1841
+ attr.issuesIntroduced += issuesByLine.get(lineNum)!
1842
+ }
1843
+ })
1844
+
1845
+ // Count unique commits per author
1846
+ const commitsByAuthor = new Map<string, Set<string>>()
1847
+ for (const entry of entries) {
1848
+ const key = entry.email || entry.author
1849
+ if (!commitsByAuthor.has(key)) commitsByAuthor.set(key, new Set())
1850
+ commitsByAuthor.get(key)!.add(entry.hash)
1851
+ }
1852
+
1853
+ const total = entries.length || 1
1854
+ const results: BlameAttribution[] = []
1855
+ for (const [key, attr] of byAuthor) {
1856
+ attr.commits = commitsByAuthor.get(key)?.size ?? 0
1857
+ attr.avgScoreImpact = (attr.linesChanged / total) * report.score
1858
+ results.push(attr)
1859
+ }
1860
+
1861
+ return results.sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1862
+ }
1863
+
1864
+ /** Blame for a specific rule across all files in targetPath. */
1865
+ static async analyzeRuleBlame(rule: string, targetPath: string): Promise<BlameAttribution[]> {
1866
+ assertGitRepo(targetPath)
1867
+
1868
+ const tsFiles = fs
1869
+ .readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
1870
+ .filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('node_modules'))
1871
+ .map(f => path.join(targetPath, f))
1872
+
1873
+ const combined = new Map<string, BlameAttribution>()
1874
+
1875
+ for (const file of tsFiles) {
1876
+ const report = analyzeFilePath(file)
1877
+ const ruleIssues = report.issues.filter(i => i.rule === rule)
1878
+ if (ruleIssues.length === 0) continue
1879
+
1880
+ let blameEntries: GitBlameEntry[] = []
1881
+ try {
1882
+ const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
1883
+ blameEntries = parseGitBlame(blameOutput)
1884
+ } catch { continue }
1885
+
1886
+ for (const issue of ruleIssues) {
1887
+ const entry = blameEntries[issue.line - 1]
1888
+ if (!entry) continue
1889
+ const key = entry.email || entry.author
1890
+ if (!combined.has(key)) {
1891
+ combined.set(key, {
1892
+ author: entry.author,
1893
+ email: entry.email,
1894
+ commits: 0,
1895
+ linesChanged: 0,
1896
+ issuesIntroduced: 0,
1897
+ avgScoreImpact: 0,
1898
+ })
1899
+ }
1900
+ const attr = combined.get(key)!
1901
+ attr.issuesIntroduced++
1902
+ attr.avgScoreImpact += RULE_WEIGHTS[rule]?.weight ?? 5
1903
+ }
1904
+ }
1905
+
1906
+ return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1907
+ }
1908
+
1909
+ /** Overall blame across all files and rules. */
1910
+ static async analyzeOverallBlame(targetPath: string): Promise<BlameAttribution[]> {
1911
+ assertGitRepo(targetPath)
1912
+
1913
+ const tsFiles = fs
1914
+ .readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
1915
+ .filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('node_modules'))
1916
+ .map(f => path.join(targetPath, f))
1917
+
1918
+ const combined = new Map<string, BlameAttribution>()
1919
+ const commitsByAuthor = new Map<string, Set<string>>()
1920
+
1921
+ for (const file of tsFiles) {
1922
+ let blameEntries: GitBlameEntry[] = []
1923
+ try {
1924
+ const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
1925
+ blameEntries = parseGitBlame(blameOutput)
1926
+ } catch { continue }
1927
+
1928
+ const report = analyzeFilePath(file)
1929
+ const issuesByLine = new Map<number, number>()
1930
+ for (const issue of report.issues) {
1931
+ issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
1932
+ }
1933
+
1934
+ blameEntries.forEach((entry, idx) => {
1935
+ const key = entry.email || entry.author
1936
+ if (!combined.has(key)) {
1937
+ combined.set(key, {
1938
+ author: entry.author,
1939
+ email: entry.email,
1940
+ commits: 0,
1941
+ linesChanged: 0,
1942
+ issuesIntroduced: 0,
1943
+ avgScoreImpact: 0,
1944
+ })
1945
+ commitsByAuthor.set(key, new Set())
1946
+ }
1947
+ const attr = combined.get(key)!
1948
+ attr.linesChanged++
1949
+ commitsByAuthor.get(key)!.add(entry.hash)
1950
+ const lineNum = idx + 1
1951
+ if (issuesByLine.has(lineNum)) {
1952
+ attr.issuesIntroduced += issuesByLine.get(lineNum)!
1953
+ attr.avgScoreImpact += report.score * (1 / (blameEntries.length || 1))
1954
+ }
1955
+ })
1956
+ }
1957
+
1958
+ for (const [key, attr] of combined) {
1959
+ attr.commits = commitsByAuthor.get(key)?.size ?? 0
1960
+ }
1961
+
1962
+ return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1963
+ }
1964
+
1965
+ // --- Instance method -------------------------------------------------------
1966
+
1967
+ async analyzeBlame(options: {
1968
+ target?: 'file' | 'rule' | 'overall'
1969
+ top?: number
1970
+ filePath?: string
1971
+ rule?: string
1972
+ }): Promise<DriftBlameReport> {
1973
+ assertGitRepo(this.projectPath)
1974
+
1975
+ let blame: BlameAttribution[] = []
1976
+ const mode = options.target ?? 'overall'
1977
+
1978
+ if (mode === 'file' && options.filePath) {
1979
+ blame = await BlameAnalyzer.analyzeFileBlame(options.filePath)
1980
+ } else if (mode === 'rule' && options.rule) {
1981
+ blame = await BlameAnalyzer.analyzeRuleBlame(options.rule, this.projectPath)
1982
+ } else {
1983
+ blame = await BlameAnalyzer.analyzeOverallBlame(this.projectPath)
1984
+ }
1985
+
1986
+ if (options.top) {
1987
+ blame = blame.slice(0, options.top)
1988
+ }
1989
+
1990
+ const currentFiles = analyzeProject(this.projectPath, this.config)
1991
+ const baseReport = buildReport(this.projectPath, currentFiles)
1992
+
1993
+ return { ...baseReport, blame }
1994
+ }
1995
+ }
package/src/cli.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  import { Command } from 'commander'
3
3
  import { writeFileSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
5
+ import { createRequire } from 'node:module'
6
+ const require = createRequire(import.meta.url)
7
+ const { version: VERSION } = require('../package.json') as { version: string }
5
8
  import { analyzeProject } from './analyzer.js'
6
9
  import { buildReport, formatMarkdown, formatAIOutput } from './reporter.js'
7
10
  import { printConsole, printDiff } from './printer.js'
@@ -11,13 +14,14 @@ import { computeDiff } from './diff.js'
11
14
  import { generateHtmlReport } from './report.js'
12
15
  import { generateBadge } from './badge.js'
13
16
  import { emitCIAnnotations, printCISummary } from './ci.js'
17
+ import { TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
14
18
 
15
19
  const program = new Command()
16
20
 
17
21
  program
18
22
  .name('drift')
19
23
  .description('Detect silent technical debt left by AI-generated code')
20
- .version('0.6.0')
24
+ .version(VERSION)
21
25
 
22
26
  program
23
27
  .command('scan [path]', { isDefault: true })
@@ -163,4 +167,46 @@ program
163
167
  }
164
168
  })
165
169
 
170
+ program
171
+ .command('trend [period]')
172
+ .description('Analyze trend of technical debt over time')
173
+ .option('--since <date>', 'Start date for trend analysis (ISO format)')
174
+ .option('--until <date>', 'End date for trend analysis (ISO format)')
175
+ .action(async (period: string | undefined, options: { since?: string; until?: string }) => {
176
+ const resolvedPath = resolve('.')
177
+ process.stderr.write(`\nAnalyzing trend in ${resolvedPath}...\n`)
178
+
179
+ const config = await loadConfig(resolvedPath)
180
+ const analyzer = new TrendAnalyzer(resolvedPath, config)
181
+
182
+ const trendData = await analyzer.analyzeTrend({
183
+ period: period as 'week' | 'month' | 'quarter' | 'year',
184
+ since: options.since,
185
+ until: options.until
186
+ })
187
+
188
+ process.stderr.write(`\nTrend analysis complete:\n`)
189
+ process.stdout.write(JSON.stringify(trendData, null, 2) + '\n')
190
+ })
191
+
192
+ program
193
+ .command('blame [target]')
194
+ .description('Analyze which files/rules contribute most to technical debt')
195
+ .option('--top <n>', 'Number of top contributors to show (default: 10)', '10')
196
+ .action(async (target: string | undefined, options: { top: string }) => {
197
+ const resolvedPath = resolve('.')
198
+ process.stderr.write(`\nAnalyzing blame in ${resolvedPath}...\n`)
199
+
200
+ const config = await loadConfig(resolvedPath)
201
+ const analyzer = new BlameAnalyzer(resolvedPath, config)
202
+
203
+ const blameData = await analyzer.analyzeBlame({
204
+ target: target as 'file' | 'rule' | 'overall' | undefined,
205
+ top: Number(options.top)
206
+ })
207
+
208
+ process.stderr.write(`\nBlame analysis complete:\n`)
209
+ process.stdout.write(JSON.stringify(blameData, null, 2) + '\n')
210
+ })
211
+
166
212
  program.parse()