@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
@@ -0,0 +1,154 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ function readRepoFile(rootDir, relativePath) {
6
+ return readFileSync(join(rootDir, relativePath), 'utf8')
7
+ }
8
+
9
+ function readPackageVersion(rootDir) {
10
+ const packageJson = JSON.parse(readRepoFile(rootDir, 'package.json'))
11
+ const version = packageJson?.version
12
+ if (typeof version !== 'string' || version.length === 0) {
13
+ throw new Error('package.json is missing a valid version field')
14
+ }
15
+ return version
16
+ }
17
+
18
+ function extractRuleIdsFromAnalyzer(analyzerContent) {
19
+ const blockMatch = analyzerContent.match(/export const RULE_WEIGHTS[\s\S]*?=\s*\{([\s\S]*?)\n\}/)
20
+ if (!blockMatch) {
21
+ throw new Error('Could not locate RULE_WEIGHTS block in src/analyzer.ts')
22
+ }
23
+
24
+ return Array.from(blockMatch[1].matchAll(/'([^']+)'\s*:/g), (match) => match[1])
25
+ }
26
+
27
+ function extractRuleIdsFromCatalog(catalogContent) {
28
+ const ids = []
29
+ for (const match of catalogContent.matchAll(/^\|\s*`([^`]+)`\s*\|/gm)) {
30
+ const id = match[1]
31
+ if (id !== 'id') {
32
+ ids.push(id)
33
+ }
34
+ }
35
+ return ids
36
+ }
37
+
38
+ function extractSingleNumber(content, pattern, errorMessage) {
39
+ const match = content.match(pattern)
40
+ if (!match) {
41
+ throw new Error(errorMessage)
42
+ }
43
+ return Number.parseInt(match[1], 10)
44
+ }
45
+
46
+ function extractAgentsVersion(agentsContent) {
47
+ const match = agentsContent.match(/Versi[oó]n del paquete:\s*`([^`]+)`/)
48
+ if (!match) {
49
+ throw new Error('AGENTS.md must include "Versión del paquete: `<version>`"')
50
+ }
51
+ return match[1]
52
+ }
53
+
54
+ function compareRuleSets(sourceRuleIds, catalogRuleIds) {
55
+ const sourceSet = new Set(sourceRuleIds)
56
+ const catalogSet = new Set(catalogRuleIds)
57
+
58
+ const missingInCatalog = [...sourceSet].filter((ruleId) => !catalogSet.has(ruleId)).sort()
59
+ const extraInCatalog = [...catalogSet].filter((ruleId) => !sourceSet.has(ruleId)).sort()
60
+
61
+ return { missingInCatalog, extraInCatalog }
62
+ }
63
+
64
+ export function validateDocsDrift(rootDir = process.cwd()) {
65
+ const packageVersion = readPackageVersion(rootDir)
66
+ const analyzer = readRepoFile(rootDir, 'src/analyzer.ts')
67
+ const rulesCatalog = readRepoFile(rootDir, 'docs/rules-catalog.md')
68
+ const readme = readRepoFile(rootDir, 'README.md')
69
+ const agents = readRepoFile(rootDir, 'AGENTS.md')
70
+
71
+ const sourceRuleIds = extractRuleIdsFromAnalyzer(analyzer)
72
+ const catalogRuleIds = extractRuleIdsFromCatalog(rulesCatalog)
73
+ const sourceRuleCount = sourceRuleIds.length
74
+
75
+ const readmeRuleCount = extractSingleNumber(
76
+ readme,
77
+ /defines\s+\*\*(\d+)\s+rule IDs\*\*/,
78
+ 'README.md must declare the current rule ID count as "defines **<n> rule IDs**"',
79
+ )
80
+ const agentsRuleCount = extractSingleNumber(
81
+ agents,
82
+ /Estado actual:\s+\*\*(\d+)\s+rule IDs\*\*/,
83
+ 'AGENTS.md must declare the current rule ID count as "Estado actual: **<n> rule IDs**"',
84
+ )
85
+ const catalogRuleCount = extractSingleNumber(
86
+ rulesCatalog,
87
+ /Total rule IDs currently defined:\s+\*\*(\d+)\*\*/,
88
+ 'docs/rules-catalog.md must declare the current rule count line',
89
+ )
90
+ const agentsVersion = extractAgentsVersion(agents)
91
+ const { missingInCatalog, extraInCatalog } = compareRuleSets(sourceRuleIds, catalogRuleIds)
92
+
93
+ const errors = []
94
+
95
+ if (agentsVersion !== packageVersion) {
96
+ errors.push(`AGENTS.md package version (${agentsVersion}) does not match package.json (${packageVersion})`)
97
+ }
98
+
99
+ if (!rulesCatalog.includes('Source of truth: `RULE_WEIGHTS` in `src/analyzer.ts`.')) {
100
+ errors.push('docs/rules-catalog.md must explicitly declare RULE_WEIGHTS in src/analyzer.ts as source of truth')
101
+ }
102
+
103
+ if (catalogRuleIds.length !== sourceRuleCount) {
104
+ errors.push(`docs/rules-catalog.md table has ${catalogRuleIds.length} rule IDs, but RULE_WEIGHTS defines ${sourceRuleCount}`)
105
+ }
106
+
107
+ if (missingInCatalog.length > 0) {
108
+ errors.push(`rules missing in docs/rules-catalog.md: ${missingInCatalog.join(', ')}`)
109
+ }
110
+
111
+ if (extraInCatalog.length > 0) {
112
+ errors.push(`rules present in docs/rules-catalog.md but not in RULE_WEIGHTS: ${extraInCatalog.join(', ')}`)
113
+ }
114
+
115
+ if (readmeRuleCount !== sourceRuleCount) {
116
+ errors.push(`README.md rule ID count (${readmeRuleCount}) does not match RULE_WEIGHTS (${sourceRuleCount})`)
117
+ }
118
+
119
+ if (agentsRuleCount !== sourceRuleCount) {
120
+ errors.push(`AGENTS.md rule ID count (${agentsRuleCount}) does not match RULE_WEIGHTS (${sourceRuleCount})`)
121
+ }
122
+
123
+ if (catalogRuleCount !== sourceRuleCount) {
124
+ errors.push(`docs/rules-catalog.md total rule ID count (${catalogRuleCount}) does not match RULE_WEIGHTS (${sourceRuleCount})`)
125
+ }
126
+
127
+ return {
128
+ ok: errors.length === 0,
129
+ packageVersion,
130
+ sourceRuleCount,
131
+ errors,
132
+ }
133
+ }
134
+
135
+ export function runDocsDriftCheck(rootDir = process.cwd()) {
136
+ const result = validateDocsDrift(rootDir)
137
+
138
+ if (!result.ok) {
139
+ process.stderr.write('Docs drift check failed:\n')
140
+ for (const error of result.errors) {
141
+ process.stderr.write(`- ${error}\n`)
142
+ }
143
+ process.exitCode = 1
144
+ return
145
+ }
146
+
147
+ process.stdout.write(
148
+ `Docs drift check passed: package version ${result.packageVersion}, rule IDs ${result.sourceRuleCount}, docs aligned.\n`,
149
+ )
150
+ }
151
+
152
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
153
+ runDocsDriftCheck()
154
+ }
@@ -0,0 +1,360 @@
1
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { tmpdir } from 'node:os'
5
+ import { pathToFileURL } from 'node:url'
6
+
7
+ const BUDGET_SCHEMA_VERSION = 'drift-perf-budget/v1'
8
+ const BENCHMARK_RESULT_SCHEMA = 'drift-perf-check-result/v1'
9
+ const DEFAULT_BUDGET_PATH = 'benchmarks/perf-budget.v1.json'
10
+ const DEFAULT_RESULT_PATH = '.drift-perf/benchmark-latest.json'
11
+ const TASK_IDS = ['scan', 'review', 'trust']
12
+
13
+ function runGit(cwd, args) {
14
+ return spawnSync('git', args, {
15
+ cwd,
16
+ encoding: 'utf8',
17
+ })
18
+ }
19
+
20
+ function parseArgs(argv) {
21
+ const parsed = {
22
+ budgetPath: DEFAULT_BUDGET_PATH,
23
+ resultPath: DEFAULT_RESULT_PATH,
24
+ benchmarkResultPath: undefined,
25
+ }
26
+
27
+ let index = 0
28
+ while (index < argv.length) {
29
+ const token = argv[index]
30
+ if (token === '--budget') {
31
+ const next = argv[index + 1]
32
+ if (!next) throw new Error('--budget requires a value')
33
+ parsed.budgetPath = next
34
+ index += 2
35
+ continue
36
+ }
37
+
38
+ if (token === '--out') {
39
+ const next = argv[index + 1]
40
+ if (!next) throw new Error('--out requires a value')
41
+ parsed.resultPath = next
42
+ index += 2
43
+ continue
44
+ }
45
+
46
+ if (token === '--result') {
47
+ const next = argv[index + 1]
48
+ if (!next) throw new Error('--result requires a value')
49
+ parsed.benchmarkResultPath = next
50
+ index += 2
51
+ continue
52
+ }
53
+
54
+ throw new Error(`Unknown argument: ${token}`)
55
+ }
56
+
57
+ return parsed
58
+ }
59
+
60
+ function readJson(filePath) {
61
+ return JSON.parse(readFileSync(filePath, 'utf8'))
62
+ }
63
+
64
+ function asNonNegativeNumber(value, label) {
65
+ if (!Number.isFinite(value) || value < 0) {
66
+ throw new Error(`${label} must be a non-negative number`)
67
+ }
68
+ return value
69
+ }
70
+
71
+ function validateBudgetSchema(budget) {
72
+ if (budget?.schemaVersion !== BUDGET_SCHEMA_VERSION) {
73
+ throw new Error(`Unsupported budget schemaVersion. Expected '${BUDGET_SCHEMA_VERSION}'`)
74
+ }
75
+
76
+ for (const taskId of TASK_IDS) {
77
+ const taskBudget = budget?.tasks?.[taskId]
78
+ if (!taskBudget) {
79
+ throw new Error(`Missing budget entry for task '${taskId}'`)
80
+ }
81
+ asNonNegativeNumber(taskBudget.maxMedianMs, `tasks.${taskId}.maxMedianMs`)
82
+ asNonNegativeNumber(taskBudget.maxRssMb, `tasks.${taskId}.maxRssMb`)
83
+ }
84
+
85
+ asNonNegativeNumber(budget?.tolerance?.runtimePct ?? 0, 'tolerance.runtimePct')
86
+ asNonNegativeNumber(budget?.tolerance?.memoryPct ?? 0, 'tolerance.memoryPct')
87
+
88
+ asNonNegativeNumber(budget?.benchmark?.warmupRuns, 'benchmark.warmupRuns')
89
+ const measuredRuns = asNonNegativeNumber(budget?.benchmark?.measuredRuns, 'benchmark.measuredRuns')
90
+ if (measuredRuns < 1) {
91
+ throw new Error('benchmark.measuredRuns must be at least 1')
92
+ }
93
+ }
94
+
95
+ function benchmarkTaskMap(benchmarkResult) {
96
+ const map = new Map()
97
+ for (const taskResult of benchmarkResult?.results ?? []) {
98
+ map.set(taskResult.name, taskResult)
99
+ }
100
+ return map
101
+ }
102
+
103
+ export function evaluatePerformanceBudget(budget, benchmarkResult) {
104
+ validateBudgetSchema(budget)
105
+
106
+ const runtimeToleranceFactor = 1 + ((budget?.tolerance?.runtimePct ?? 0) / 100)
107
+ const memoryToleranceFactor = 1 + ((budget?.tolerance?.memoryPct ?? 0) / 100)
108
+ const byTask = benchmarkTaskMap(benchmarkResult)
109
+ const failures = []
110
+ const checks = []
111
+
112
+ for (const taskId of TASK_IDS) {
113
+ const taskBudget = budget.tasks[taskId]
114
+ const measured = byTask.get(taskId)
115
+ if (!measured) {
116
+ failures.push(`Benchmark output is missing task '${taskId}'`)
117
+ continue
118
+ }
119
+
120
+ const medianMs = asNonNegativeNumber(measured.medianMs, `results.${taskId}.medianMs`)
121
+ const maxRssMb = asNonNegativeNumber(measured.maxRssMb, `results.${taskId}.maxRssMb`)
122
+ const allowedMedianMs = taskBudget.maxMedianMs * runtimeToleranceFactor
123
+ const allowedMaxRssMb = taskBudget.maxRssMb * memoryToleranceFactor
124
+
125
+ const runtimePassed = medianMs <= allowedMedianMs
126
+ const memoryPassed = maxRssMb <= allowedMaxRssMb
127
+
128
+ checks.push({
129
+ task: taskId,
130
+ measured: { medianMs, maxRssMb },
131
+ budget: {
132
+ maxMedianMs: taskBudget.maxMedianMs,
133
+ maxRssMb: taskBudget.maxRssMb,
134
+ allowedMedianMs,
135
+ allowedMaxRssMb,
136
+ },
137
+ passed: runtimePassed && memoryPassed,
138
+ runtimePassed,
139
+ memoryPassed,
140
+ })
141
+
142
+ if (!runtimePassed) {
143
+ failures.push(
144
+ `${taskId}: median runtime ${medianMs.toFixed(2)}ms exceeds allowed ${allowedMedianMs.toFixed(2)}ms (budget ${taskBudget.maxMedianMs}ms + tolerance ${budget.tolerance.runtimePct}%)`,
145
+ )
146
+ }
147
+
148
+ if (!memoryPassed) {
149
+ failures.push(
150
+ `${taskId}: max RSS ${maxRssMb.toFixed(2)}MB exceeds allowed ${allowedMaxRssMb.toFixed(2)}MB (budget ${taskBudget.maxRssMb}MB + tolerance ${budget.tolerance.memoryPct}%)`,
151
+ )
152
+ }
153
+ }
154
+
155
+ return {
156
+ ok: failures.length === 0,
157
+ failures,
158
+ checks,
159
+ }
160
+ }
161
+
162
+ function createBenchmarkArgs(rootDir, budgetPath, budget, outputPath) {
163
+ const tsxLoaderPath = resolve(rootDir, 'node_modules', 'tsx', 'dist', 'loader.mjs')
164
+ if (!existsSync(tsxLoaderPath)) {
165
+ throw new Error(`Missing tsx loader at ${tsxLoaderPath}. Run npm ci first.`)
166
+ }
167
+
168
+ const benchmarkEntry = resolve(rootDir, 'src', 'benchmark.ts')
169
+ const tsxLoaderSpecifier = pathToFileURL(tsxLoaderPath).href
170
+
171
+ const benchmark = resolveBenchmarkContext(rootDir, budget)
172
+ const args = [
173
+ '--import',
174
+ tsxLoaderSpecifier,
175
+ benchmarkEntry,
176
+ '--scan-path',
177
+ benchmark.scanPath,
178
+ '--review-path',
179
+ benchmark.reviewPath,
180
+ '--trust-path',
181
+ benchmark.trustPath,
182
+ '--base',
183
+ String(benchmark.baseRef),
184
+ '--warmup',
185
+ String(benchmark.warmupRuns),
186
+ '--runs',
187
+ String(benchmark.measuredRuns),
188
+ '--json-out',
189
+ outputPath,
190
+ ]
191
+
192
+ return {
193
+ budgetPath,
194
+ benchmarkEntry,
195
+ args,
196
+ cleanup: benchmark.cleanup,
197
+ }
198
+ }
199
+
200
+ function createCommittedFixtureRepo(rootDir, fixturePath) {
201
+ const resolvedFixturePath = resolve(rootDir, fixturePath)
202
+ if (!existsSync(resolvedFixturePath)) {
203
+ throw new Error(`Benchmark fixture path does not exist: ${resolvedFixturePath}`)
204
+ }
205
+
206
+ const tempRepo = mkdtempSync(join(tmpdir(), 'drift-perf-fixture-'))
207
+ cpSync(resolvedFixturePath, tempRepo, { recursive: true })
208
+
209
+ const init = runGit(tempRepo, ['init'])
210
+ if (init.status !== 0) {
211
+ throw new Error(`Failed to initialize git fixture repository: ${init.stderr ?? ''}`)
212
+ }
213
+
214
+ const add = runGit(tempRepo, ['add', '.'])
215
+ if (add.status !== 0) {
216
+ throw new Error(`Failed to stage git fixture files: ${add.stderr ?? ''}`)
217
+ }
218
+
219
+ const commit = runGit(tempRepo, [
220
+ '-c',
221
+ 'user.name=drift-ci',
222
+ '-c',
223
+ 'user.email=drift-ci@example.com',
224
+ 'commit',
225
+ '-m',
226
+ 'fixture baseline',
227
+ ])
228
+
229
+ if (commit.status !== 0) {
230
+ throw new Error(`Failed to commit git fixture baseline: ${commit.stderr ?? ''}`)
231
+ }
232
+
233
+ return {
234
+ repoPath: tempRepo,
235
+ cleanup: () => {
236
+ rmSync(tempRepo, { recursive: true, force: true })
237
+ },
238
+ }
239
+ }
240
+
241
+ function resolveBenchmarkContext(rootDir, budget) {
242
+ const benchmark = budget?.benchmark ?? {}
243
+ const warmupRuns = benchmark.warmupRuns
244
+ const measuredRuns = benchmark.measuredRuns
245
+
246
+ if (typeof benchmark.fixturePath === 'string' && benchmark.fixturePath.trim().length > 0) {
247
+ const fixtureRepo = createCommittedFixtureRepo(rootDir, benchmark.fixturePath)
248
+ return {
249
+ scanPath: fixtureRepo.repoPath,
250
+ reviewPath: fixtureRepo.repoPath,
251
+ trustPath: fixtureRepo.repoPath,
252
+ baseRef: 'HEAD',
253
+ warmupRuns,
254
+ measuredRuns,
255
+ cleanup: fixtureRepo.cleanup,
256
+ }
257
+ }
258
+
259
+ if (!benchmark.scanPath || !benchmark.reviewPath || !benchmark.trustPath || !benchmark.baseRef) {
260
+ throw new Error('benchmark must provide fixturePath or scanPath/reviewPath/trustPath/baseRef')
261
+ }
262
+
263
+ return {
264
+ scanPath: resolve(rootDir, benchmark.scanPath),
265
+ reviewPath: resolve(rootDir, benchmark.reviewPath),
266
+ trustPath: resolve(rootDir, benchmark.trustPath),
267
+ baseRef: benchmark.baseRef,
268
+ warmupRuns,
269
+ measuredRuns,
270
+ cleanup: undefined,
271
+ }
272
+ }
273
+
274
+ function runBenchmark(rootDir, budgetPath, budget, resultPath) {
275
+ mkdirSync(dirname(resultPath), { recursive: true })
276
+
277
+ const benchmark = createBenchmarkArgs(rootDir, budgetPath, budget, resultPath)
278
+ const execution = spawnSync(process.execPath, benchmark.args, {
279
+ cwd: rootDir,
280
+ encoding: 'utf8',
281
+ })
282
+
283
+ if (benchmark.cleanup) {
284
+ benchmark.cleanup()
285
+ }
286
+
287
+ if (execution.status !== 0) {
288
+ const errorOutput = `${execution.stdout ?? ''}${execution.stderr ?? ''}`.trim()
289
+ throw new Error(`Benchmark command failed (${benchmark.benchmarkEntry}):\n${errorOutput}`)
290
+ }
291
+
292
+ if (!existsSync(resultPath)) {
293
+ throw new Error(`Benchmark did not produce expected JSON output at ${resultPath}`)
294
+ }
295
+
296
+ return readJson(resultPath)
297
+ }
298
+
299
+ export function runPerformanceBudgetCheck(rootDir = process.cwd(), argv = process.argv.slice(2)) {
300
+ const parsed = parseArgs(argv)
301
+ const budgetPath = resolve(rootDir, parsed.budgetPath)
302
+ const resultPath = resolve(rootDir, parsed.resultPath)
303
+ const budget = readJson(budgetPath)
304
+
305
+ const benchmarkResult = parsed.benchmarkResultPath
306
+ ? readJson(resolve(rootDir, parsed.benchmarkResultPath))
307
+ : runBenchmark(rootDir, budgetPath, budget, resultPath)
308
+
309
+ const gateResultPath = resolve(dirname(resultPath), 'perf-gate-result.json')
310
+
311
+ const evaluation = evaluatePerformanceBudget(budget, benchmarkResult)
312
+ const gateResult = {
313
+ schemaVersion: BENCHMARK_RESULT_SCHEMA,
314
+ generatedAt: new Date().toISOString(),
315
+ budgetFile: budgetPath,
316
+ budgetVersion: budget.budgetVersion,
317
+ benchmarkFile: parsed.benchmarkResultPath ? resolve(rootDir, parsed.benchmarkResultPath) : resultPath,
318
+ ok: evaluation.ok,
319
+ checks: evaluation.checks,
320
+ failures: evaluation.failures,
321
+ }
322
+
323
+ mkdirSync(dirname(resultPath), { recursive: true })
324
+ const gateResultSerialized = `${JSON.stringify(gateResult, null, 2)}\n`
325
+ writeFileSync(gateResultPath, gateResultSerialized, 'utf8')
326
+
327
+ if (!parsed.benchmarkResultPath) {
328
+ process.stdout.write(`Performance benchmark generated: ${resultPath}\n`)
329
+ }
330
+ process.stdout.write(`Performance gate report: ${gateResultPath}\n`)
331
+
332
+ process.stdout.write(`Performance budget version: ${budget.budgetVersion}\n`)
333
+ for (const check of evaluation.checks) {
334
+ process.stdout.write(
335
+ `- ${check.task}: median ${check.measured.medianMs.toFixed(2)}ms (<= ${check.budget.allowedMedianMs.toFixed(2)}ms), max RSS ${check.measured.maxRssMb.toFixed(2)}MB (<= ${check.budget.allowedMaxRssMb.toFixed(2)}MB)\n`,
336
+ )
337
+ }
338
+
339
+ if (!evaluation.ok) {
340
+ process.stderr.write('Performance budget check failed:\n')
341
+ for (const failure of evaluation.failures) {
342
+ process.stderr.write(`- ${failure}\n`)
343
+ }
344
+ process.exitCode = 1
345
+ return gateResult
346
+ }
347
+
348
+ process.stdout.write('Performance budget check passed.\n')
349
+ return gateResult
350
+ }
351
+
352
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
353
+ try {
354
+ runPerformanceBudgetCheck()
355
+ } catch (error) {
356
+ const message = error instanceof Error ? error.message : String(error)
357
+ process.stderr.write(`Performance budget check failed: ${message}\n`)
358
+ process.exit(1)
359
+ }
360
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const EXPECTED_ENGINE_RANGE = '^20.0.0 || ^22.0.0'
5
+ const EXPECTED_NODE_MATRIX = '["20", "22"]'
6
+ const EXPECTED_README_RUNTIME = '**Runtime:** Node.js 20.x and 22.x (LTS)'
7
+ const EXPECTED_INIT_TEMPLATE_NODE_VERSION = 'node-version: 20'
8
+
9
+ function readRepoFile(relativePath) {
10
+ return readFileSync(join(process.cwd(), relativePath), 'utf8')
11
+ }
12
+
13
+ function assertIncludes(content, expected, errorMessage) {
14
+ if (!content.includes(expected)) {
15
+ throw new Error(errorMessage)
16
+ }
17
+ }
18
+
19
+ function main() {
20
+ const packageJson = JSON.parse(readRepoFile('package.json'))
21
+ const engineRange = packageJson?.engines?.node
22
+
23
+ if (engineRange !== EXPECTED_ENGINE_RANGE) {
24
+ throw new Error(
25
+ `Invalid package.json engines.node: expected "${EXPECTED_ENGINE_RANGE}", got "${String(engineRange)}"`,
26
+ )
27
+ }
28
+
29
+ const qualityWorkflow = readRepoFile('.github/workflows/quality.yml')
30
+ assertIncludes(
31
+ qualityWorkflow,
32
+ `node_versions: '${EXPECTED_NODE_MATRIX}'`,
33
+ `quality.yml must declare node_versions: '${EXPECTED_NODE_MATRIX}'`,
34
+ )
35
+
36
+ const reusableWorkflow = readRepoFile('.github/workflows/reusable-quality-checks.yml')
37
+ assertIncludes(
38
+ reusableWorkflow,
39
+ `default: '${EXPECTED_NODE_MATRIX}'`,
40
+ `reusable-quality-checks.yml must declare default: '${EXPECTED_NODE_MATRIX}'`,
41
+ )
42
+
43
+ const initTemplate = readRepoFile('src/init.ts')
44
+ assertIncludes(
45
+ initTemplate,
46
+ EXPECTED_INIT_TEMPLATE_NODE_VERSION,
47
+ `src/init.ts workflow template must include: ${EXPECTED_INIT_TEMPLATE_NODE_VERSION}`,
48
+ )
49
+
50
+ const readme = readRepoFile('README.md')
51
+ assertIncludes(
52
+ readme,
53
+ EXPECTED_README_RUNTIME,
54
+ `README runtime line must include: ${EXPECTED_README_RUNTIME}`,
55
+ )
56
+
57
+ const lockfile = readRepoFile('package-lock.json')
58
+ assertIncludes(lockfile, '"node_modules/commander"', 'package-lock must include commander entry')
59
+ assertIncludes(lockfile, '"node": ">=20"', 'commander dependency requires Node >=20; runtime policy cannot be lower')
60
+
61
+ process.stdout.write(
62
+ `Runtime policy check passed: engines.node=${EXPECTED_ENGINE_RANGE}, matrix=${EXPECTED_NODE_MATRIX}, docs aligned.\n`,
63
+ )
64
+ }
65
+
66
+ main()