@eduardbar/drift 1.3.0 → 1.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.
Files changed (198) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +62 -0
  3. package/.github/actions/drift-review/action.yml +148 -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 +1 -3
  7. package/.github/workflows/publish.yml +8 -0
  8. package/.github/workflows/quality.yml +15 -0
  9. package/.github/workflows/reusable-quality-checks.yml +95 -0
  10. package/.github/workflows/review-pr.yml +33 -41
  11. package/AGENTS.md +75 -251
  12. package/CHANGELOG.md +41 -0
  13. package/README.md +177 -43
  14. package/benchmarks/fixtures/critical/drift.config.ts +21 -0
  15. package/benchmarks/fixtures/critical/src/app/user-service.ts +30 -0
  16. package/benchmarks/fixtures/critical/src/domain/entities.ts +19 -0
  17. package/benchmarks/fixtures/critical/src/domain/policies.ts +22 -0
  18. package/benchmarks/fixtures/critical/src/index.ts +10 -0
  19. package/benchmarks/fixtures/critical/src/infra/memory-user-repo.ts +14 -0
  20. package/benchmarks/perf-budget.v1.json +27 -0
  21. package/dist/benchmark.d.ts +1 -1
  22. package/dist/benchmark.js +83 -52
  23. package/dist/cli.js +243 -8
  24. package/dist/config.js +16 -2
  25. package/dist/diff.js +42 -50
  26. package/dist/doctor.d.ts +26 -0
  27. package/dist/doctor.js +140 -0
  28. package/dist/format.d.ts +17 -0
  29. package/dist/format.js +45 -0
  30. package/dist/guard-baseline.d.ts +12 -0
  31. package/dist/guard-baseline.js +57 -0
  32. package/dist/guard-metrics.d.ts +6 -0
  33. package/dist/guard-metrics.js +39 -0
  34. package/dist/guard-types.d.ts +58 -0
  35. package/dist/guard-types.js +2 -0
  36. package/dist/guard.d.ts +16 -0
  37. package/dist/guard.js +178 -0
  38. package/dist/index.d.ts +10 -3
  39. package/dist/index.js +4 -1
  40. package/dist/init.d.ts +15 -0
  41. package/dist/init.js +273 -0
  42. package/dist/map-cycles.d.ts +2 -0
  43. package/dist/map-cycles.js +34 -0
  44. package/dist/map-svg.d.ts +19 -0
  45. package/dist/map-svg.js +97 -0
  46. package/dist/map.js +78 -138
  47. package/dist/metrics.js +70 -55
  48. package/dist/output-metadata.d.ts +15 -0
  49. package/dist/output-metadata.js +19 -0
  50. package/dist/plugins-capabilities.d.ts +4 -0
  51. package/dist/plugins-capabilities.js +21 -0
  52. package/dist/plugins-messages.d.ts +10 -0
  53. package/dist/plugins-messages.js +16 -0
  54. package/dist/plugins-rules.d.ts +9 -0
  55. package/dist/plugins-rules.js +137 -0
  56. package/dist/plugins.d.ts +1 -1
  57. package/dist/plugins.js +45 -142
  58. package/dist/reporter-constants.d.ts +16 -0
  59. package/dist/reporter-constants.js +39 -0
  60. package/dist/reporter.d.ts +3 -3
  61. package/dist/reporter.js +35 -55
  62. package/dist/review.d.ts +2 -1
  63. package/dist/review.js +2 -1
  64. package/dist/rules/phase3-configurable.js +23 -15
  65. package/dist/saas/constants.d.ts +15 -0
  66. package/dist/saas/constants.js +48 -0
  67. package/dist/saas/dashboard.d.ts +8 -0
  68. package/dist/saas/dashboard.js +132 -0
  69. package/dist/saas/errors.d.ts +19 -0
  70. package/dist/saas/errors.js +37 -0
  71. package/dist/saas/helpers.d.ts +21 -0
  72. package/dist/saas/helpers.js +110 -0
  73. package/dist/saas/ingest.d.ts +3 -0
  74. package/dist/saas/ingest.js +249 -0
  75. package/dist/saas/organization.d.ts +5 -0
  76. package/dist/saas/organization.js +82 -0
  77. package/dist/saas/plan-change.d.ts +10 -0
  78. package/dist/saas/plan-change.js +15 -0
  79. package/dist/saas/store.d.ts +21 -0
  80. package/dist/saas/store.js +159 -0
  81. package/dist/saas/types.d.ts +191 -0
  82. package/dist/saas/types.js +2 -0
  83. package/dist/saas.d.ts +8 -218
  84. package/dist/saas.js +7 -761
  85. package/dist/sarif.d.ts +74 -0
  86. package/dist/sarif.js +122 -0
  87. package/dist/trust-advanced.d.ts +14 -0
  88. package/dist/trust-advanced.js +65 -0
  89. package/dist/trust-kpi-fs.d.ts +3 -0
  90. package/dist/trust-kpi-fs.js +141 -0
  91. package/dist/trust-kpi-parse.d.ts +7 -0
  92. package/dist/trust-kpi-parse.js +186 -0
  93. package/dist/trust-kpi-types.d.ts +16 -0
  94. package/dist/trust-kpi-types.js +2 -0
  95. package/dist/trust-kpi.d.ts +1 -3
  96. package/dist/trust-kpi.js +6 -266
  97. package/dist/trust-policy.d.ts +32 -0
  98. package/dist/trust-policy.js +160 -0
  99. package/dist/trust-render.d.ts +9 -0
  100. package/dist/trust-render.js +54 -0
  101. package/dist/trust-scoring.d.ts +9 -0
  102. package/dist/trust-scoring.js +208 -0
  103. package/dist/trust.d.ts +5 -32
  104. package/dist/trust.js +29 -432
  105. package/dist/types/app.d.ts +30 -0
  106. package/dist/types/app.js +2 -0
  107. package/dist/types/config.d.ts +25 -0
  108. package/dist/types/config.js +2 -0
  109. package/dist/types/core.d.ts +100 -0
  110. package/dist/types/core.js +2 -0
  111. package/dist/types/diff.d.ts +55 -0
  112. package/dist/types/diff.js +2 -0
  113. package/dist/types/plugin.d.ts +41 -0
  114. package/dist/types/plugin.js +2 -0
  115. package/dist/types/trust.d.ts +120 -0
  116. package/dist/types/trust.js +2 -0
  117. package/dist/types.d.ts +8 -365
  118. package/docs/AGENTS.md +1 -1
  119. package/docs/release-notes-draft.md +40 -0
  120. package/docs/rules-catalog.md +49 -0
  121. package/docs/trust-core-release-checklist.md +37 -5
  122. package/package.json +11 -4
  123. package/packages/vscode-drift/src/code-actions.ts +1 -1
  124. package/schemas/drift-ai-output.v1.json +162 -0
  125. package/schemas/drift-doctor.v1.json +57 -0
  126. package/schemas/drift-guard.v1.json +298 -0
  127. package/schemas/drift-report.v1.json +151 -0
  128. package/schemas/drift-trust.v1.json +131 -0
  129. package/scripts/check-docs-drift.mjs +154 -0
  130. package/scripts/check-performance-budget.mjs +360 -0
  131. package/scripts/check-runtime-policy.mjs +66 -0
  132. package/scripts/smoke-repo.mjs +394 -0
  133. package/src/benchmark.ts +92 -53
  134. package/src/cli.ts +285 -13
  135. package/src/config.ts +19 -2
  136. package/src/diff.ts +57 -48
  137. package/src/doctor.ts +185 -0
  138. package/src/format.ts +81 -0
  139. package/src/guard-baseline.ts +74 -0
  140. package/src/guard-metrics.ts +52 -0
  141. package/src/guard-types.ts +66 -0
  142. package/src/guard.ts +248 -0
  143. package/src/index.ts +36 -0
  144. package/src/init.ts +298 -0
  145. package/src/map-cycles.ts +38 -0
  146. package/src/map-svg.ts +124 -0
  147. package/src/map.ts +111 -142
  148. package/src/metrics.ts +78 -59
  149. package/src/output-metadata.ts +32 -0
  150. package/src/plugins-capabilities.ts +36 -0
  151. package/src/plugins-messages.ts +35 -0
  152. package/src/plugins-rules.ts +296 -0
  153. package/src/plugins.ts +76 -283
  154. package/src/reporter-constants.ts +46 -0
  155. package/src/reporter.ts +64 -65
  156. package/src/review.ts +4 -2
  157. package/src/rules/phase3-configurable.ts +39 -26
  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 -1031
  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 +8 -316
  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 +62 -576
  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 +79 -409
  185. package/tests/ci-quality-matrix.test.ts +37 -0
  186. package/tests/ci-smoke-gate.test.ts +26 -0
  187. package/tests/ci-version-alignment.test.ts +93 -0
  188. package/tests/cli-sarif.test.ts +92 -0
  189. package/tests/docs-drift-check.test.ts +115 -0
  190. package/tests/format.test.ts +157 -0
  191. package/tests/new-features.test.ts +11 -3
  192. package/tests/perf-budget-check.test.ts +146 -0
  193. package/tests/phase1-init-doctor-guard.test.ts +301 -0
  194. package/tests/runtime-policy-alignment.test.ts +46 -0
  195. package/tests/sarif.test.ts +160 -0
  196. package/tests/trust-kpi.test.ts +31 -4
  197. package/tests/trust.test.ts +18 -0
  198. package/vitest.config.ts +2 -0
