@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.
- package/.github/actions/drift-review/README.md +4 -2
- package/.github/actions/drift-review/action.yml +22 -5
- package/.github/actions/drift-scan/README.md +3 -3
- package/.github/actions/drift-scan/action.yml +1 -1
- 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 +0 -1
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +14 -1
- package/README.md +30 -3
- 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.js +12 -0
- package/dist/cli.js +2 -2
- package/dist/doctor.d.ts +21 -0
- package/dist/doctor.js +10 -3
- 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 +2 -1
- package/dist/guard.d.ts +3 -1
- package/dist/guard.js +9 -70
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/init.js +1 -1
- package/dist/output-metadata.d.ts +2 -0
- package/dist/output-metadata.js +2 -0
- package/dist/trust.d.ts +2 -1
- package/dist/trust.js +1 -1
- package/dist/types.d.ts +1 -1
- package/docs/AGENTS.md +1 -1
- package/package.json +10 -4
- package/schemas/drift-doctor.v1.json +57 -0
- package/schemas/drift-guard.v1.json +298 -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/src/benchmark.ts +17 -0
- package/src/cli.ts +2 -2
- package/src/doctor.ts +15 -3
- package/src/guard-baseline.ts +74 -0
- package/src/guard-metrics.ts +52 -0
- package/src/guard-types.ts +3 -1
- package/src/guard.ts +14 -90
- package/src/index.ts +1 -0
- package/src/init.ts +1 -1
- package/src/output-metadata.ts +2 -0
- package/src/trust.ts +1 -1
- package/src/types.ts +1 -0
- 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/docs-drift-check.test.ts +115 -0
- package/tests/new-features.test.ts +2 -2
- package/tests/perf-budget-check.test.ts +146 -0
- package/tests/phase1-init-doctor-guard.test.ts +104 -2
- package/tests/runtime-policy-alignment.test.ts +46 -0
- 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 {
|
|
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
package/src/init.ts
CHANGED
package/src/output-metadata.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
},
|
|
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
|
+
})
|