@eduardbar/drift 1.2.0 → 1.4.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 (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. package/tests/trust.test.ts +602 -0
package/src/config.ts CHANGED
@@ -3,6 +3,22 @@ import { join, resolve } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import type { DriftConfig } from './types.js'
5
5
 
6
+ function normalizeLegacyConfig(config: DriftConfig): DriftConfig {
7
+ if (config.modules !== undefined) {
8
+ return config
9
+ }
10
+
11
+ const legacyModules = config.moduleBoundaries ?? config.boundaries
12
+ if (!legacyModules || legacyModules.length === 0) {
13
+ return config
14
+ }
15
+
16
+ return {
17
+ ...config,
18
+ modules: legacyModules,
19
+ }
20
+ }
21
+
6
22
  /**
7
23
  * Load drift.config.ts / .js / .json from the given project root.
8
24
  * Returns undefined if no config file is found.
@@ -27,7 +43,8 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
27
43
 
28
44
  if (ext === 'json') {
29
45
  const { readFileSync } = await import('node:fs')
30
- return JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
46
+ const rawConfig = JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
47
+ return normalizeLegacyConfig(rawConfig)
31
48
  }
32
49
 
33
50
  // .ts / .js — dynamic import via file URL
@@ -35,7 +52,7 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
35
52
  const mod = await import(fileUrl)
36
53
  const config: DriftConfig = mod.default ?? mod
37
54
 
38
- return config
55
+ return normalizeLegacyConfig(config)
39
56
  } catch { // drift-ignore
40
57
  // drift-ignore: catch-swallow — config is optional; load failure is non-fatal
41
58
  }
package/src/diff.ts CHANGED
@@ -1,9 +1,78 @@
1
1
  import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
2
2
 
3
+ function normalizePath(filePath: string): string {
4
+ return filePath.replace(/\\/g, '/')
5
+ }
6
+
7
+ function normalizeIssueText(value: string): string {
8
+ return value
9
+ .replace(/\r\n/g, '\n')
10
+ .replace(/\r/g, '\n')
11
+ .replace(/\s+/g, ' ')
12
+ .trim()
13
+ }
14
+
15
+ const SNIPPET_PREFIX_LENGTH = 80
16
+
17
+ interface IssueMatchState {
18
+ matchedBaseIndexes: Set<number>
19
+ matchedCurrentIndexes: Set<number>
20
+ }
21
+
22
+ function strictIssueKey(i: DriftIssue): string {
23
+ return `${i.rule}:${i.line}:${i.column}`
24
+ }
25
+
26
+ function normalizedIssueKey(i: DriftIssue): string {
27
+ const normalizedMessage = normalizeIssueText(i.message)
28
+ const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, SNIPPET_PREFIX_LENGTH)
29
+ return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
30
+ }
31
+
32
+ function buildIssueIndex(
33
+ issues: DriftIssue[],
34
+ getKey: (issue: DriftIssue) => string,
35
+ skip?: Set<number>,
36
+ ): Map<string, number[]> {
37
+ const index = new Map<string, number[]>()
38
+ for (const [idx, issue] of issues.entries()) {
39
+ if (skip?.has(idx)) continue
40
+ const key = getKey(issue)
41
+ const bucket = index.get(key)
42
+ if (bucket) bucket.push(idx)
43
+ else index.set(key, [idx])
44
+ }
45
+ return index
46
+ }
47
+
48
+ function matchIssues(
49
+ currentIssues: DriftIssue[],
50
+ index: Map<string, number[]>,
51
+ state: IssueMatchState,
52
+ getKey: (issue: DriftIssue) => string,
53
+ ): void {
54
+ for (const [currentIndex, issue] of currentIssues.entries()) {
55
+ if (state.matchedCurrentIndexes.has(currentIndex)) continue
56
+ const bucket = index.get(getKey(issue))
57
+ if (!bucket || bucket.length === 0) continue
58
+
59
+ const matchedIndex = bucket.shift()
60
+ if (matchedIndex === undefined) continue
61
+
62
+ state.matchedBaseIndexes.add(matchedIndex)
63
+ state.matchedCurrentIndexes.add(currentIndex)
64
+ }
65
+ }
66
+
3
67
  /**
4
68
  * Compute the diff between two DriftReports.
5
69
  *
6
- * Issues are matched by (rule + line + column) as a unique key within a file.
70
+ * Issues are matched in two passes:
71
+ * 1) strict location key (rule + line + column)
72
+ * 2) normalized content key (rule + severity + line + message + snippet)
73
+ *
74
+ * This keeps deterministic matching while preventing false churn caused by
75
+ * cross-platform line ending changes and small column offset noise.
7
76
  * A "new" issue exists in `current` but not in `base`.
8
77
  * A "resolved" issue exists in `base` but not in `current`.
9
78
  */
@@ -19,13 +88,18 @@ function computeFileDiff(
19
88
  const baseIssues = baseFile?.issues ?? []
20
89
  const currentIssues = currentFile?.issues ?? []
21
90
 
22
- const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
91
+ const matchedBaseIndexes = new Set<number>()
92
+ const matchedCurrentIndexes = new Set<number>()
93
+ const matchState = { matchedBaseIndexes, matchedCurrentIndexes }
94
+
95
+ const baseStrictIndex = buildIssueIndex(baseIssues, strictIssueKey)
96
+ matchIssues(currentIssues, baseStrictIndex, matchState, strictIssueKey)
23
97
 
24
- const baseKeys = new Set(baseIssues.map(issueKey))
25
- const currentKeys = new Set(currentIssues.map(issueKey))
98
+ const baseNormalizedIndex = buildIssueIndex(baseIssues, normalizedIssueKey, matchedBaseIndexes)
99
+ matchIssues(currentIssues, baseNormalizedIndex, matchState, normalizedIssueKey)
26
100
 
27
- const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
28
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
101
+ const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
102
+ const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
29
103
 
30
104
  if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
31
105
  return {
@@ -48,12 +122,12 @@ export function computeDiff(
48
122
  ): DriftDiff {
49
123
  const fileDiffs: FileDiff[] = []
50
124
 
51
- const baseByPath = new Map(base.files.map(f => [f.path, f]))
52
- const currentByPath = new Map(current.files.map(f => [f.path, f]))
125
+ const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]))
126
+ const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]))
53
127
 
