@eduardbar/drift 1.4.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 (66) hide show
  1. package/.github/actions/drift-review/README.md +4 -2
  2. package/.github/actions/drift-review/action.yml +22 -5
  3. package/.github/actions/drift-scan/README.md +3 -3
  4. package/.github/actions/drift-scan/action.yml +1 -1
  5. package/.github/workflows/publish-vscode.yml +1 -3
  6. package/.github/workflows/publish.yml +8 -0
  7. package/.github/workflows/quality.yml +15 -0
  8. package/.github/workflows/reusable-quality-checks.yml +95 -0
  9. package/.github/workflows/review-pr.yml +0 -1
  10. package/AGENTS.md +2 -2
  11. package/CHANGELOG.md +14 -1
  12. package/README.md +30 -3
  13. package/benchmarks/fixtures/critical/drift.config.ts +21 -0
  14. package/benchmarks/fixtures/critical/src/app/user-service.ts +30 -0
  15. package/benchmarks/fixtures/critical/src/domain/entities.ts +19 -0
  16. package/benchmarks/fixtures/critical/src/domain/policies.ts +22 -0
  17. package/benchmarks/fixtures/critical/src/index.ts +10 -0
  18. package/benchmarks/fixtures/critical/src/infra/memory-user-repo.ts +14 -0
  19. package/benchmarks/perf-budget.v1.json +27 -0
  20. package/dist/benchmark.js +12 -0
  21. package/dist/cli.js +2 -2
  22. package/dist/doctor.d.ts +21 -0
  23. package/dist/doctor.js +10 -3
  24. package/dist/guard-baseline.d.ts +12 -0
  25. package/dist/guard-baseline.js +57 -0
  26. package/dist/guard-metrics.d.ts +6 -0
  27. package/dist/guard-metrics.js +39 -0
  28. package/dist/guard-types.d.ts +2 -1
  29. package/dist/guard.d.ts +3 -1
  30. package/dist/guard.js +9 -70
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/init.js +1 -1
  34. package/dist/output-metadata.d.ts +2 -0
  35. package/dist/output-metadata.js +2 -0
  36. package/dist/trust.d.ts +2 -1
  37. package/dist/trust.js +1 -1
  38. package/dist/types.d.ts +1 -1
  39. package/docs/AGENTS.md +1 -1
  40. package/package.json +10 -4
  41. package/schemas/drift-doctor.v1.json +57 -0
  42. package/schemas/drift-guard.v1.json +298 -0
  43. package/scripts/check-docs-drift.mjs +154 -0
  44. package/scripts/check-performance-budget.mjs +360 -0
  45. package/scripts/check-runtime-policy.mjs +66 -0
  46. package/src/benchmark.ts +17 -0
  47. package/src/cli.ts +2 -2
  48. package/src/doctor.ts +15 -3
  49. package/src/guard-baseline.ts +74 -0
  50. package/src/guard-metrics.ts +52 -0
  51. package/src/guard-types.ts +3 -1
  52. package/src/guard.ts +14 -90
  53. package/src/index.ts +1 -0
  54. package/src/init.ts +1 -1
  55. package/src/output-metadata.ts +2 -0
  56. package/src/trust.ts +1 -1
  57. package/src/types.ts +1 -0
  58. package/tests/ci-quality-matrix.test.ts +37 -0
  59. package/tests/ci-smoke-gate.test.ts +26 -0
  60. package/tests/ci-version-alignment.test.ts +93 -0
  61. package/tests/docs-drift-check.test.ts +115 -0
  62. package/tests/new-features.test.ts +2 -2
  63. package/tests/perf-budget-check.test.ts +146 -0
  64. package/tests/phase1-init-doctor-guard.test.ts +104 -2
  65. package/tests/runtime-policy-alignment.test.ts +46 -0
  66. package/vitest.config.ts +2 -0
package/src/guard.ts CHANGED
@@ -1,27 +1,24 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
1
  import { relative, resolve } from 'node:path'
3
2
  import { analyzeProject } from './analyzer.js'
4
3
  import { loadConfig } from './config.js'
5
4
  import { computeDiff } from './diff.js'
6
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'
7
9
  import { buildReport } from './reporter.js'