package/src/doctor.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import kleur from 'kleur'
4
+ import type { DriftOutputMetadata } from './types.js'
5
+ import { OUTPUT_SCHEMA, withOutputMetadata } from './output-metadata.js'
6
+
7
+ export interface DoctorOptions {
8
+ json?: boolean
9
+ }
10
+
11
+ interface DoctorReport {
12
+ targetPath: string
13
+ node: {
14
+ version: string
15
+ major: number
16
+ supported: boolean
17
+ }
18
+ project: {
19
+ packageJsonFound: boolean
20
+ esm: boolean
21
+ tsconfigFound: boolean
22
+ sourceFilesCount: number
23
+ lowMemorySuggested: boolean
24
+ driftConfigFile: string | null
25
+ }
26
+ }
27
+
28
+ export type DoctorReportJson = DoctorReport & DriftOutputMetadata
29
+
30
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
31
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'coverage'])
32
+ const DECIMAL_RADIX = 10
33
+ const MIN_SUPPORTED_NODE_MAJOR = 20
34
+ const LOW_MEMORY_SOURCE_FILE_THRESHOLD = 500
35
+ const DRIFT_CONFIG_CANDIDATES = [
36
+ 'drift.config.ts',
37
+ 'drift.config.js',
38
+ 'drift.config.mjs',
39
+ 'drift.config.cjs',
40
+ 'drift.config.json',
41
+ ] as const
42
+
43
+ function parseNodeMajor(version: string): number {
44
+ const parsed = Number.parseInt(version.replace(/^v/, '').split('.')[0] ?? '0', DECIMAL_RADIX)
45
+ return Number.isFinite(parsed) ? parsed : 0
46
+ }
47
+
48
+ function detectDriftConfig(projectPath: string): string | null {
49
+ for (const candidate of DRIFT_CONFIG_CANDIDATES) {
50
+ if (existsSync(join(projectPath, candidate))) {
51
+ return candidate
52
+ }
53
+ }
54
+ return null
55
+ }
56
+
57
+ function countSourceFiles(projectPath: string): number {
58
+ let total = 0
59
+ const stack = [projectPath]
60
+
61
+ while (stack.length > 0) {
62
+ const currentDir = stack.pop()
63
+ if (!currentDir) continue
64
+
65
+ const entries = readdirSync(currentDir, { withFileTypes: true })
66
+ for (const entry of entries) {
67
+ if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
68
+ stack.push(join(currentDir, entry.name))
69
+ continue
70
+ }
71
+
72
+ if (entry.isDirectory()) {
73
+ continue
74
+ }
75
+
76
+ if (!entry.isFile()) continue
77
+
78
+ const lastDot = entry.name.lastIndexOf('.')
79
+ if (lastDot === -1) continue
80
+
81
+ const extension = entry.name.slice(lastDot)
82
+ if (SOURCE_EXTENSIONS.has(extension)) {
83
+ total += 1
84
+ }
85
+ }
86
+ }
87
+
88
+ return total
89
+ }
90
+
91
+ function buildDoctorReport(projectPath: string): DoctorReport {
92
+ const nodeMajor = parseNodeMajor(process.version)
93
+ const packageJsonPath = join(projectPath, 'package.json')
94
+ const packageJsonFound = existsSync(packageJsonPath)
95
+
96
+ let esm = false
97
+ if (packageJsonFound) {
98
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }
99
+ esm = parsed.type === 'module'
100
+ }
101
+
102
+ const sourceFilesCount = countSourceFiles(projectPath)
103
+
104
+ return {
105
+ targetPath: projectPath,
106
+ node: {
107
+ version: process.version,
108
+ major: nodeMajor,
109
+ supported: nodeMajor >= MIN_SUPPORTED_NODE_MAJOR,
110
+ },
111
+ project: {
112
+ packageJsonFound,
113
+ esm,
114
+ tsconfigFound: existsSync(join(projectPath, 'tsconfig.json')),
115
+ sourceFilesCount,
116
+ lowMemorySuggested: sourceFilesCount > LOW_MEMORY_SOURCE_FILE_THRESHOLD,
117
+ driftConfigFile: detectDriftConfig(projectPath),
118
+ },
119
+ }
120
+ }
121
+
122
+ function printConsoleReport(report: DoctorReport): void {
123
+ const icons = {
124
+ check: kleur.green('✓'),
125
+ warn: kleur.yellow('⚠'),
126
+ error: kleur.red('✗'),
127
+ info: kleur.cyan('ℹ'),
128
+ }
129
+
130
+ process.stdout.write('\n')
131
+ process.stdout.write(`${kleur.bold().white('drift doctor')} ${kleur.gray('- environment diagnostics')}\n\n`)
132
+
133
+ const nodeStatus = report.node.supported
134
+ ? `${icons.check} ${kleur.green('Node runtime supported')}`
135
+ : `${icons.warn} ${kleur.yellow('Node runtime below supported minimum (>=20)')}`
136
+ process.stdout.write(`${nodeStatus} ${kleur.gray(`(${report.node.version})`)}\n`)
137
+
138
+ if (report.project.packageJsonFound) {
139
+ process.stdout.write(`${icons.check} package.json found\n`)
140
+ process.stdout.write(`${icons.info} ESM mode: ${report.project.esm ? kleur.green('yes') : kleur.yellow('no')}\n`)
141
+ } else {
142
+ process.stdout.write(`${icons.warn} package.json not found\n`)
143
+ process.stdout.write(`${icons.info} ESM mode: ${kleur.gray('unknown')}\n`)
144
+ }
145
+
146
+ if (report.project.tsconfigFound) {
147
+ process.stdout.write(`${icons.check} tsconfig.json found\n`)
148
+ } else {
149
+ process.stdout.write(`${icons.warn} tsconfig.json not found\n`)
150
+ }
151
+
152
+ process.stdout.write(`${icons.info} Source files (.ts/.tsx/.js/.jsx): ${report.project.sourceFilesCount}\n`)
153
+
154
+ if (report.project.lowMemorySuggested) {
155
+ process.stdout.write(`${icons.warn} Large codebase detected, consider ${kleur.bold('--low-memory')}\n`)
156
+ }
157
+
158
+ if (report.project.driftConfigFile) {
159
+ process.stdout.write(`${icons.check} Drift config: ${report.project.driftConfigFile}\n`)
160
+ } else {
161
+ process.stdout.write(`${icons.warn} Drift config not found (drift.config.*)\n`)
162
+ }
163
+
164
+ process.stdout.write('\n')
165
+ }
166
+
167
+ export function formatDoctorJsonObject(report: DoctorReport): DoctorReportJson {
168
+ return withOutputMetadata(report, OUTPUT_SCHEMA.doctor)
169
+ }
170
+
171
+ export function formatDoctorJson(report: DoctorReport): string {
172
+ return JSON.stringify(formatDoctorJsonObject(report), null, 2)
173
+ }
174
+
175
+ export async function runDoctor(projectPath: string, options?: DoctorOptions): Promise<number> {
176
+ const report = buildDoctorReport(projectPath)
177
+
178
+ if (options?.json) {
179
+ process.stdout.write(`${formatDoctorJson(report)}\n`)
180
+ } else {
181
+ printConsoleReport(report)
182
+ }
183
+
184
+ return 0
185
+ }
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
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { GuardBaseline, IssueSeverity } from './guard-types.js'
4
+
5
+ export interface NormalizedBaseline {
6
+ score?: number
7
+ totalIssues?: number
8
+ bySeverity: Partial<Record<IssueSeverity, number>>
9
+ }
10
+
11
+ function parseNumber(value: unknown): number | undefined {
12
+ return typeof value === 'number' && !Number.isNaN(value) ? value : undefined
13
+ }
14
+
15
+ function firstDefinedNumber(values: unknown[]): number | undefined {
16
+ for (const value of values) {
17
+ const parsed = parseNumber(value)
18
+ if (parsed !== undefined) {
19
+ return parsed
20
+ }
21
+ }
22
+
23
+ return undefined
24
+ }
25
+
26
+ function normalizeSeverity(baseline: GuardBaseline, severity: IssueSeverity): number | undefined {
27
+ const summaryBySeverity = baseline.summary?.[`${severity}s` as 'errors' | 'warnings' | 'infos']
28
+
29
+ return firstDefinedNumber([
30
+ baseline.bySeverity?.[severity],
31
+ severity === 'error' ? baseline.errors : undefined,
32
+ severity === 'warning' ? baseline.warnings : undefined,
33
+ severity === 'info' ? baseline.infos : undefined,
34
+ summaryBySeverity,
35
+ ])
36
+ }
37
+
38
+ function hasAnchor(baseline: NormalizedBaseline): boolean {
39
+ if (baseline.score !== undefined || baseline.totalIssues !== undefined) {
40
+ return true
41
+ }
42
+
43
+ const severities: IssueSeverity[] = ['error', 'warning', 'info']
44
+ return severities.some((severity) => baseline.bySeverity[severity] !== undefined)
45
+ }
46
+
47
+ export function normalizeBaseline(baseline: GuardBaseline): NormalizedBaseline {
48
+ const normalized: NormalizedBaseline = {
49
+ score: parseNumber(baseline.score),
50
+ totalIssues: parseNumber(baseline.totalIssues),
51
+ bySeverity: {
52
+ error: normalizeSeverity(baseline, 'error'),
53
+ warning: normalizeSeverity(baseline, 'warning'),
54
+ info: normalizeSeverity(baseline, 'info'),
55
+ },
56
+ }
57
+
58
+ if (!hasAnchor(normalized)) {
59
+ throw new Error('Invalid guard baseline: expected score, totalIssues, or severity counters (error/warning/info).')
60
+ }
61
+
62
+ return normalized
63
+ }
64
+
65
+ export function readBaselineFromFile(projectPath: string, baselinePath?: string): { baseline: NormalizedBaseline; path: string } | undefined {
66
+ const resolvedBaselinePath = resolve(projectPath, baselinePath ?? 'drift-baseline.json')
67
+ if (!existsSync(resolvedBaselinePath)) return undefined
68
+
69
+ const raw = JSON.parse(readFileSync(resolvedBaselinePath, 'utf8')) as GuardBaseline
70
+ return {
71
+ baseline: normalizeBaseline(raw),
72
+ path: resolvedBaselinePath,
73
+ }
74
+ }
@@ -0,0 +1,52 @@
1
+ import type { DriftDiff, DriftIssue, DriftReport } from './types.js'
2
+ import type { GuardMetrics, IssueSeverity } from './guard-types.js'
3
+ import type { NormalizedBaseline } from './guard-baseline.js'
4
+
5
+ function createSeverityDelta(): Record<IssueSeverity, number> {
6
+ return {
7
+ error: 0,
8
+ warning: 0,
9
+ info: 0,
10
+ }
11
+ }
12
+
13
+ function applySeverityDelta(
14
+ delta: Record<IssueSeverity, number>,
15
+ issues: DriftIssue[],
16
+ direction: 1 | -1,
17
+ ): void {
18
+ for (const issue of issues) {
19
+ delta[issue.severity] += direction
20
+ }
21
+ }
22
+
23
+ function countSeverityDeltaFromDiff(diff: DriftDiff): Record<IssueSeverity, number> {
24
+ const severityDelta = createSeverityDelta()
25
+
26
+ for (const file of diff.files) {
27
+ applySeverityDelta(severityDelta, file.newIssues, 1)
28
+ applySeverityDelta(severityDelta, file.resolvedIssues, -1)
29
+ }
30
+
31
+ return severityDelta
32
+ }
33
+
34
+ export function buildMetricsFromDiff(diff: DriftDiff): GuardMetrics {
35
+ return {
36
+ scoreDelta: diff.totalDelta,
37
+ totalIssuesDelta: diff.newIssuesCount - diff.resolvedIssuesCount,
38
+ severityDelta: countSeverityDeltaFromDiff(diff),
39
+ }
40
+ }
41
+
42
+ export function buildMetricsFromBaseline(current: DriftReport, baseline: NormalizedBaseline): GuardMetrics {
43
+ return {
44
+ scoreDelta: current.totalScore - (baseline.score ?? current.totalScore),
45
+ totalIssuesDelta: current.totalIssues - (baseline.totalIssues ?? current.totalIssues),
46
+ severityDelta: {
47
+ error: current.summary.errors - (baseline.bySeverity.error ?? current.summary.errors),
48
+ warning: current.summary.warnings - (baseline.bySeverity.warning ?? current.summary.warnings),
49
+ info: current.summary.infos - (baseline.bySeverity.info ?? current.summary.infos),
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,66 @@
1
+ import type { DriftAnalysisOptions, DriftDiff, DriftIssue, DriftOutputMetadata, 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
+ }
65
+
66
+ export type GuardResultJson = GuardResult & DriftOutputMetadata
package/src/guard.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { relative, resolve } from 'node:path'
2
+ import { analyzeProject } from './analyzer.js'
3
+ import { loadConfig } from './config.js'
4
+ import { computeDiff } from './diff.js'
5
+ import { cleanupTempDir, extractFilesAtRef } from './git.js'
6
+ import { normalizeBaseline, readBaselineFromFile } from './guard-baseline.js'
7
+ import { buildMetricsFromBaseline, buildMetricsFromDiff } from './guard-metrics.js'
8
+ import { OUTPUT_SCHEMA, withOutputMetadata } from './output-metadata.js'
9
+ import { buildReport } from './reporter.js'
10
+ import type { DriftReport } from './types.js'
11
+ import type {
12
+ GuardCheck,
13
+ GuardEvaluation,
14
+ GuardMetrics,
15
+ GuardOptions,
16
+ GuardResult,
17
+ GuardResultJson,
18
+ GuardThresholds,
19
+ IssueSeverity,
20
+ } from './guard-types.js'
21
+ import type { NormalizedBaseline } from './guard-baseline.js'
22
+
23
+ interface GuardEvalInput {
24
+ metrics: GuardMetrics
25
+ budget?: number
26
+ bySeverity?: GuardThresholds
27
+ enforceNoRegression: {
28
+ score: boolean
29
+ totalIssues: boolean
30
+ }
31
+ }
32
+
33
+ interface GuardRuntimeState {
34
+ currentReport: DriftReport
35
+ config: Awaited<ReturnType<typeof loadConfig>>
36
+ projectPath: string
37
+ }
38
+
39
+ interface DiffGuardResultInput {
40
+ projectPath: string
41
+ currentReport: DriftReport
42
+ options: GuardOptions
43
+ tempDir: string
44
+ config: Awaited<ReturnType<typeof loadConfig>>
45
+ baseRef: string
46
+ }
47
+
48
+ interface BaselineGuardResultInput {
49
+ projectPath: string
50
+ currentReport: DriftReport
51
+ options: GuardOptions
52
+ baseline: NormalizedBaseline
53
+ baselinePath?: string
54
+ }
55
+
56
+ function remapBaseReportPaths(baseReport: DriftReport, tempDir: string, projectPath: string): DriftReport {
57
+ return {
58
+ ...baseReport,
59
+ files: baseReport.files.map((file) => ({
60
+ ...file,
61
+ path: resolve(projectPath, relative(tempDir, file.path)),
62
+ })),
63
+ }
64
+ }
65
+
66
+
67
+ interface GuardCheckInput {
68
+ id: string
69
+ actual: number
70
+ limit: number
71
+ message: string
72
+ }
73
+
74
+ function addCheck(checks: GuardCheck[], input: GuardCheckInput): void {
75
+ checks.push({
76
+ id: input.id,
77
+ passed: input.actual <= input.limit,
78
+ actual: input.actual,
79
+ limit: input.limit,
80
+ message: input.message,
81
+ })
82
+ }
83
+
84
+ export function evaluateGuard(input: GuardEvalInput): GuardEvaluation {
85
+ const checks: GuardCheck[] = []
86
+
87
+ if (input.enforceNoRegression.score) {
88
+ addCheck(checks, {
89
+ id: 'no-regression-score',
90
+ actual: input.metrics.scoreDelta,
91
+ limit: 0,
92
+ message: 'Score delta must be <= 0.',
93
+ })
94
+ }
95
+
96
+ if (input.enforceNoRegression.totalIssues) {
97
+ addCheck(checks, {
98
+ id: 'no-regression-total-issues',
99
+ actual: input.metrics.totalIssuesDelta,
100
+ limit: 0,
101
+ message: 'Total issues delta must be <= 0.',
102
+ })
103
+ }
104
+
105
+ if (typeof input.budget === 'number' && !Number.isNaN(input.budget)) {
106
+ addCheck(checks, {
107
+ id: 'budget-total-delta',
108
+ actual: input.metrics.scoreDelta,
109
+ limit: input.budget,
110
+ message: `Score delta must be <= budget (${input.budget}).`,
111
+ })
112
+ }
113
+
114
+ const severityThresholds = input.bySeverity
115
+ if (severityThresholds) {
116
+ const severities: IssueSeverity[] = ['error', 'warning', 'info']
117
+ for (const severity of severities) {
118
+ const threshold = severityThresholds[severity]
119
+ if (typeof threshold !== 'number' || Number.isNaN(threshold)) continue
120
+ addCheck(checks, {
121
+ id: `severity-${severity}`,
122
+ actual: input.metrics.severityDelta[severity],
123
+ limit: threshold,
124
+ message: `${severity} delta must be <= ${threshold}.`,
125
+ })
126
+ }
127
+ }
128
+
129
+ return {
130
+ passed: checks.every((check) => check.passed),
131
+ checks,
132
+ }
133
+ }
134
+
135
+ export function formatGuardJsonObject(result: GuardResult): GuardResultJson {
136
+ return withOutputMetadata(result, OUTPUT_SCHEMA.guard)
137
+ }
138
+
139
+ export function formatGuardJson(result: GuardResult): string {
140
+ return JSON.stringify(formatGuardJsonObject(result), null, 2)
141
+ }
142
+
143
+ export async function runGuard(targetPath: string, options: GuardOptions = {}): Promise<GuardResult> {
144
+ const runtimeState = await initializeGuardRuntime(targetPath, options)
145
+ const { projectPath, config, currentReport } = runtimeState
146
+
147
+ let tempDir: string | undefined
148
+ try {
149
+ if (options.baseRef) {
150
+ tempDir = extractFilesAtRef(projectPath, options.baseRef)
151
+ return createDiffGuardResult({
152
+ projectPath,
153
+ currentReport,
154
+ options,
155
+ tempDir,
156
+ config,
157
+ baseRef: options.baseRef,
158
+ })
159
+ }
160
+
161
+ const inlineBaseline = options.baseline ? normalizeBaseline(options.baseline) : undefined
162
+ const fileBaseline = inlineBaseline ? undefined : readBaselineFromFile(projectPath, options.baselinePath)
163
+ const baseline = inlineBaseline ?? fileBaseline?.baseline
164
+ const baselinePath = fileBaseline?.path
165
+
166
+ if (!baseline) {
167
+ throw new Error('Guard requires a comparison point: provide baseRef or a baseline (inline or file).')
168
+ }
169
+
170
+ return createBaselineGuardResult({
171
+ projectPath,
172
+ currentReport,
173
+ options,
174
+ baseline,
175
+ baselinePath,
176
+ })
177
+ } finally {
178
+ if (tempDir) cleanupTempDir(tempDir)
179
+ }
180
+ }
181
+
182
+ async function initializeGuardRuntime(targetPath: string, options: GuardOptions): Promise<GuardRuntimeState> {
183
+ const projectPath = resolve(targetPath)
184
+ const config = await loadConfig(projectPath)
185
+ const currentFiles = analyzeProject(projectPath, config, options.analysis)
186
+ const currentReport = buildReport(projectPath, currentFiles)
187
+
188
+ return {
189
+ projectPath,
190
+ config,
191
+ currentReport,
192
+ }
193
+ }
194
+
195
+ function createDiffGuardResult(input: DiffGuardResultInput): GuardResult {
196
+ const { projectPath, currentReport, options, tempDir, config, baseRef } = input
197
+ const baseFiles = analyzeProject(tempDir, config, options.analysis)
198
+ const baseReport = buildReport(tempDir, baseFiles)
199
+ const remappedBase = remapBaseReportPaths(baseReport, tempDir, projectPath)
200
+ const diff = computeDiff(remappedBase, currentReport, baseRef)
201
+ const metrics = buildMetricsFromDiff(diff)
202
+ const evaluation = evaluateGuard({
203
+ metrics,
204
+ budget: options.budget,
205
+ bySeverity: options.bySeverity,
206
+ enforceNoRegression: {
207
+ score: true,
208
+ totalIssues: true,
209
+ },
210
+ })
211
+
212
+ return {
213
+ scannedAt: new Date().toISOString(),
214
+ projectPath,
215
+ mode: 'diff',
216
+ passed: evaluation.passed,
217
+ baseRef,
218
+ metrics,
219
+ checks: evaluation.checks,
220
+ current: currentReport,
221
+ diff,
222
+ }
223
+ }
224
+
225
+ function createBaselineGuardResult(input: BaselineGuardResultInput): GuardResult {
226
+ const { projectPath, currentReport, options, baseline, baselinePath } = input
227
+ const metrics = buildMetricsFromBaseline(currentReport, baseline)
228
+ const evaluation = evaluateGuard({
229
+ metrics,
230
+ budget: options.budget,
231
+ bySeverity: options.bySeverity,
232
+ enforceNoRegression: {
233
+ score: baseline.score !== undefined,
234
+ totalIssues: baseline.totalIssues !== undefined,
235
+ },
236
+ })
237
+
238
+ return {
239
+ scannedAt: new Date().toISOString(),
240
+ projectPath,
241
+ mode: 'baseline',
242
+ passed: evaluation.passed,
243
+ baselinePath,
244
+ metrics,
245
+ checks: evaluation.checks,
246
+ current: currentReport,
247
+ }
248
+ }