54
128
  const allPaths = new Set([
55
- ...base.files.map(f => f.path),
56
- ...current.files.map(f => f.path),
129
+ ...base.files.map(f => normalizePath(f.path)),
130
+ ...current.files.map(f => normalizePath(f.path)),
57
131
  ])
58
132
 
59
133
  for (const filePath of allPaths) {
package/src/doctor.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import kleur from 'kleur'
4
+
5
+ export interface DoctorOptions {
6
+ json?: boolean
7
+ }
8
+
9
+ interface DoctorReport {
10
+ targetPath: string
11
+ node: {
12
+ version: string
13
+ major: number
14
+ supported: boolean
15
+ }
16
+ project: {
17
+ packageJsonFound: boolean
18
+ esm: boolean
19
+ tsconfigFound: boolean
20
+ sourceFilesCount: number
21
+ lowMemorySuggested: boolean
22
+ driftConfigFile: string | null
23
+ }
24
+ }
25
+
26
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
27
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'coverage'])
28
+ const DECIMAL_RADIX = 10
29
+ const MIN_SUPPORTED_NODE_MAJOR = 18
30
+ const LOW_MEMORY_SOURCE_FILE_THRESHOLD = 500
31
+ const DRIFT_CONFIG_CANDIDATES = [
32
+ 'drift.config.ts',
33
+ 'drift.config.js',
34
+ 'drift.config.mjs',
35
+ 'drift.config.cjs',
36
+ 'drift.config.json',
37
+ ] as const
38
+
39
+ function parseNodeMajor(version: string): number {
40
+ const parsed = Number.parseInt(version.replace(/^v/, '').split('.')[0] ?? '0', DECIMAL_RADIX)
41
+ return Number.isFinite(parsed) ? parsed : 0
42
+ }
43
+
44
+ function detectDriftConfig(projectPath: string): string | null {
45
+ for (const candidate of DRIFT_CONFIG_CANDIDATES) {
46
+ if (existsSync(join(projectPath, candidate))) {
47
+ return candidate
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ function countSourceFiles(projectPath: string): number {
54
+ let total = 0
55
+ const stack = [projectPath]
56
+
57
+ while (stack.length > 0) {
58
+ const currentDir = stack.pop()
59
+ if (!currentDir) continue
60
+
61
+ const entries = readdirSync(currentDir, { withFileTypes: true })
62
+ for (const entry of entries) {
63
+ if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
64
+ stack.push(join(currentDir, entry.name))
65
+ continue
66
+ }
67
+
68
+ if (entry.isDirectory()) {
69
+ continue
70
+ }
71
+
72
+ if (!entry.isFile()) continue
73
+
74
+ const lastDot = entry.name.lastIndexOf('.')
75
+ if (lastDot === -1) continue
76
+
77
+ const extension = entry.name.slice(lastDot)
78
+ if (SOURCE_EXTENSIONS.has(extension)) {
79
+ total += 1
80
+ }
81
+ }
82
+ }
83
+
84
+ return total
85
+ }
86
+
87
+ function buildDoctorReport(projectPath: string): DoctorReport {
88
+ const nodeMajor = parseNodeMajor(process.version)
89
+ const packageJsonPath = join(projectPath, 'package.json')
90
+ const packageJsonFound = existsSync(packageJsonPath)
91
+
92
+ let esm = false
93
+ if (packageJsonFound) {
94
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }
95
+ esm = parsed.type === 'module'
96
+ }
97
+
98
+ const sourceFilesCount = countSourceFiles(projectPath)
99
+
100
+ return {
101
+ targetPath: projectPath,
102
+ node: {
103
+ version: process.version,
104
+ major: nodeMajor,
105
+ supported: nodeMajor >= MIN_SUPPORTED_NODE_MAJOR,
106
+ },
107
+ project: {
108
+ packageJsonFound,
109
+ esm,
110
+ tsconfigFound: existsSync(join(projectPath, 'tsconfig.json')),
111
+ sourceFilesCount,
112
+ lowMemorySuggested: sourceFilesCount > LOW_MEMORY_SOURCE_FILE_THRESHOLD,
113
+ driftConfigFile: detectDriftConfig(projectPath),
114
+ },
115
+ }
116
+ }
117
+
118
+ function printConsoleReport(report: DoctorReport): void {
119
+ const icons = {
120
+ check: kleur.green('✓'),
121
+ warn: kleur.yellow('⚠'),
122
+ error: kleur.red('✗'),
123
+ info: kleur.cyan('ℹ'),
124
+ }
125
+
126
+ process.stdout.write('\n')
127
+ process.stdout.write(`${kleur.bold().white('drift doctor')} ${kleur.gray('- environment diagnostics')}\n\n`)
128
+
129
+ const nodeStatus = report.node.supported
130
+ ? `${icons.check} ${kleur.green('Node runtime supported')}`
131
+ : `${icons.warn} ${kleur.yellow('Node runtime below recommended minimum (>=18)')}`
132
+ process.stdout.write(`${nodeStatus} ${kleur.gray(`(${report.node.version})`)}\n`)
133
+
134
+ if (report.project.packageJsonFound) {
135
+ process.stdout.write(`${icons.check} package.json found\n`)
136
+ process.stdout.write(`${icons.info} ESM mode: ${report.project.esm ? kleur.green('yes') : kleur.yellow('no')}\n`)
137
+ } else {
138
+ process.stdout.write(`${icons.warn} package.json not found\n`)
139
+ process.stdout.write(`${icons.info} ESM mode: ${kleur.gray('unknown')}\n`)
140
+ }
141
+
142
+ if (report.project.tsconfigFound) {
143
+ process.stdout.write(`${icons.check} tsconfig.json found\n`)
144
+ } else {
145
+ process.stdout.write(`${icons.warn} tsconfig.json not found\n`)
146
+ }
147
+
148
+ process.stdout.write(`${icons.info} Source files (.ts/.tsx/.js/.jsx): ${report.project.sourceFilesCount}\n`)
149
+
150
+ if (report.project.lowMemorySuggested) {
151
+ process.stdout.write(`${icons.warn} Large codebase detected, consider ${kleur.bold('--low-memory')}\n`)
152
+ }
153
+
154
+ if (report.project.driftConfigFile) {
155
+ process.stdout.write(`${icons.check} Drift config: ${report.project.driftConfigFile}\n`)
156
+ } else {
157
+ process.stdout.write(`${icons.warn} Drift config not found (drift.config.*)\n`)
158
+ }
159
+
160
+ process.stdout.write('\n')
161
+ }
162
+
163
+ export async function runDoctor(projectPath: string, options?: DoctorOptions): Promise<number> {
164
+ const report = buildDoctorReport(projectPath)
165
+
166
+ if (options?.json) {
167
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
168
+ } else {
169
+ printConsoleReport(report)
170
+ }
171
+
172
+ return 0
173
+ }
package/src/format.ts ADDED
@@ -0,0 +1,81 @@
1
+ const UNIFIED_FORMAT_VALUES = ['console', 'json', 'markdown', 'ai', 'sarif'] as const
2
+
3
+ type UnifiedOutputFormat = (typeof UNIFIED_FORMAT_VALUES)[number]
4
+
5
+ type LegacyAlias = {
6
+ flag: string
7
+ used?: boolean
8
+ mapsTo: UnifiedOutputFormat
9
+ }
10
+
11
+ interface ResolveOutputFormatOptions {
12
+ command: string
13
+ format?: string
14
+ supported: readonly UnifiedOutputFormat[]
15
+ legacyAliases?: LegacyAlias[]
16
+ onWarning?: (message: string) => void
17
+ }
18
+
19
+ function assertSupportedFormatValue(command: string, format: string): asserts format is UnifiedOutputFormat {
20
+ if ((UNIFIED_FORMAT_VALUES as readonly string[]).includes(format)) return
21
+ throw new Error(
22
+ `Invalid --format '${format}' for '${command}'. Allowed values: ${UNIFIED_FORMAT_VALUES.join(', ')}.`,
23
+ )
24
+ }
25
+
26
+ function throwUnsupportedFormat(command: string, selected: UnifiedOutputFormat, supported: readonly UnifiedOutputFormat[]): never {
27
+ throw new Error(
28
+ `Format '${selected}' is not supported for '${command}'. Supported formats: ${supported.join(', ')}.`,
29
+ )
30
+ }
31
+
32
+ function normalizeLegacyFormatSelection(command: string, selectedLegacyFormats: UnifiedOutputFormat[]): UnifiedOutputFormat | undefined {
33
+ if (selectedLegacyFormats.length === 0) return undefined
34
+
35
+ const uniqueFormats = [...new Set(selectedLegacyFormats)]
36
+ if (uniqueFormats.length > 1) {
37
+ throw new Error(
38
+ `Conflicting legacy format flags for '${command}': ${uniqueFormats.join(' vs ')}. Use a single format option.`,
39
+ )
40
+ }
41
+
42
+ return uniqueFormats[0]
43
+ }
44
+
45
+ export function resolveOutputFormat(options: ResolveOutputFormatOptions): UnifiedOutputFormat {
46
+ const { command, format, supported, onWarning } = options
47
+ const legacyAliases = options.legacyAliases ?? []
48
+
49
+ for (const alias of legacyAliases) {
50
+ if (!alias.used) continue
51
+ onWarning?.(`Warning: --${alias.flag} is deprecated for '${command}'. Use --format ${alias.mapsTo} instead.`)
52
+ }
53
+
54
+ const selectedLegacyFormat = normalizeLegacyFormatSelection(
55
+ command,
56
+ legacyAliases.filter((alias) => alias.used).map((alias) => alias.mapsTo),
57
+ )
58
+
59
+ const selectedFormat = format?.trim()
60
+ if (selectedFormat) {
61
+ assertSupportedFormatValue(command, selectedFormat)
62
+ if (selectedLegacyFormat && selectedLegacyFormat !== selectedFormat) {
63
+ throw new Error(
64
+ `Conflicting format flags for '${command}': --format ${selectedFormat} and legacy alias for ${selectedLegacyFormat}.`,
65
+ )
66
+ }
67
+
68
+ if (!supported.includes(selectedFormat)) {
69
+ throwUnsupportedFormat(command, selectedFormat, supported)
70
+ }
71
+
72
+ return selectedFormat
73
+ }
74
+
75
+ const resolvedFromLegacy = selectedLegacyFormat ?? 'console'
76
+ if (!supported.includes(resolvedFromLegacy)) {
77
+ throwUnsupportedFormat(command, resolvedFromLegacy, supported)
78
+ }
79
+
80
+ return resolvedFromLegacy
81
+ }
package/src/git.ts CHANGED
@@ -63,6 +63,18 @@ function extractFile(projectPath: string, ref: string, filePath: string, tempDir
63
63
  writeFileSync(destPath, content, 'utf-8')
64
64
  }
65
65
 
66
+ function extractArchiveAtRef(projectPath: string, ref: string, tempDir: string): boolean {
67
+ try {
68
+ execSync(
69
+ `git archive --format=tar ${ref} | tar -x -C "${tempDir}"`,
70
+ { cwd: projectPath, stdio: 'pipe' }
71
+ )
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
66
78
  export function extractFilesAtRef(projectPath: string, ref: string): string {
67
79
  verifyGitRepo(projectPath)
68
80
  verifyRefExists(projectPath, ref)
@@ -76,6 +88,10 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
76
88
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
77
89
  mkdirSync(tempDir, { recursive: true })
78
90
 
91
+ if (extractArchiveAtRef(projectPath, ref, tempDir)) {
92
+ return tempDir
93
+ }
94
+
79
95
  for (const filePath of tsFiles) {
80
96
  extractFile(projectPath, ref, filePath, tempDir)
81
97
  }
@@ -0,0 +1,64 @@
1
+ import type { DriftAnalysisOptions, DriftDiff, DriftIssue, DriftReport } from './types.js'
2
+
3
+ export type IssueSeverity = DriftIssue['severity']
4
+
5
+ export interface GuardBaseline {
6
+ score?: number
7
+ totalIssues?: number
8
+ errors?: number
9
+ warnings?: number
10
+ infos?: number
11
+ bySeverity?: Partial<Record<IssueSeverity, number>>
12
+ summary?: {
13
+ errors?: number
14
+ warnings?: number
15
+ infos?: number
16
+ }
17
+ }
18
+
19
+ export interface GuardThresholds {
20
+ error?: number
21
+ warning?: number
22
+ info?: number
23
+ }
24
+
25
+ export interface GuardOptions {
26
+ baseRef?: string
27
+ baselinePath?: string
28
+ baseline?: GuardBaseline
29
+ budget?: number
30
+ bySeverity?: GuardThresholds
31
+ analysis?: DriftAnalysisOptions
32
+ }
33
+
34
+ export interface GuardMetrics {
35
+ scoreDelta: number
36
+ totalIssuesDelta: number
37
+ severityDelta: Record<IssueSeverity, number>
38
+ }
39
+
40
+ export interface GuardCheck {
41
+ id: string
42
+ passed: boolean
43
+ actual: number
44
+ limit: number
45
+ message: string
46
+ }
47
+
48
+ export interface GuardEvaluation {
49
+ passed: boolean
50
+ checks: GuardCheck[]
51
+ }
52
+
53
+ export interface GuardResult {
54
+ scannedAt: string
55
+ projectPath: string
56
+ mode: 'diff' | 'baseline'
57
+ passed: boolean
58
+ baseRef?: string
59
+ baselinePath?: string
60
+ metrics: GuardMetrics
61
+ checks: GuardCheck[]
62
+ current: DriftReport
63
+ diff?: DriftDiff
64
+ }