8
- import type { DriftDiff, DriftReport } from './types.js'
10
+ import type { DriftReport } from './types.js'
9
11
  import type {
10
- GuardBaseline,
11
12
  GuardCheck,
12
13
  GuardEvaluation,
13
14
  GuardMetrics,
14
15
  GuardOptions,
15
16
  GuardResult,
17
+ GuardResultJson,
16
18
  GuardThresholds,
17
19
  IssueSeverity,
18
20
  } from './guard-types.js'
19
-
20
- interface NormalizedBaseline {
21
- score?: number
22
- totalIssues?: number
23
- bySeverity: Partial<Record<IssueSeverity, number>>
24
- }
21
+ import type { NormalizedBaseline } from './guard-baseline.js'
25
22
 
26
23
  interface GuardEvalInput {
27
24
  metrics: GuardMetrics
@@ -56,49 +53,6 @@ interface BaselineGuardResultInput {
56
53
  baselinePath?: string
57
54
  }
58
55
 
59
- function parseNumber(value: unknown): number | undefined {
60
- return typeof value === 'number' && !Number.isNaN(value) ? value : undefined
61
- }
62
-
63
- function normalizeBaseline(baseline: GuardBaseline): NormalizedBaseline {
64
- const bySeverityFromRoot = baseline.bySeverity
65
- const bySeverity = {
66
- error: parseNumber(bySeverityFromRoot?.error) ?? parseNumber(baseline.errors) ?? parseNumber(baseline.summary?.errors),
67
- warning: parseNumber(bySeverityFromRoot?.warning) ?? parseNumber(baseline.warnings) ?? parseNumber(baseline.summary?.warnings),
68
- info: parseNumber(bySeverityFromRoot?.info) ?? parseNumber(baseline.infos) ?? parseNumber(baseline.summary?.infos),
69
- }
70
-
71
- const normalized: NormalizedBaseline = {
72
- score: parseNumber(baseline.score),
73
- totalIssues: parseNumber(baseline.totalIssues),
74
- bySeverity,
75
- }
76
-
77
- const hasAnyAnchor =
78
- normalized.score !== undefined ||
79
- normalized.totalIssues !== undefined ||
80
- normalized.bySeverity.error !== undefined ||
81
- normalized.bySeverity.warning !== undefined ||
82
- normalized.bySeverity.info !== undefined
83
-
84
- if (!hasAnyAnchor) {
85
- throw new Error('Invalid guard baseline: expected score, totalIssues, or severity counters (error/warning/info).')
86
- }
87
-
88
- return normalized
89
- }
90
-
91
- function readBaselineFromFile(projectPath: string, baselinePath?: string): { baseline: NormalizedBaseline; path: string } | undefined {
92
- const resolvedBaselinePath = resolve(projectPath, baselinePath ?? 'drift-baseline.json')
93
- if (!existsSync(resolvedBaselinePath)) return undefined
94
-
95
- const raw = JSON.parse(readFileSync(resolvedBaselinePath, 'utf8')) as GuardBaseline
96
- return {
97
- baseline: normalizeBaseline(raw),
98
- path: resolvedBaselinePath,
99
- }
100
- }
101
-
102
56
  function remapBaseReportPaths(baseReport: DriftReport, tempDir: string, projectPath: string): DriftReport {
103
57
  return {
104
58
  ...baseReport,
@@ -109,44 +63,6 @@ function remapBaseReportPaths(baseReport: DriftReport, tempDir: string, projectP
109
63
  }
110
64
  }
111
65
 
112
- function countSeverityDeltaFromDiff(diff: DriftDiff): Record<IssueSeverity, number> {
113
- const severityDelta: Record<IssueSeverity, number> = {
114
- error: 0,
115
- warning: 0,
116
- info: 0,
117
- }
118
-
119
- for (const file of diff.files) {
120
- for (const issue of file.newIssues) {
121
- severityDelta[issue.severity] += 1
122
- }
123
- for (const issue of file.resolvedIssues) {
124
- severityDelta[issue.severity] -= 1
125
- }
126
- }
127
-
128
- return severityDelta
129
- }
130
-
131
- function buildMetricsFromDiff(diff: DriftDiff): GuardMetrics {
132
- return {
133
- scoreDelta: diff.totalDelta,
134
- totalIssuesDelta: diff.newIssuesCount - diff.resolvedIssuesCount,
135
- severityDelta: countSeverityDeltaFromDiff(diff),
136
- }
137
- }
138
-
139
- function buildMetricsFromBaseline(current: DriftReport, baseline: NormalizedBaseline): GuardMetrics {
140
- return {
141
- scoreDelta: current.totalScore - (baseline.score ?? current.totalScore),
142
- totalIssuesDelta: current.totalIssues - (baseline.totalIssues ?? current.totalIssues),
143
- severityDelta: {
144
- error: current.summary.errors - (baseline.bySeverity.error ?? current.summary.errors),
145
- warning: current.summary.warnings - (baseline.bySeverity.warning ?? current.summary.warnings),
146
- info: current.summary.infos - (baseline.bySeverity.info ?? current.summary.infos),
147
- },
148
- }
149
- }
150
66
 
151
67
  interface GuardCheckInput {
152
68
  id: string
@@ -216,6 +132,14 @@ export function evaluateGuard(input: GuardEvalInput): GuardEvaluation {
216
132
  }
217
133
  }
218
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
+
219
143
  export async function runGuard(targetPath: string, options: GuardOptions = {}): Promise<GuardResult> {
220
144
  const runtimeState = await initializeGuardRuntime(targetPath, options)
221
145
  const { projectPath, config, currentReport } = runtimeState
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  buildTrustReport,
19
19
  formatTrustConsole,
20
20
  formatTrustMarkdown,
21
+ formatTrustJsonObject,
21
22
  formatTrustJson,
22
23
  resolveTrustGatePolicy,
23
24
  evaluateTrustGate,
package/src/init.ts CHANGED
@@ -142,7 +142,7 @@ jobs:
142
142
 
143
143
  - uses: actions/setup-node@v4
144
144
  with:
145
- node-version: 18
145
+ node-version: 20
146
146
 
147
147
  - name: Install drift
148
148
  run: npm install -g @eduardbar/drift
@@ -9,6 +9,8 @@ export const OUTPUT_SCHEMA = {
9
9
  report: 'schemas/drift-report.v1.json',
10
10
  trust: 'schemas/drift-trust.v1.json',
11
11
  ai: 'schemas/drift-ai-output.v1.json',
12
+ doctor: 'schemas/drift-doctor.v1.json',
13
+ guard: 'schemas/drift-guard.v1.json',
12
14
  } as const
13
15
 
14
16
  type OutputMetadata = {
package/src/trust.ts CHANGED
@@ -193,7 +193,7 @@ export function formatTrustMarkdown(trust: DriftTrustReport): string {
193
193
  return sections.join('\n')
194
194
  }
195
195
 
196
- function formatTrustJsonObject(trust: DriftTrustReport): DriftTrustReportJson {
196
+ export function formatTrustJsonObject(trust: DriftTrustReport): DriftTrustReportJson {
197
197
  return withOutputMetadata(trust, OUTPUT_SCHEMA.trust)
198
198
  }
199
199
 
package/src/types.ts CHANGED
@@ -67,6 +67,7 @@ export type {
67
67
  GuardCheck,
68
68
  GuardEvaluation,
69
69
  GuardResult,
70
+ GuardResultJson,
70
71
  } from './guard-types.js'
71
72
 
72
73
  export type {
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const repoRoot = process.cwd()
6
+
7
+ function readRepoFile(path: string): string {
8
+ return readFileSync(join(repoRoot, path), 'utf8')
9
+ }
10
+
11
+ describe('CI quality matrix contract', () => {
12
+ it('requires Node 20/22 in reusable and entry workflows', () => {
13
+ const reusable = readRepoFile('.github/workflows/reusable-quality-checks.yml')
14
+ const quality = readRepoFile('.github/workflows/quality.yml')
15
+
16
+ expect(reusable).toContain("default: '[\"20\", \"22\"]'")
17
+ expect(quality).toContain("node_versions: '[\"20\", \"22\"]'")
18
+ expect(quality).toContain('pull_request:')
19
+ expect(quality).toContain('branches: [main, master]')
20
+ expect(quality).toContain('uses: ./.github/workflows/reusable-quality-checks.yml')
21
+ })
22
+
23
+ it('includes required quality commands in reusable checks', () => {
24
+ const reusable = readRepoFile('.github/workflows/reusable-quality-checks.yml')
25
+
26
+ expect(reusable).toContain('run: npm ci')
27
+ expect(reusable).toContain('run: npm run check:runtime-policy')
28
+ expect(reusable).toContain('run: npm run check:docs-drift')
29
+ expect(reusable).toContain("if: matrix.node == '20'")
30
+ expect(reusable).toContain('run: npm run check:perf-budget -- --out .drift-perf/ci-node-${{ matrix.node }}/benchmark-latest.json')
31
+ expect(reusable).toContain('run: npm test')
32
+ expect(reusable).toContain('run: npm run test:coverage')
33
+ expect(reusable).toContain('run: npm run build')
34
+ expect(reusable).toContain('name: Upload perf gate artifacts')
35
+ expect(reusable).toContain('path: .drift-perf/ci-node-${{ matrix.node }}/')
36
+ })
37
+ })
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const repoRoot = process.cwd()
6
+
7
+ function readRepoFile(path: string): string {
8
+ return readFileSync(join(repoRoot, path), 'utf8')
9
+ }
10
+
11
+ describe('CI smoke E2E gate', () => {
12
+ it('runs smoke:repo in reusable quality checks', () => {
13
+ const workflow = readRepoFile('.github/workflows/reusable-quality-checks.yml')
14
+
15
+ expect(workflow).toContain('name: Run CLI smoke E2E')
16
+ expect(workflow).toContain('run: npm run smoke:repo -- --base HEAD --out .drift-smoke/ci-node-${{ matrix.node }}')
17
+ })
18
+
19
+ it('uploads smoke artifacts even when smoke fails', () => {
20
+ const workflow = readRepoFile('.github/workflows/reusable-quality-checks.yml')
21
+
22
+ expect(workflow).toContain('name: Upload smoke E2E artifacts')
23
+ expect(workflow).toContain('if: always()')
24
+ expect(workflow).toContain('path: .drift-smoke/ci-node-${{ matrix.node }}/')
25
+ })
26
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const repoRoot = process.cwd()
6
+
7
+ function readRepoFile(path: string): string {
8
+ return readFileSync(join(repoRoot, path), 'utf8')
9
+ }
10
+
11
+ function getPackageVersion(): string {
12
+ const pkg = JSON.parse(readRepoFile('package.json')) as { version?: unknown }
13
+ if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
14
+ throw new Error('package.json is missing a valid version field')
15
+ }
16
+ return pkg.version
17
+ }
18
+
19
+ function extractVersionDefaultFromAction(content: string): string | undefined {
20
+ const lines = content.split(/\r?\n/)
21
+ const versionLine = lines.findIndex((line) => line.trim() === 'version:')
22
+ if (versionLine === -1) {
23
+ return undefined
24
+ }
25
+
26
+ for (let index = versionLine + 1; index < lines.length; index += 1) {
27
+ const trimmed = lines[index].trim()
28
+ if (trimmed.endsWith(':') && !trimmed.startsWith('default:')) {
29
+ break
30
+ }
31
+ const match = trimmed.match(/^default:\s*['"](\d+\.\d+\.\d+)['"]$/)
32
+ if (match) {
33
+ return match[1]
34
+ }
35
+ }
36
+
37
+ return undefined
38
+ }
39
+
40
+ describe('CI drift version alignment', () => {
41
+ it('keeps action version defaults aligned with package version', () => {
42
+ const packageVersion = getPackageVersion()
43
+ const actionFiles = ['.github/actions/drift-scan/action.yml', '.github/actions/drift-review/action.yml']
44
+
45
+ for (const actionFile of actionFiles) {
46
+ const content = readRepoFile(actionFile)
47
+ const actionVersion = extractVersionDefaultFromAction(content)
48
+ expect(actionVersion, `${actionFile} version default must match package.json`).toBe(packageVersion)
49
+ }
50
+ })
51
+
52
+ it('keeps documented action versions aligned with package version', () => {
53
+ const packageVersion = getPackageVersion()
54
+ const docs = ['.github/actions/drift-scan/README.md', '.github/actions/drift-review/README.md']
55
+ const versionRegex = /\b(\d+\.\d+\.\d+)\b/g
56
+
57
+ for (const doc of docs) {
58
+ const content = readRepoFile(doc)
59
+ const versions = Array.from(content.matchAll(versionRegex), (match) => match[1])
60
+
61
+ expect(versions.length, `${doc} should include at least one semver literal`).toBeGreaterThan(0)
62
+ expect(new Set(versions), `${doc} semver literals must match package.json`).toEqual(new Set([packageVersion]))
63
+ }
64
+ })
65
+
66
+ it('rejects hardcoded drift package semvers that diverge in CI yaml files', () => {
67
+ const packageVersion = getPackageVersion()
68
+ const yamlFiles = [
69
+ '.github/actions/drift-scan/action.yml',
70
+ '.github/actions/drift-review/action.yml',
71
+ '.github/workflows/quality.yml',
72
+ '.github/workflows/reusable-quality-checks.yml',
73
+ '.github/workflows/review-pr.yml',
74
+ '.github/workflows/publish.yml',
75
+ '.github/workflows/publish-vscode.yml',
76
+ ]
77
+
78
+ const mismatches: string[] = []
79
+ const packageRefRegex = /@eduardbar\/drift@(\d+\.\d+\.\d+)/g
80
+
81
+ for (const yamlFile of yamlFiles) {
82
+ const content = readRepoFile(yamlFile)
83
+ for (const match of content.matchAll(packageRefRegex)) {
84
+ const version = match[1]
85
+ if (version !== packageVersion) {
86
+ mismatches.push(`${yamlFile}: ${version}`)
87
+ }
88
+ }
89
+ }
90
+
91
+ expect(mismatches).toEqual([])
92
+ })
93
+ })
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ const repoRoot = process.cwd()
8
+ const checkScriptPath = join(repoRoot, 'scripts/check-docs-drift.mjs')
9
+ const tempDirs: string[] = []
10
+
11
+ function runCheck(cwd: string) {
12
+ return spawnSync(process.execPath, [checkScriptPath], {
13
+ cwd,
14
+ encoding: 'utf8',
15
+ })
16
+ }
17
+
18
+ function writeFixtureFile(root: string, relativePath: string, content: string) {
19
+ const filePath = join(root, relativePath)
20
+ const directory = dirname(filePath)
21
+ mkdirSync(directory, { recursive: true })
22
+ writeFileSync(filePath, content, 'utf8')
23
+ }
24
+
25
+ function createFixture(options?: { agentsVersion?: string; catalogMissingRule?: boolean; readmeCount?: number }) {
26
+ const fixtureRoot = mkdtempSync(join(tmpdir(), 'drift-docs-check-'))
27
+ tempDirs.push(fixtureRoot)
28
+
29
+ const agentsVersion = options?.agentsVersion ?? '1.4.0'
30
+ const catalogMissingRule = options?.catalogMissingRule ?? false
31
+ const readmeCount = options?.readmeCount ?? 2
32
+
33
+ writeFixtureFile(fixtureRoot, 'package.json', JSON.stringify({ version: '1.4.0' }, null, 2))
34
+ writeFixtureFile(
35
+ fixtureRoot,
36
+ 'src/analyzer.ts',
37
+ `export const RULE_WEIGHTS = {\n 'large-file': { severity: 'error', weight: 20 },\n 'dead-code': { severity: 'warning', weight: 8 },\n}\n`,
38
+ )
39
+
40
+ const catalogTableRows = catalogMissingRule
41
+ ? "| `large-file` | error | 20 | phase0-basic | sample |"
42
+ : [
43
+ '| `large-file` | error | 20 | phase0-basic | sample |',
44
+ '| `dead-code` | warning | 8 | phase0-basic | sample |',
45
+ ].join('\n')
46
+
47
+ const catalogTotal = catalogMissingRule ? 1 : 2
48
+ writeFixtureFile(
49
+ fixtureRoot,
50
+ 'docs/rules-catalog.md',
51
+ [
52
+ '# drift rules catalog (current)',
53
+ '',
54
+ 'Source of truth: `RULE_WEIGHTS` in `src/analyzer.ts`.',
55
+ '',
56
+ '| id | severity | weight | phase/origin | note |',
57
+ '|---|---|---:|---|---|',
58
+ catalogTableRows,
59
+ '',
60
+ `- Total rule IDs currently defined: **${catalogTotal}**.`,
61
+ '',
62
+ ].join('\n'),
63
+ )
64
+
65
+ writeFixtureFile(fixtureRoot, 'README.md', `drift currently defines **${readmeCount} rule IDs** in RULE_WEIGHTS.`)
66
+ writeFixtureFile(
67
+ fixtureRoot,
68
+ 'AGENTS.md',
69
+ [
70
+ '# AGENTS.md — drift',
71
+ `- Versión del paquete: \`${agentsVersion}\` (\`package.json\`)`,
72
+ '- Estado actual: **2 rule IDs** (sample).',
73
+ '',
74
+ ].join('\n'),
75
+ )
76
+
77
+ return fixtureRoot
78
+ }
79
+
80
+ afterEach(() => {
81
+ while (tempDirs.length > 0) {
82
+ const dir = tempDirs.pop()
83
+ if (!dir) continue
84
+ rmSync(dir, { recursive: true, force: true })
85
+ }
86
+ })
87
+
88
+ describe('docs drift checker', () => {
89
+ it('passes against the real repository contract', () => {
90
+ const result = runCheck(repoRoot)
91
+ expect(result.status).toBe(0)
92
+ expect(result.stdout).toContain('Docs drift check passed')
93
+ expect(result.stderr).toBe('')
94
+ })
95
+
96
+ it('fails when AGENTS package version diverges from package.json', () => {
97
+ const fixtureRoot = createFixture({ agentsVersion: '9.9.9' })
98
+
99
+ const result = runCheck(fixtureRoot)
100
+
101
+ expect(result.status).toBe(1)
102
+ expect(result.stderr).toContain('Docs drift check failed:')
103
+ expect(result.stderr).toContain('AGENTS.md package version (9.9.9) does not match package.json (1.4.0)')
104
+ })
105
+
106
+ it('fails when catalog rule IDs diverge from RULE_WEIGHTS', () => {
107
+ const fixtureRoot = createFixture({ catalogMissingRule: true, readmeCount: 1 })
108
+
109
+ const result = runCheck(fixtureRoot)
110
+
111
+ expect(result.status).toBe(1)
112
+ expect(result.stderr).toContain('rules missing in docs/rules-catalog.md: dead-code')
113
+ expect(result.stderr).toContain('README.md rule ID count (1) does not match RULE_WEIGHTS (2)')
114
+ })
115
+ })
@@ -118,7 +118,7 @@ describe('new feature MVP', () => {
118
118
  expect(rules).toContain('controller-no-db')
119
119
  expect(rules).toContain('service-no-http')
120
120
  expect(rules).toContain('max-function-lines')
121
- })
121
+ }, 15000)
122
122
 
123
123
  it('generates architecture SVG map', () => {
124
124
  tmpDir = mkdtempSync(join(tmpdir(), 'drift-map-'))
@@ -199,7 +199,7 @@ describe('new feature MVP', () => {
199
199
 
200
200
  expect(fullRules.has('circular-dependency')).toBe(true)
201
201
  expect(lowMemoryRules.has('circular-dependency')).toBe(true)
202
- }, 15000)
202
+ }, 30000)
203
203
 
204
204
  it('adds diagnostics when max file size guardrail skips files', () => {
205
205
  tmpDir = mkdtempSync(join(tmpdir(), 'drift-max-file-size-'))
@@ -0,0 +1,146 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ const repoRoot = process.cwd()
8
+ const checkScriptPath = join(repoRoot, 'scripts/check-performance-budget.mjs')
9
+ const tempDirs: string[] = []
10
+
11
+ function runCheck(cwd: string, args: string[]) {
12
+ return spawnSync(process.execPath, [checkScriptPath, ...args], {
13
+ cwd,
14
+ encoding: 'utf8',
15
+ })
16
+ }
17
+
18
+ function writeFixtureFile(root: string, relativePath: string, content: string) {
19
+ const filePath = join(root, relativePath)
20
+ mkdirSync(dirname(filePath), { recursive: true })
21
+ writeFileSync(filePath, content, 'utf8')
22
+ }
23
+
24
+ function createFixture(options?: {
25
+ schemaVersion?: string
26
+ scanMedianMs?: number
27
+ reviewMedianMs?: number
28
+ trustMedianMs?: number
29
+ measuredRuns?: number
30
+ includeTrustResult?: boolean
31
+ }) {
32
+ const fixtureRoot = mkdtempSync(join(tmpdir(), 'drift-perf-check-'))
33
+ tempDirs.push(fixtureRoot)
34
+
35
+ const budget = {
36
+ schemaVersion: options?.schemaVersion ?? 'drift-perf-budget/v1',
37
+ budgetVersion: 'test-budget',
38
+ benchmark: {
39
+ scanPath: 'benchmarks/fixtures/critical',
40
+ reviewPath: 'benchmarks/fixtures/critical',
41
+ trustPath: 'benchmarks/fixtures/critical',
42
+ baseRef: 'HEAD',
43
+ warmupRuns: 1,
44
+ measuredRuns: options?.measuredRuns ?? 5,
45
+ },
46
+ tolerance: {
47
+ runtimePct: 10,
48
+ memoryPct: 10,
49
+ },
50
+ tasks: {
51
+ scan: { maxMedianMs: 1000, maxRssMb: 400 },
52
+ review: { maxMedianMs: 1200, maxRssMb: 450 },
53
+ trust: { maxMedianMs: 1400, maxRssMb: 500 },
54
+ },
55
+ }
56
+
57
+ const benchmarkResult = {
58
+ meta: {
59
+ scannedAt: new Date().toISOString(),
60
+ node: process.version,
61
+ platform: process.platform,
62
+ cwd: fixtureRoot,
63
+ },
64
+ options: budget.benchmark,
65
+ results: [
66
+ {
67
+ name: 'scan',
68
+ medianMs: options?.scanMedianMs ?? 800,
69
+ maxRssMb: 320,
70
+ },
71
+ {
72
+ name: 'review',
73
+ medianMs: options?.reviewMedianMs ?? 900,
74
+ maxRssMb: 360,
75
+ },
76
+ ...(options?.includeTrustResult === false
77
+ ? []
78
+ : [{
79
+ name: 'trust',
80
+ medianMs: options?.trustMedianMs ?? 1100,
81
+ maxRssMb: 390,
82
+ }]),
83
+ ],
84
+ }
85
+
86
+ writeFixtureFile(fixtureRoot, 'benchmarks/perf-budget.v1.json', `${JSON.stringify(budget, null, 2)}\n`)
87
+ writeFixtureFile(fixtureRoot, '.drift-perf/benchmark-sample.json', `${JSON.stringify(benchmarkResult, null, 2)}\n`)
88
+
89
+ return fixtureRoot
90
+ }
91
+
92
+ afterEach(() => {
93
+ while (tempDirs.length > 0) {
94
+ const dir = tempDirs.pop()
95
+ if (!dir) continue
96
+ rmSync(dir, { recursive: true, force: true })
97
+ }
98
+ })
99
+
100
+ describe('performance budget checker', () => {
101
+ it('passes when benchmark result stays within budget and tolerance', () => {
102
+ const fixtureRoot = createFixture()
103
+
104
+ const result = runCheck(fixtureRoot, ['--result', '.drift-perf/benchmark-sample.json'])
105
+
106
+ expect(result.status).toBe(0)
107
+ expect(result.stdout).toContain('Performance budget check passed.')
108
+ })
109
+
110
+ it('fails when runtime budget is exceeded', () => {
111
+ const fixtureRoot = createFixture({ scanMedianMs: 1300 })
112
+
113
+ const result = runCheck(fixtureRoot, ['--result', '.drift-perf/benchmark-sample.json'])
114
+
115
+ expect(result.status).toBe(1)
116
+ expect(result.stderr).toContain('Performance budget check failed:')
117
+ expect(result.stderr).toContain('scan: median runtime')
118
+ })
119
+
120
+ it('fails when budget schema version is invalid', () => {
121
+ const fixtureRoot = createFixture({ schemaVersion: 'drift-perf-budget/v0' })
122
+
123
+ const result = runCheck(fixtureRoot, ['--result', '.drift-perf/benchmark-sample.json'])
124
+
125
+ expect(result.status).toBe(1)
126
+ expect(result.stderr).toContain('Unsupported budget schemaVersion')
127
+ })
128
+
129
+ it('fails when benchmark output does not include all required tasks', () => {
130
+ const fixtureRoot = createFixture({ includeTrustResult: false })
131
+
132
+ const result = runCheck(fixtureRoot, ['--result', '.drift-perf/benchmark-sample.json'])
133
+
134
+ expect(result.status).toBe(1)
135
+ expect(result.stderr).toContain("Benchmark output is missing task 'trust'")
136
+ })
137
+
138
+ it('fails when benchmark measuredRuns is invalid', () => {
139
+ const fixtureRoot = createFixture({ measuredRuns: 0 })
140
+
141
+ const result = runCheck(fixtureRoot, ['--result', '.drift-perf/benchmark-sample.json'])
142
+
143
+ expect(result.status).toBe(1)
144
+ expect(result.stderr).toContain('benchmark.measuredRuns must be at least 1')
145
+ })
146
+ })