@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,301 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { runDoctor } from '../src/doctor.js'
|
|
6
|
+
import { runInit } from '../src/init.js'
|
|
7
|
+
import { evaluateGuard, formatGuardJsonObject, runGuard } from '../src/guard.js'
|
|
8
|
+
import { analyzeProject } from '../src/analyzer.js'
|
|
9
|
+
import { buildReport } from '../src/reporter.js'
|
|
10
|
+
|
|
11
|
+
type JsonSchema = {
|
|
12
|
+
type?: string | string[]
|
|
13
|
+
required?: string[]
|
|
14
|
+
properties?: Record<string, JsonSchema>
|
|
15
|
+
additionalProperties?: boolean | JsonSchema
|
|
16
|
+
items?: JsonSchema
|
|
17
|
+
const?: unknown
|
|
18
|
+
enum?: unknown[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateAgainstSchema(schema: JsonSchema, value: unknown, path = '$'): string[] {
|
|
22
|
+
const errors: string[] = []
|
|
23
|
+
|
|
24
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
25
|
+
errors.push(`${path} must be ${JSON.stringify(schema.const)}`)
|
|
26
|
+
return errors
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
30
|
+
errors.push(`${path} must be one of ${JSON.stringify(schema.enum)}`)
|
|
31
|
+
return errors
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (schema.type) {
|
|
35
|
+
const allowedTypes = Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
36
|
+
const actualType = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
|
|
37
|
+
if (!allowedTypes.includes(actualType)) {
|
|
38
|
+
errors.push(`${path} must be type ${allowedTypes.join('|')}, got ${actualType}`)
|
|
39
|
+
return errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (schema.type === 'array' && schema.items && Array.isArray(value)) {
|
|
44
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
45
|
+
errors.push(...validateAgainstSchema(schema.items, value[i], `${path}[${i}]`))
|
|
46
|
+
}
|
|
47
|
+
return errors
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (schema.type === 'object' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
51
|
+
const objectValue = value as Record<string, unknown>
|
|
52
|
+
const required = schema.required ?? []
|
|
53
|
+
const properties = schema.properties ?? {}
|
|
54
|
+
|
|
55
|
+
for (const key of required) {
|
|
56
|
+
if (!(key in objectValue)) {
|
|
57
|
+
errors.push(`${path}.${key} is required`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
62
|
+
if (key in objectValue) {
|
|
63
|
+
errors.push(...validateAgainstSchema(propertySchema, objectValue[key], `${path}.${key}`))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (schema.additionalProperties === false) {
|
|
68
|
+
for (const key of Object.keys(objectValue)) {
|
|
69
|
+
if (!(key in properties)) {
|
|
70
|
+
errors.push(`${path}.${key} is not allowed`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
schema.additionalProperties &&
|
|
77
|
+
typeof schema.additionalProperties === 'object' &&
|
|
78
|
+
schema.additionalProperties !== null
|
|
79
|
+
) {
|
|
80
|
+
for (const [key, propValue] of Object.entries(objectValue)) {
|
|
81
|
+
if (!(key in properties)) {
|
|
82
|
+
errors.push(...validateAgainstSchema(schema.additionalProperties, propValue, `${path}.${key}`))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return errors
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadSchema(schemaFileName: string): JsonSchema {
|
|
92
|
+
const raw = readFileSync(join(process.cwd(), 'schemas', schemaFileName), 'utf8')
|
|
93
|
+
return JSON.parse(raw) as JsonSchema
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createTempDir(prefix: string): string {
|
|
97
|
+
return mkdtempSync(join(tmpdir(), prefix))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('phase 1: doctor/init/guard', () => {
|
|
101
|
+
const tempDirs: string[] = []
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
vi.restoreAllMocks()
|
|
105
|
+
for (const dir of tempDirs) {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true })
|
|
107
|
+
}
|
|
108
|
+
tempDirs.length = 0
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('runDoctor', () => {
|
|
112
|
+
it('prints a basic diagnostic report and exits with code 0', async () => {
|
|
113
|
+
const projectDir = createTempDir('drift-doctor-basic-')
|
|
114
|
+
tempDirs.push(projectDir)
|
|
115
|
+
writeFileSync(join(projectDir, 'index.ts'), 'export const value = 1\n')
|
|
116
|
+
|
|
117
|
+
const output: string[] = []
|
|
118
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
119
|
+
output.push(String(chunk))
|
|
120
|
+
return true
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const exitCode = await runDoctor(projectDir)
|
|
124
|
+
const text = output.join('')
|
|
125
|
+
|
|
126
|
+
expect(exitCode).toBe(0)
|
|
127
|
+
expect(text).toContain('drift doctor')
|
|
128
|
+
expect(text).toContain('Source files (.ts/.tsx/.js/.jsx): 1')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('prints valid JSON output with expected shape', async () => {
|
|
132
|
+
const projectDir = createTempDir('drift-doctor-json-')
|
|
133
|
+
tempDirs.push(projectDir)
|
|
134
|
+
mkdirSync(join(projectDir, 'src'))
|
|
135
|
+
writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2))
|
|
136
|
+
writeFileSync(join(projectDir, 'tsconfig.json'), '{"compilerOptions":{}}\n')
|
|
137
|
+
writeFileSync(join(projectDir, 'drift.config.ts'), 'export default {}\n')
|
|
138
|
+
writeFileSync(join(projectDir, 'src', 'app.ts'), 'export const answer = 42\n')
|
|
139
|
+
|
|
140
|
+
const output: string[] = []
|
|
141
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
142
|
+
output.push(String(chunk))
|
|
143
|
+
return true
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await runDoctor(projectDir, { json: true })
|
|
147
|
+
const report = JSON.parse(output.join('')) as {
|
|
148
|
+
$schema: string
|
|
149
|
+
toolVersion: string
|
|
150
|
+
targetPath: string
|
|
151
|
+
node: { version: string; major: number; supported: boolean }
|
|
152
|
+
project: {
|
|
153
|
+
packageJsonFound: boolean
|
|
154
|
+
esm: boolean
|
|
155
|
+
tsconfigFound: boolean
|
|
156
|
+
sourceFilesCount: number
|
|
157
|
+
lowMemorySuggested: boolean
|
|
158
|
+
driftConfigFile: string | null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const schema = loadSchema('drift-doctor.v1.json')
|
|
163
|
+
const schemaErrors = validateAgainstSchema(schema, report)
|
|
164
|
+
|
|
165
|
+
expect(report.$schema).toBe('schemas/drift-doctor.v1.json')
|
|
166
|
+
expect(typeof report.toolVersion).toBe('string')
|
|
167
|
+
expect(report.toolVersion.length).toBeGreaterThan(0)
|
|
168
|
+
expect(report.targetPath).toBe(projectDir)
|
|
169
|
+
expect(typeof report.node.version).toBe('string')
|
|
170
|
+
expect(typeof report.node.major).toBe('number')
|
|
171
|
+
expect(typeof report.node.supported).toBe('boolean')
|
|
172
|
+
expect(report.project.packageJsonFound).toBe(true)
|
|
173
|
+
expect(report.project.esm).toBe(true)
|
|
174
|
+
expect(report.project.tsconfigFound).toBe(true)
|
|
175
|
+
expect(report.project.sourceFilesCount).toBe(2)
|
|
176
|
+
expect(report.project.driftConfigFile).toBe('drift.config.ts')
|
|
177
|
+
expect(typeof report.project.lowMemorySuggested).toBe('boolean')
|
|
178
|
+
expect(schemaErrors).toEqual([])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('runInit', () => {
|
|
183
|
+
it('creates drift.config.ts when using a valid preset', async () => {
|
|
184
|
+
const projectDir = createTempDir('drift-init-preset-')
|
|
185
|
+
tempDirs.push(projectDir)
|
|
186
|
+
|
|
187
|
+
await runInit(projectDir, { preset: 'node-backend' })
|
|
188
|
+
|
|
189
|
+
const generated = readFileSync(join(projectDir, 'drift.config.ts'), 'utf8')
|
|
190
|
+
expect(generated).toContain('satisfies DriftConfig')
|
|
191
|
+
expect(generated).toContain("name: 'api'")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('throws on invalid preset', async () => {
|
|
195
|
+
const projectDir = createTempDir('drift-init-invalid-')
|
|
196
|
+
tempDirs.push(projectDir)
|
|
197
|
+
|
|
198
|
+
await expect(runInit(projectDir, { preset: 'invalid-preset' })).rejects.toThrow("Invalid preset 'invalid-preset'")
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('creates ci workflow when --ci flag is enabled', async () => {
|
|
202
|
+
const projectDir = createTempDir('drift-init-ci-')
|
|
203
|
+
tempDirs.push(projectDir)
|
|
204
|
+
|
|
205
|
+
await runInit(projectDir, { ci: true })
|
|
206
|
+
|
|
207
|
+
const workflowPath = join(projectDir, '.github', 'workflows', 'drift-review.yml')
|
|
208
|
+
const workflow = readFileSync(workflowPath, 'utf8')
|
|
209
|
+
expect(workflow).toContain('name: drift PR Review')
|
|
210
|
+
expect(workflow).toContain('node-version: 20')
|
|
211
|
+
expect(workflow).toContain('npx drift review --base')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('prints no-actions message when no flags are provided', async () => {
|
|
215
|
+
const projectDir = createTempDir('drift-init-empty-')
|
|
216
|
+
tempDirs.push(projectDir)
|
|
217
|
+
|
|
218
|
+
const output: string[] = []
|
|
219
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
220
|
+
output.push(String(chunk))
|
|
221
|
+
return true
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
await runInit(projectDir, {})
|
|
225
|
+
|
|
226
|
+
expect(output.join('')).toContain('No actions taken. Use --preset, --ci, or --baseline flags.')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('guard', () => {
|
|
231
|
+
it('evaluateGuard applies no-regression, budget and severity checks', () => {
|
|
232
|
+
const evaluation = evaluateGuard({
|
|
233
|
+
metrics: {
|
|
234
|
+
scoreDelta: 3,
|
|
235
|
+
totalIssuesDelta: 1,
|
|
236
|
+
severityDelta: { error: 1, warning: 0, info: 0 },
|
|
237
|
+
},
|
|
238
|
+
budget: 2,
|
|
239
|
+
bySeverity: { error: 0, warning: 1, info: 0 },
|
|
240
|
+
enforceNoRegression: {
|
|
241
|
+
score: true,
|
|
242
|
+
totalIssues: true,
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
expect(evaluation.passed).toBe(false)
|
|
247
|
+
expect(evaluation.checks.some((check) => check.id === 'no-regression-score' && !check.passed)).toBe(true)
|
|
248
|
+
expect(evaluation.checks.some((check) => check.id === 'no-regression-total-issues' && !check.passed)).toBe(true)
|
|
249
|
+
expect(evaluation.checks.some((check) => check.id === 'budget-total-delta' && !check.passed)).toBe(true)
|
|
250
|
+
expect(evaluation.checks.some((check) => check.id === 'severity-error' && !check.passed)).toBe(true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('runs in baseline mode with inline baseline fixture', async () => {
|
|
254
|
+
const projectDir = createTempDir('drift-guard-baseline-')
|
|
255
|
+
tempDirs.push(projectDir)
|
|
256
|
+
writeFileSync(join(projectDir, 'main.ts'), 'export const value: any = 42\n')
|
|
257
|
+
|
|
258
|
+
const reports = analyzeProject(projectDir)
|
|
259
|
+
const current = buildReport(projectDir, reports)
|
|
260
|
+
|
|
261
|
+
const result = await runGuard(projectDir, {
|
|
262
|
+
baseline: {
|
|
263
|
+
score: current.totalScore,
|
|
264
|
+
totalIssues: current.totalIssues,
|
|
265
|
+
bySeverity: {
|
|
266
|
+
error: current.summary.errors,
|
|
267
|
+
warning: current.summary.warnings,
|
|
268
|
+
info: current.summary.infos,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
budget: 0,
|
|
272
|
+
bySeverity: { error: 0, warning: 0, info: 0 },
|
|
273
|
+
})
|
|
274
|
+
const resultJson = formatGuardJsonObject(result)
|
|
275
|
+
const schema = loadSchema('drift-guard.v1.json')
|
|
276
|
+
const schemaErrors = validateAgainstSchema(schema, JSON.parse(JSON.stringify(resultJson)))
|
|
277
|
+
|
|
278
|
+
expect(result.mode).toBe('baseline')
|
|
279
|
+
expect(result.passed).toBe(true)
|
|
280
|
+
expect(result.metrics.scoreDelta).toBe(0)
|
|
281
|
+
expect(result.metrics.totalIssuesDelta).toBe(0)
|
|
282
|
+
expect(result.metrics.severityDelta).toEqual({ error: 0, warning: 0, info: 0 })
|
|
283
|
+
expect(result.checks.some((check) => check.id === 'no-regression-score')).toBe(true)
|
|
284
|
+
expect(result.checks.some((check) => check.id === 'no-regression-total-issues')).toBe(true)
|
|
285
|
+
expect(resultJson.$schema).toBe('schemas/drift-guard.v1.json')
|
|
286
|
+
expect(typeof resultJson.toolVersion).toBe('string')
|
|
287
|
+
expect(resultJson.toolVersion.length).toBeGreaterThan(0)
|
|
288
|
+
expect(schemaErrors).toEqual([])
|
|
289
|
+
}, 15000)
|
|
290
|
+
|
|
291
|
+
it('throws when guard has no baseRef and no baseline', async () => {
|
|
292
|
+
const projectDir = createTempDir('drift-guard-missing-anchor-')
|
|
293
|
+
tempDirs.push(projectDir)
|
|
294
|
+
writeFileSync(join(projectDir, 'main.ts'), 'export const value = 1\n')
|
|
295
|
+
|
|
296
|
+
await expect(runGuard(projectDir, {})).rejects.toThrow(
|
|
297
|
+
'Guard requires a comparison point: provide baseRef or a baseline (inline or file).',
|
|
298
|
+
)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
const EXPECTED_ENGINE_RANGE = '^20.0.0 || ^22.0.0'
|
|
7
|
+
const EXPECTED_NODE_MATRIX = '["20", "22"]'
|
|
8
|
+
const EXPECTED_README_RUNTIME = '**Runtime:** Node.js 20.x and 22.x (LTS)'
|
|
9
|
+
|
|
10
|
+
function readRepoFile(path: string): string {
|
|
11
|
+
return readFileSync(join(repoRoot, path), 'utf8')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('runtime support policy alignment', () => {
|
|
15
|
+
it('keeps package engines aligned with supported Node policy', () => {
|
|
16
|
+
const pkg = JSON.parse(readRepoFile('package.json')) as { engines?: { node?: string } }
|
|
17
|
+
expect(pkg.engines?.node).toBe(EXPECTED_ENGINE_RANGE)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps CI workflows aligned with supported Node policy', () => {
|
|
21
|
+
const reusable = readRepoFile('.github/workflows/reusable-quality-checks.yml')
|
|
22
|
+
const quality = readRepoFile('.github/workflows/quality.yml')
|
|
23
|
+
const initTemplate = readRepoFile('src/init.ts')
|
|
24
|
+
|
|
25
|
+
expect(reusable).toContain(`default: '${EXPECTED_NODE_MATRIX}'`)
|
|
26
|
+
expect(quality).toContain(`node_versions: '${EXPECTED_NODE_MATRIX}'`)
|
|
27
|
+
expect(reusable).toContain('run: npm run check:runtime-policy')
|
|
28
|
+
expect(initTemplate).toContain('node-version: 20')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keeps runtime docs and doctor messaging aligned with supported minimum', () => {
|
|
32
|
+
const readme = readRepoFile('README.md')
|
|
33
|
+
const doctor = readRepoFile('src/doctor.ts')
|
|
34
|
+
|
|
35
|
+
expect(readme).toContain(EXPECTED_README_RUNTIME)
|
|
36
|
+
expect(doctor).toContain('const MIN_SUPPORTED_NODE_MAJOR = 20')
|
|
37
|
+
expect(doctor).toContain('Node runtime below supported minimum (>=20)')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('documents dependency-driven minimum from lockfile constraints', () => {
|
|
41
|
+
const lockfile = readRepoFile('package-lock.json')
|
|
42
|
+
|
|
43
|
+
expect(lockfile).toContain('"node_modules/commander"')
|
|
44
|
+
expect(lockfile).toContain('"node": ">=20"')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { diffToSarif, toSarif } from '../src/sarif.js'
|
|
3
|
+
import type { DriftDiff, DriftReport } from '../src/types.js'
|
|
4
|
+
|
|
5
|
+
describe('toSarif', () => {
|
|
6
|
+
function createReport(): DriftReport {
|
|
7
|
+
return {
|
|
8
|
+
scannedAt: '2026-03-17T10:20:30.000Z',
|
|
9
|
+
targetPath: '/repo',
|
|
10
|
+
files: [{
|
|
11
|
+
path: 'src/app.ts',
|
|
12
|
+
score: 72,
|
|
13
|
+
issues: [
|
|
14
|
+
{
|
|
15
|
+
rule: 'large-file',
|
|
16
|
+
severity: 'error',
|
|
17
|
+
message: 'File exceeds threshold',
|
|
18
|
+
line: 14,
|
|
19
|
+
column: 3,
|
|
20
|
+
snippet: 'export function app() {}',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
rule: 'debug-leftover',
|
|
24
|
+
severity: 'warning',
|
|
25
|
+
message: 'console.log detected',
|
|
26
|
+
line: 22,
|
|
27
|
+
column: 1,
|
|
28
|
+
snippet: 'console.log(value)',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
rule: 'plugin-warning',
|
|
32
|
+
severity: 'info',
|
|
33
|
+
message: 'Plugin diagnostic',
|
|
34
|
+
line: 1,
|
|
35
|
+
column: 1,
|
|
36
|
+
snippet: 'app.ts',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}],
|
|
40
|
+
totalIssues: 3,
|
|
41
|
+
totalScore: 72,
|
|
42
|
+
totalFiles: 1,
|
|
43
|
+
summary: {
|
|
44
|
+
errors: 1,
|
|
45
|
+
warnings: 1,
|
|
46
|
+
infos: 1,
|
|
47
|
+
byRule: {
|
|
48
|
+
'large-file': 1,
|
|
49
|
+
'debug-leftover': 1,
|
|
50
|
+
'plugin-warning': 1,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
quality: {
|
|
54
|
+
overall: 90,
|
|
55
|
+
dimensions: {
|
|
56
|
+
architecture: 91,
|
|
57
|
+
complexity: 87,
|
|
58
|
+
'ai-patterns': 92,
|
|
59
|
+
testing: 89,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
maintenanceRisk: {
|
|
63
|
+
score: 20,
|
|
64
|
+
level: 'low',
|
|
65
|
+
hotspots: [],
|
|
66
|
+
signals: {
|
|
67
|
+
highComplexityFiles: 0,
|
|
68
|
+
filesWithoutNearbyTests: 0,
|
|
69
|
+
frequentChangeFiles: 0,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('maps drift severities to SARIF levels', () => {
|
|
76
|
+
const sarif = toSarif(createReport())
|
|
77
|
+
const levels = sarif.runs[0].results.map((result) => result.level)
|
|
78
|
+
|
|
79
|
+
expect(levels).toContain('error')
|
|
80
|
+
expect(levels).toContain('warning')
|
|
81
|
+
expect(levels).toContain('note')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('builds SARIF minimal valid structure', () => {
|
|
85
|
+
const sarif = toSarif(createReport())
|
|
86
|
+
|
|
87
|
+
expect(sarif.version).toBe('2.1.0')
|
|
88
|
+
expect(sarif.runs).toHaveLength(1)
|
|
89
|
+
expect(sarif.runs[0].tool.driver.name).toBe('drift')
|
|
90
|
+
expect(Array.isArray(sarif.runs[0].results)).toBe(true)
|
|
91
|
+
expect(sarif.runs[0].results).toHaveLength(3)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('maps message and location fields for each issue', () => {
|
|
95
|
+
const sarif = toSarif(createReport())
|
|
96
|
+
const result = sarif.runs[0].results.find((item) => item.ruleId === 'large-file')
|
|
97
|
+
|
|
98
|
+
expect(result).toBeDefined()
|
|
99
|
+
expect(result?.message.text).toBe('File exceeds threshold')
|
|
100
|
+
expect(result?.locations[0].physicalLocation.artifactLocation.uri).toBe('src/app.ts')
|
|
101
|
+
expect(result?.locations[0].physicalLocation.region.startLine).toBe(14)
|
|
102
|
+
expect(result?.locations[0].physicalLocation.region.startColumn).toBe(3)
|
|
103
|
+
expect(result?.properties?.weight).toBe(20)
|
|
104
|
+
expect(result?.properties?.fileScore).toBe(72)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('maps diff newIssues to SARIF results', () => {
|
|
108
|
+
const diff: DriftDiff = {
|
|
109
|
+
baseRef: 'origin/main',
|
|
110
|
+
projectPath: '/repo',
|
|
111
|
+
scannedAt: '2026-03-17T10:20:30.000Z',
|
|
112
|
+
files: [
|
|
113
|
+
{
|
|
114
|
+
path: 'src/app.ts',
|
|
115
|
+
scoreBefore: 60,
|
|
116
|
+
scoreAfter: 72,
|
|
117
|
+
scoreDelta: 12,
|
|
118
|
+
newIssues: [
|
|
119
|
+
{
|
|
120
|
+
rule: 'debug-leftover',
|
|
121
|
+
severity: 'warning',
|
|
122
|
+
message: 'console.log detected',
|
|
123
|
+
line: 22,
|
|
124
|
+
column: 1,
|
|
125
|
+
snippet: 'console.log(value)',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
resolvedIssues: [
|
|
129
|
+
{
|
|
130
|
+
rule: 'magic-number',
|
|
131
|
+
severity: 'info',
|
|
132
|
+
message: 'legacy issue resolved',
|
|
133
|
+
line: 10,
|
|
134
|
+
column: 5,
|
|
135
|
+
snippet: '42',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
totalScoreBefore: 60,
|
|
141
|
+
totalScoreAfter: 72,
|
|
142
|
+
totalDelta: 12,
|
|
143
|
+
newIssuesCount: 1,
|
|
144
|
+
resolvedIssuesCount: 1,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sarif = diffToSarif(diff)
|
|
148
|
+
|
|
149
|
+
expect(sarif.version).toBe('2.1.0')
|
|
150
|
+
expect(sarif.runs[0].results).toHaveLength(1)
|
|
151
|
+
expect(sarif.runs[0].results[0]?.ruleId).toBe('debug-leftover')
|
|
152
|
+
expect(sarif.runs[0].results[0]?.locations[0]?.physicalLocation?.artifactLocation?.uri).toBe('src/app.ts')
|
|
153
|
+
expect(sarif.runs[0].results[0]?.properties?.baseRef).toBe('origin/main')
|
|
154
|
+
expect(sarif.runs[0].results[0]?.properties?.scoreDelta).toBe(12)
|
|
155
|
+
expect(sarif.runs[0].results[0]?.properties?.changeType).toBe('new-issue')
|
|
156
|
+
expect(sarif.runs[0].properties.baseRef).toBe('origin/main')
|
|
157
|
+
expect(sarif.runs[0].properties.newIssuesCount).toBe(1)
|
|
158
|
+
expect(sarif.runs[0].properties.resolvedIssuesCount).toBe(1)
|
|
159
|
+
})
|
|
160
|
+
})
|
package/tests/trust-kpi.test.ts
CHANGED
|
@@ -16,6 +16,8 @@ describe('trust KPI aggregation', () => {
|
|
|
16
16
|
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-aggregate-'))
|
|
17
17
|
|
|
18
18
|
writeFileSync(join(tempDir, 'trust-a.json'), JSON.stringify({
|
|
19
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
20
|
+
toolVersion: '1.3.0',
|
|
19
21
|
trust_score: 80,
|
|
20
22
|
merge_risk: 'LOW',
|
|
21
23
|
diff_context: {
|
|
@@ -32,6 +34,8 @@ describe('trust KPI aggregation', () => {
|
|
|
32
34
|
}, null, 2))
|
|
33
35
|
|
|
34
36
|
writeFileSync(join(tempDir, 'trust-b.json'), JSON.stringify({
|
|
37
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
38
|
+
toolVersion: '1.3.0',
|
|
35
39
|
trust_score: 60,
|
|
36
40
|
merge_risk: 'MEDIUM',
|
|
37
41
|
diff_context: {
|
|
@@ -48,6 +52,8 @@ describe('trust KPI aggregation', () => {
|
|
|
48
52
|
}, null, 2))
|
|
49
53
|
|
|
50
54
|
writeFileSync(join(tempDir, 'trust-c.json'), JSON.stringify({
|
|
55
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
56
|
+
toolVersion: '1.3.0',
|
|
51
57
|
trust_score: 30,
|
|
52
58
|
merge_risk: 'HIGH',
|
|
53
59
|
}, null, 2))
|
|
@@ -72,6 +78,8 @@ describe('trust KPI aggregation', () => {
|
|
|
72
78
|
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-parse-'))
|
|
73
79
|
|
|
74
80
|
writeFileSync(join(tempDir, 'valid.json'), JSON.stringify({
|
|
81
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
82
|
+
toolVersion: '1.3.0',
|
|
75
83
|
trust_score: 70,
|
|
76
84
|
merge_risk: 'MEDIUM',
|
|
77
85
|
diff_context: {
|
|
@@ -84,16 +92,25 @@ describe('trust KPI aggregation', () => {
|
|
|
84
92
|
writeFileSync(join(tempDir, 'broken.json'), '{"trust_score":70')
|
|
85
93
|
writeFileSync(join(tempDir, 'invalid-shape.json'), JSON.stringify({ trust_score: 70 }, null, 2))
|
|
86
94
|
writeFileSync(join(tempDir, 'bad-diff.json'), JSON.stringify({
|
|
95
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
96
|
+
toolVersion: '1.3.0',
|
|
87
97
|
trust_score: 50,
|
|
88
98
|
merge_risk: 'HIGH',
|
|
89
99
|
diff_context: 'oops',
|
|
90
100
|
}, null, 2))
|
|
91
101
|
|
|
102
|
+
writeFileSync(join(tempDir, 'wrong-schema.json'), JSON.stringify({
|
|
103
|
+
$schema: 'schemas/drift-report.v1.json',
|
|
104
|
+
toolVersion: '1.3.0',
|
|
105
|
+
trust_score: 65,
|
|
106
|
+
merge_risk: 'MEDIUM',
|
|
107
|
+
}, null, 2))
|
|
108
|
+
|
|
92
109
|
const kpi = computeTrustKpis(tempDir)
|
|
93
110
|
|
|
94
|
-
expect(kpi.files.matched).toBe(
|
|
111
|
+
expect(kpi.files.matched).toBe(5)
|
|
95
112
|
expect(kpi.files.parsed).toBe(2)
|
|
96
|
-
expect(kpi.files.malformed).toBe(
|
|
113
|
+
expect(kpi.files.malformed).toBe(3)
|
|
97
114
|
expect(kpi.prsEvaluated).toBe(2)
|
|
98
115
|
|
|
99
116
|
const byCode = new Set(kpi.diagnostics.map((diagnostic) => diagnostic.code))
|
|
@@ -106,8 +123,18 @@ describe('trust KPI aggregation', () => {
|
|
|
106
123
|
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-glob-'))
|
|
107
124
|
mkdirSync(join(tempDir, 'nested'))
|
|
108
125
|
|
|
109
|
-
writeFileSync(join(tempDir, 'trust-1.json'), JSON.stringify({
|
|
110
|
-
|
|
126
|
+
writeFileSync(join(tempDir, 'trust-1.json'), JSON.stringify({
|
|
127
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
128
|
+
toolVersion: '1.3.0',
|
|
129
|
+
trust_score: 90,
|
|
130
|
+
merge_risk: 'LOW',
|
|
131
|
+
}))
|
|
132
|
+
writeFileSync(join(tempDir, 'nested', 'trust-2.json'), JSON.stringify({
|
|
133
|
+
$schema: 'schemas/drift-trust.v1.json',
|
|
134
|
+
toolVersion: '1.3.0',
|
|
135
|
+
trust_score: 20,
|
|
136
|
+
merge_risk: 'CRITICAL',
|
|
137
|
+
}))
|
|
111
138
|
writeFileSync(join(tempDir, 'other.json'), JSON.stringify({ trust_score: 55, merge_risk: 'MEDIUM' }))
|
|
112
139
|
|
|
113
140
|
const pattern = join(tempDir, '**', 'trust-*.json')
|
package/tests/trust.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
formatTrustGatePolicyExplanation,
|
|
7
7
|
explainTrustGatePolicy,
|
|
8
8
|
formatTrustJson,
|
|
9
|
+
formatTrustJsonObject,
|
|
9
10
|
formatTrustMarkdown,
|
|
10
11
|
normalizeMergeRiskLevel,
|
|
11
12
|
renderTrustOutput,
|
|
@@ -201,6 +202,23 @@ describe('drift trust baseline', () => {
|
|
|
201
202
|
expect(renderTrustOutput(trust, { json: true, markdown: true })).toBe(formatTrustJson(trust))
|
|
202
203
|
})
|
|
203
204
|
|
|
205
|
+
it('adds schema metadata in trust JSON output without breaking trust payload', () => {
|
|
206
|
+
const report = createBaseReport({ targetPath: '/tmp/metadata' })
|
|
207
|
+
const trust = buildTrustReport(report)
|
|
208
|
+
|
|
209
|
+
const jsonObject = formatTrustJsonObject(trust)
|
|
210
|
+
expect(jsonObject.$schema).toBe('schemas/drift-trust.v1.json')
|
|
211
|
+
expect(typeof jsonObject.toolVersion).toBe('string')
|
|
212
|
+
expect(jsonObject.toolVersion.length).toBeGreaterThan(0)
|
|
213
|
+
expect(jsonObject.trust_score).toBe(trust.trust_score)
|
|
214
|
+
expect(jsonObject.merge_risk).toBe(trust.merge_risk)
|
|
215
|
+
expect(jsonObject.targetPath).toBe('/tmp/metadata')
|
|
216
|
+
|
|
217
|
+
const parsed = JSON.parse(formatTrustJson(trust)) as Record<string, unknown>
|
|
218
|
+
expect(parsed.$schema).toBe('schemas/drift-trust.v1.json')
|
|
219
|
+
expect(typeof parsed.toolVersion).toBe('string')
|
|
220
|
+
})
|
|
221
|
+
|
|
204
222
|
it('keeps baseline trust contract unchanged when advanced mode is disabled', () => {
|
|
205
223
|
const report = createBaseReport({
|
|
206
224
|
totalScore: 28,
|