@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.
- package/.gga +50 -0
- package/.github/actions/drift-review/README.md +62 -0
- package/.github/actions/drift-review/action.yml +148 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/publish-vscode.yml +1 -3
- package/.github/workflows/publish.yml +8 -0
- package/.github/workflows/quality.yml +15 -0
- package/.github/workflows/reusable-quality-checks.yml +95 -0
- package/.github/workflows/review-pr.yml +33 -41
- package/AGENTS.md +75 -251
- package/CHANGELOG.md +41 -0
- package/README.md +177 -43
- package/benchmarks/fixtures/critical/drift.config.ts +21 -0
- package/benchmarks/fixtures/critical/src/app/user-service.ts +30 -0
- package/benchmarks/fixtures/critical/src/domain/entities.ts +19 -0
- package/benchmarks/fixtures/critical/src/domain/policies.ts +22 -0
- package/benchmarks/fixtures/critical/src/index.ts +10 -0
- package/benchmarks/fixtures/critical/src/infra/memory-user-repo.ts +14 -0
- package/benchmarks/perf-budget.v1.json +27 -0
- package/dist/benchmark.d.ts +1 -1
- package/dist/benchmark.js +83 -52
- package/dist/cli.js +243 -8
- package/dist/config.js +16 -2
- package/dist/diff.js +42 -50
- package/dist/doctor.d.ts +26 -0
- package/dist/doctor.js +140 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/guard-baseline.d.ts +12 -0
- package/dist/guard-baseline.js +57 -0
- package/dist/guard-metrics.d.ts +6 -0
- package/dist/guard-metrics.js +39 -0
- package/dist/guard-types.d.ts +58 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +16 -0
- package/dist/guard.js +178 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +4 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +15 -0
- package/dist/output-metadata.js +19 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +1 -1
- package/dist/plugins.js +45 -142
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +2 -1
- package/dist/rules/phase3-configurable.js +23 -15
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -218
- package/dist/saas.js +7 -761
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +1 -3
- package/dist/trust-kpi.js +6 -266
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +5 -32
- package/dist/trust.js +29 -432
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -365
- package/docs/AGENTS.md +1 -1
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +37 -5
- package/package.json +11 -4
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-doctor.v1.json +57 -0
- package/schemas/drift-guard.v1.json +298 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/check-docs-drift.mjs +154 -0
- package/scripts/check-performance-budget.mjs +360 -0
- package/scripts/check-runtime-policy.mjs +66 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/benchmark.ts +92 -53
- package/src/cli.ts +285 -13
- package/src/config.ts +19 -2
- package/src/diff.ts +57 -48
- package/src/doctor.ts +185 -0
- package/src/format.ts +81 -0
- package/src/guard-baseline.ts +74 -0
- package/src/guard-metrics.ts +52 -0
- package/src/guard-types.ts +66 -0
- package/src/guard.ts +248 -0
- package/src/index.ts +36 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +32 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +76 -283
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +4 -2
- package/src/rules/phase3-configurable.ts +39 -26
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -1031
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +8 -316
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +62 -576
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +79 -409
- package/tests/ci-quality-matrix.test.ts +37 -0
- package/tests/ci-smoke-gate.test.ts +26 -0
- package/tests/ci-version-alignment.test.ts +93 -0
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/docs-drift-check.test.ts +115 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +11 -3
- package/tests/perf-budget-check.test.ts +146 -0
- package/tests/phase1-init-doctor-guard.test.ts +301 -0
- package/tests/runtime-policy-alignment.test.ts +46 -0
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +31 -4
- package/tests/trust.test.ts +18 -0
- 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()
|