@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
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import kleur from 'kleur'
|
|
4
|
+
import type { DriftOutputMetadata } from './types.js'
|
|
5
|
+
import { OUTPUT_SCHEMA, withOutputMetadata } from './output-metadata.js'
|
|
6
|
+
|
|
7
|
+
export interface DoctorOptions {
|
|
8
|
+
json?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DoctorReport {
|
|
12
|
+
targetPath: string
|
|
13
|
+
node: {
|
|
14
|
+
version: string
|
|
15
|
+
major: number
|
|
16
|
+
supported: boolean
|
|
17
|
+
}
|
|
18
|
+
project: {
|
|
19
|
+
packageJsonFound: boolean
|
|
20
|
+
esm: boolean
|
|
21
|
+
tsconfigFound: boolean
|
|
22
|
+
sourceFilesCount: number
|
|
23
|
+
lowMemorySuggested: boolean
|
|
24
|
+
driftConfigFile: string | null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DoctorReportJson = DoctorReport & DriftOutputMetadata
|
|
29
|
+
|
|
30
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
|
|
31
|
+
const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'coverage'])
|
|
32
|
+
const DECIMAL_RADIX = 10
|
|
33
|
+
const MIN_SUPPORTED_NODE_MAJOR = 20
|
|
34
|
+
const LOW_MEMORY_SOURCE_FILE_THRESHOLD = 500
|
|
35
|
+
const DRIFT_CONFIG_CANDIDATES = [
|
|
36
|
+
'drift.config.ts',
|
|
37
|
+
'drift.config.js',
|
|
38
|
+
'drift.config.mjs',
|
|
39
|
+
'drift.config.cjs',
|
|
40
|
+
'drift.config.json',
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
function parseNodeMajor(version: string): number {
|
|
44
|
+
const parsed = Number.parseInt(version.replace(/^v/, '').split('.')[0] ?? '0', DECIMAL_RADIX)
|
|
45
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detectDriftConfig(projectPath: string): string | null {
|
|
49
|
+
for (const candidate of DRIFT_CONFIG_CANDIDATES) {
|
|
50
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
51
|
+
return candidate
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function countSourceFiles(projectPath: string): number {
|
|
58
|
+
let total = 0
|
|
59
|
+
const stack = [projectPath]
|
|
60
|
+
|
|
61
|
+
while (stack.length > 0) {
|
|
62
|
+
const currentDir = stack.pop()
|
|
63
|
+
if (!currentDir) continue
|
|
64
|
+
|
|
65
|
+
const entries = readdirSync(currentDir, { withFileTypes: true })
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
|
|
68
|
+
stack.push(join(currentDir, entry.name))
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!entry.isFile()) continue
|
|
77
|
+
|
|
78
|
+
const lastDot = entry.name.lastIndexOf('.')
|
|
79
|
+
if (lastDot === -1) continue
|
|
80
|
+
|
|
81
|
+
const extension = entry.name.slice(lastDot)
|
|
82
|
+
if (SOURCE_EXTENSIONS.has(extension)) {
|
|
83
|
+
total += 1
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return total
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildDoctorReport(projectPath: string): DoctorReport {
|
|
92
|
+
const nodeMajor = parseNodeMajor(process.version)
|
|
93
|
+
const packageJsonPath = join(projectPath, 'package.json')
|
|
94
|
+
const packageJsonFound = existsSync(packageJsonPath)
|
|
95
|
+
|
|
96
|
+
let esm = false
|
|
97
|
+
if (packageJsonFound) {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }
|
|
99
|
+
esm = parsed.type === 'module'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sourceFilesCount = countSourceFiles(projectPath)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
targetPath: projectPath,
|
|
106
|
+
node: {
|
|
107
|
+
version: process.version,
|
|
108
|
+
major: nodeMajor,
|
|
109
|
+
supported: nodeMajor >= MIN_SUPPORTED_NODE_MAJOR,
|
|
110
|
+
},
|
|
111
|
+
project: {
|
|
112
|
+
packageJsonFound,
|
|
113
|
+
esm,
|
|
114
|
+
tsconfigFound: existsSync(join(projectPath, 'tsconfig.json')),
|
|
115
|
+
sourceFilesCount,
|
|
116
|
+
lowMemorySuggested: sourceFilesCount > LOW_MEMORY_SOURCE_FILE_THRESHOLD,
|
|
117
|
+
driftConfigFile: detectDriftConfig(projectPath),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printConsoleReport(report: DoctorReport): void {
|
|
123
|
+
const icons = {
|
|
124
|
+
check: kleur.green('✓'),
|
|
125
|
+
warn: kleur.yellow('⚠'),
|
|
126
|
+
error: kleur.red('✗'),
|
|
127
|
+
info: kleur.cyan('ℹ'),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process.stdout.write('\n')
|
|
131
|
+
process.stdout.write(`${kleur.bold().white('drift doctor')} ${kleur.gray('- environment diagnostics')}\n\n`)
|
|
132
|
+
|
|
133
|
+
const nodeStatus = report.node.supported
|
|
134
|
+
? `${icons.check} ${kleur.green('Node runtime supported')}`
|
|
135
|
+
: `${icons.warn} ${kleur.yellow('Node runtime below supported minimum (>=20)')}`
|
|
136
|
+
process.stdout.write(`${nodeStatus} ${kleur.gray(`(${report.node.version})`)}\n`)
|
|
137
|
+
|
|
138
|
+
if (report.project.packageJsonFound) {
|
|
139
|
+
process.stdout.write(`${icons.check} package.json found\n`)
|
|
140
|
+
process.stdout.write(`${icons.info} ESM mode: ${report.project.esm ? kleur.green('yes') : kleur.yellow('no')}\n`)
|
|
141
|
+
} else {
|
|
142
|
+
process.stdout.write(`${icons.warn} package.json not found\n`)
|
|
143
|
+
process.stdout.write(`${icons.info} ESM mode: ${kleur.gray('unknown')}\n`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (report.project.tsconfigFound) {
|
|
147
|
+
process.stdout.write(`${icons.check} tsconfig.json found\n`)
|
|
148
|
+
} else {
|
|
149
|
+
process.stdout.write(`${icons.warn} tsconfig.json not found\n`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
process.stdout.write(`${icons.info} Source files (.ts/.tsx/.js/.jsx): ${report.project.sourceFilesCount}\n`)
|
|
153
|
+
|
|
154
|
+
if (report.project.lowMemorySuggested) {
|
|
155
|
+
process.stdout.write(`${icons.warn} Large codebase detected, consider ${kleur.bold('--low-memory')}\n`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (report.project.driftConfigFile) {
|
|
159
|
+
process.stdout.write(`${icons.check} Drift config: ${report.project.driftConfigFile}\n`)
|
|
160
|
+
} else {
|
|
161
|
+
process.stdout.write(`${icons.warn} Drift config not found (drift.config.*)\n`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
process.stdout.write('\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function formatDoctorJsonObject(report: DoctorReport): DoctorReportJson {
|
|
168
|
+
return withOutputMetadata(report, OUTPUT_SCHEMA.doctor)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatDoctorJson(report: DoctorReport): string {
|
|
172
|
+
return JSON.stringify(formatDoctorJsonObject(report), null, 2)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runDoctor(projectPath: string, options?: DoctorOptions): Promise<number> {
|
|
176
|
+
const report = buildDoctorReport(projectPath)
|
|
177
|
+
|
|
178
|
+
if (options?.json) {
|
|
179
|
+
process.stdout.write(`${formatDoctorJson(report)}\n`)
|
|
180
|
+
} else {
|
|
181
|
+
printConsoleReport(report)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return 0
|
|
185
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const UNIFIED_FORMAT_VALUES = ['console', 'json', 'markdown', 'ai', 'sarif'] as const
|
|
2
|
+
|
|
3
|
+
type UnifiedOutputFormat = (typeof UNIFIED_FORMAT_VALUES)[number]
|
|
4
|
+
|
|
5
|
+
type LegacyAlias = {
|
|
6
|
+
flag: string
|
|
7
|
+
used?: boolean
|
|
8
|
+
mapsTo: UnifiedOutputFormat
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ResolveOutputFormatOptions {
|
|
12
|
+
command: string
|
|
13
|
+
format?: string
|
|
14
|
+
supported: readonly UnifiedOutputFormat[]
|
|
15
|
+
legacyAliases?: LegacyAlias[]
|
|
16
|
+
onWarning?: (message: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertSupportedFormatValue(command: string, format: string): asserts format is UnifiedOutputFormat {
|
|
20
|
+
if ((UNIFIED_FORMAT_VALUES as readonly string[]).includes(format)) return
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid --format '${format}' for '${command}'. Allowed values: ${UNIFIED_FORMAT_VALUES.join(', ')}.`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwUnsupportedFormat(command: string, selected: UnifiedOutputFormat, supported: readonly UnifiedOutputFormat[]): never {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Format '${selected}' is not supported for '${command}'. Supported formats: ${supported.join(', ')}.`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeLegacyFormatSelection(command: string, selectedLegacyFormats: UnifiedOutputFormat[]): UnifiedOutputFormat | undefined {
|
|
33
|
+
if (selectedLegacyFormats.length === 0) return undefined
|
|
34
|
+
|
|
35
|
+
const uniqueFormats = [...new Set(selectedLegacyFormats)]
|
|
36
|
+
if (uniqueFormats.length > 1) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Conflicting legacy format flags for '${command}': ${uniqueFormats.join(' vs ')}. Use a single format option.`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return uniqueFormats[0]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveOutputFormat(options: ResolveOutputFormatOptions): UnifiedOutputFormat {
|
|
46
|
+
const { command, format, supported, onWarning } = options
|
|
47
|
+
const legacyAliases = options.legacyAliases ?? []
|
|
48
|
+
|
|
49
|
+
for (const alias of legacyAliases) {
|
|
50
|
+
if (!alias.used) continue
|
|
51
|
+
onWarning?.(`Warning: --${alias.flag} is deprecated for '${command}'. Use --format ${alias.mapsTo} instead.`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const selectedLegacyFormat = normalizeLegacyFormatSelection(
|
|
55
|
+
command,
|
|
56
|
+
legacyAliases.filter((alias) => alias.used).map((alias) => alias.mapsTo),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const selectedFormat = format?.trim()
|
|
60
|
+
if (selectedFormat) {
|
|
61
|
+
assertSupportedFormatValue(command, selectedFormat)
|
|
62
|
+
if (selectedLegacyFormat && selectedLegacyFormat !== selectedFormat) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Conflicting format flags for '${command}': --format ${selectedFormat} and legacy alias for ${selectedLegacyFormat}.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!supported.includes(selectedFormat)) {
|
|
69
|
+
throwUnsupportedFormat(command, selectedFormat, supported)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return selectedFormat
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resolvedFromLegacy = selectedLegacyFormat ?? 'console'
|
|
76
|
+
if (!supported.includes(resolvedFromLegacy)) {
|
|
77
|
+
throwUnsupportedFormat(command, resolvedFromLegacy, supported)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return resolvedFromLegacy
|
|
81
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { GuardBaseline, IssueSeverity } from './guard-types.js'
|
|
4
|
+
|
|
5
|
+
export interface NormalizedBaseline {
|
|
6
|
+
score?: number
|
|
7
|
+
totalIssues?: number
|
|
8
|
+
bySeverity: Partial<Record<IssueSeverity, number>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseNumber(value: unknown): number | undefined {
|
|
12
|
+
return typeof value === 'number' && !Number.isNaN(value) ? value : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function firstDefinedNumber(values: unknown[]): number | undefined {
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
const parsed = parseNumber(value)
|
|
18
|
+
if (parsed !== undefined) {
|
|
19
|
+
return parsed
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeSeverity(baseline: GuardBaseline, severity: IssueSeverity): number | undefined {
|
|
27
|
+
const summaryBySeverity = baseline.summary?.[`${severity}s` as 'errors' | 'warnings' | 'infos']
|
|
28
|
+
|
|
29
|
+
return firstDefinedNumber([
|
|
30
|
+
baseline.bySeverity?.[severity],
|
|
31
|
+
severity === 'error' ? baseline.errors : undefined,
|
|
32
|
+
severity === 'warning' ? baseline.warnings : undefined,
|
|
33
|
+
severity === 'info' ? baseline.infos : undefined,
|
|
34
|
+
summaryBySeverity,
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasAnchor(baseline: NormalizedBaseline): boolean {
|
|
39
|
+
if (baseline.score !== undefined || baseline.totalIssues !== undefined) {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const severities: IssueSeverity[] = ['error', 'warning', 'info']
|
|
44
|
+
return severities.some((severity) => baseline.bySeverity[severity] !== undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeBaseline(baseline: GuardBaseline): NormalizedBaseline {
|
|
48
|
+
const normalized: NormalizedBaseline = {
|
|
49
|
+
score: parseNumber(baseline.score),
|
|
50
|
+
totalIssues: parseNumber(baseline.totalIssues),
|
|
51
|
+
bySeverity: {
|
|
52
|
+
error: normalizeSeverity(baseline, 'error'),
|
|
53
|
+
warning: normalizeSeverity(baseline, 'warning'),
|
|
54
|
+
info: normalizeSeverity(baseline, 'info'),
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!hasAnchor(normalized)) {
|
|
59
|
+
throw new Error('Invalid guard baseline: expected score, totalIssues, or severity counters (error/warning/info).')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return normalized
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readBaselineFromFile(projectPath: string, baselinePath?: string): { baseline: NormalizedBaseline; path: string } | undefined {
|
|
66
|
+
const resolvedBaselinePath = resolve(projectPath, baselinePath ?? 'drift-baseline.json')
|
|
67
|
+
if (!existsSync(resolvedBaselinePath)) return undefined
|
|
68
|
+
|
|
69
|
+
const raw = JSON.parse(readFileSync(resolvedBaselinePath, 'utf8')) as GuardBaseline
|
|
70
|
+
return {
|
|
71
|
+
baseline: normalizeBaseline(raw),
|
|
72
|
+
path: resolvedBaselinePath,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DriftDiff, DriftIssue, DriftReport } from './types.js'
|
|
2
|
+
import type { GuardMetrics, IssueSeverity } from './guard-types.js'
|
|
3
|
+
import type { NormalizedBaseline } from './guard-baseline.js'
|
|
4
|
+
|
|
5
|
+
function createSeverityDelta(): Record<IssueSeverity, number> {
|
|
6
|
+
return {
|
|
7
|
+
error: 0,
|
|
8
|
+
warning: 0,
|
|
9
|
+
info: 0,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function applySeverityDelta(
|
|
14
|
+
delta: Record<IssueSeverity, number>,
|
|
15
|
+
issues: DriftIssue[],
|
|
16
|
+
direction: 1 | -1,
|
|
17
|
+
): void {
|
|
18
|
+
for (const issue of issues) {
|
|
19
|
+
delta[issue.severity] += direction
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function countSeverityDeltaFromDiff(diff: DriftDiff): Record<IssueSeverity, number> {
|
|
24
|
+
const severityDelta = createSeverityDelta()
|
|
25
|
+
|
|
26
|
+
for (const file of diff.files) {
|
|
27
|
+
applySeverityDelta(severityDelta, file.newIssues, 1)
|
|
28
|
+
applySeverityDelta(severityDelta, file.resolvedIssues, -1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return severityDelta
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildMetricsFromDiff(diff: DriftDiff): GuardMetrics {
|
|
35
|
+
return {
|
|
36
|
+
scoreDelta: diff.totalDelta,
|
|
37
|
+
totalIssuesDelta: diff.newIssuesCount - diff.resolvedIssuesCount,
|
|
38
|
+
severityDelta: countSeverityDeltaFromDiff(diff),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildMetricsFromBaseline(current: DriftReport, baseline: NormalizedBaseline): GuardMetrics {
|
|
43
|
+
return {
|
|
44
|
+
scoreDelta: current.totalScore - (baseline.score ?? current.totalScore),
|
|
45
|
+
totalIssuesDelta: current.totalIssues - (baseline.totalIssues ?? current.totalIssues),
|
|
46
|
+
severityDelta: {
|
|
47
|
+
error: current.summary.errors - (baseline.bySeverity.error ?? current.summary.errors),
|
|
48
|
+
warning: current.summary.warnings - (baseline.bySeverity.warning ?? current.summary.warnings),
|
|
49
|
+
info: current.summary.infos - (baseline.bySeverity.info ?? current.summary.infos),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DriftAnalysisOptions, DriftDiff, DriftIssue, DriftOutputMetadata, DriftReport } from './types.js'
|
|
2
|
+
|
|
3
|
+
export type IssueSeverity = DriftIssue['severity']
|
|
4
|
+
|
|
5
|
+
export interface GuardBaseline {
|
|
6
|
+
score?: number
|
|
7
|
+
totalIssues?: number
|
|
8
|
+
errors?: number
|
|
9
|
+
warnings?: number
|
|
10
|
+
infos?: number
|
|
11
|
+
bySeverity?: Partial<Record<IssueSeverity, number>>
|
|
12
|
+
summary?: {
|
|
13
|
+
errors?: number
|
|
14
|
+
warnings?: number
|
|
15
|
+
infos?: number
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GuardThresholds {
|
|
20
|
+
error?: number
|
|
21
|
+
warning?: number
|
|
22
|
+
info?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GuardOptions {
|
|
26
|
+
baseRef?: string
|
|
27
|
+
baselinePath?: string
|
|
28
|
+
baseline?: GuardBaseline
|
|
29
|
+
budget?: number
|
|
30
|
+
bySeverity?: GuardThresholds
|
|
31
|
+
analysis?: DriftAnalysisOptions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GuardMetrics {
|
|
35
|
+
scoreDelta: number
|
|
36
|
+
totalIssuesDelta: number
|
|
37
|
+
severityDelta: Record<IssueSeverity, number>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GuardCheck {
|
|
41
|
+
id: string
|
|
42
|
+
passed: boolean
|
|
43
|
+
actual: number
|
|
44
|
+
limit: number
|
|
45
|
+
message: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GuardEvaluation {
|
|
49
|
+
passed: boolean
|
|
50
|
+
checks: GuardCheck[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GuardResult {
|
|
54
|
+
scannedAt: string
|
|
55
|
+
projectPath: string
|
|
56
|
+
mode: 'diff' | 'baseline'
|
|
57
|
+
passed: boolean
|
|
58
|
+
baseRef?: string
|
|
59
|
+
baselinePath?: string
|
|
60
|
+
metrics: GuardMetrics
|
|
61
|
+
checks: GuardCheck[]
|
|
62
|
+
current: DriftReport
|
|
63
|
+
diff?: DriftDiff
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type GuardResultJson = GuardResult & DriftOutputMetadata
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { relative, resolve } from 'node:path'
|
|
2
|
+
import { analyzeProject } from './analyzer.js'
|
|
3
|
+
import { loadConfig } from './config.js'
|
|
4
|
+
import { computeDiff } from './diff.js'
|
|
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'
|
|
9
|
+
import { buildReport } from './reporter.js'
|
|
10
|
+
import type { DriftReport } from './types.js'
|
|
11
|
+
import type {
|
|
12
|
+
GuardCheck,
|
|
13
|
+
GuardEvaluation,
|
|
14
|
+
GuardMetrics,
|
|
15
|
+
GuardOptions,
|
|
16
|
+
GuardResult,
|
|
17
|
+
GuardResultJson,
|
|
18
|
+
GuardThresholds,
|
|
19
|
+
IssueSeverity,
|
|
20
|
+
} from './guard-types.js'
|
|
21
|
+
import type { NormalizedBaseline } from './guard-baseline.js'
|
|
22
|
+
|
|
23
|
+
interface GuardEvalInput {
|
|
24
|
+
metrics: GuardMetrics
|
|
25
|
+
budget?: number
|
|
26
|
+
bySeverity?: GuardThresholds
|
|
27
|
+
enforceNoRegression: {
|
|
28
|
+
score: boolean
|
|
29
|
+
totalIssues: boolean
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GuardRuntimeState {
|
|
34
|
+
currentReport: DriftReport
|
|
35
|
+
config: Awaited<ReturnType<typeof loadConfig>>
|
|
36
|
+
projectPath: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DiffGuardResultInput {
|
|
40
|
+
projectPath: string
|
|
41
|
+
currentReport: DriftReport
|
|
42
|
+
options: GuardOptions
|
|
43
|
+
tempDir: string
|
|
44
|
+
config: Awaited<ReturnType<typeof loadConfig>>
|
|
45
|
+
baseRef: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BaselineGuardResultInput {
|
|
49
|
+
projectPath: string
|
|
50
|
+
currentReport: DriftReport
|
|
51
|
+
options: GuardOptions
|
|
52
|
+
baseline: NormalizedBaseline
|
|
53
|
+
baselinePath?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function remapBaseReportPaths(baseReport: DriftReport, tempDir: string, projectPath: string): DriftReport {
|
|
57
|
+
return {
|
|
58
|
+
...baseReport,
|
|
59
|
+
files: baseReport.files.map((file) => ({
|
|
60
|
+
...file,
|
|
61
|
+
path: resolve(projectPath, relative(tempDir, file.path)),
|
|
62
|
+
})),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
interface GuardCheckInput {
|
|
68
|
+
id: string
|
|
69
|
+
actual: number
|
|
70
|
+
limit: number
|
|
71
|
+
message: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function addCheck(checks: GuardCheck[], input: GuardCheckInput): void {
|
|
75
|
+
checks.push({
|
|
76
|
+
id: input.id,
|
|
77
|
+
passed: input.actual <= input.limit,
|
|
78
|
+
actual: input.actual,
|
|
79
|
+
limit: input.limit,
|
|
80
|
+
message: input.message,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function evaluateGuard(input: GuardEvalInput): GuardEvaluation {
|
|
85
|
+
const checks: GuardCheck[] = []
|
|
86
|
+
|
|
87
|
+
if (input.enforceNoRegression.score) {
|
|
88
|
+
addCheck(checks, {
|
|
89
|
+
id: 'no-regression-score',
|
|
90
|
+
actual: input.metrics.scoreDelta,
|
|
91
|
+
limit: 0,
|
|
92
|
+
message: 'Score delta must be <= 0.',
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.enforceNoRegression.totalIssues) {
|
|
97
|
+
addCheck(checks, {
|
|
98
|
+
id: 'no-regression-total-issues',
|
|
99
|
+
actual: input.metrics.totalIssuesDelta,
|
|
100
|
+
limit: 0,
|
|
101
|
+
message: 'Total issues delta must be <= 0.',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof input.budget === 'number' && !Number.isNaN(input.budget)) {
|
|
106
|
+
addCheck(checks, {
|
|
107
|
+
id: 'budget-total-delta',
|
|
108
|
+
actual: input.metrics.scoreDelta,
|
|
109
|
+
limit: input.budget,
|
|
110
|
+
message: `Score delta must be <= budget (${input.budget}).`,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const severityThresholds = input.bySeverity
|
|
115
|
+
if (severityThresholds) {
|
|
116
|
+
const severities: IssueSeverity[] = ['error', 'warning', 'info']
|
|
117
|
+
for (const severity of severities) {
|
|
118
|
+
const threshold = severityThresholds[severity]
|
|
119
|
+
if (typeof threshold !== 'number' || Number.isNaN(threshold)) continue
|
|
120
|
+
addCheck(checks, {
|
|
121
|
+
id: `severity-${severity}`,
|
|
122
|
+
actual: input.metrics.severityDelta[severity],
|
|
123
|
+
limit: threshold,
|
|
124
|
+
message: `${severity} delta must be <= ${threshold}.`,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
passed: checks.every((check) => check.passed),
|
|
131
|
+
checks,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
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
|
+
|
|
143
|
+
export async function runGuard(targetPath: string, options: GuardOptions = {}): Promise<GuardResult> {
|
|
144
|
+
const runtimeState = await initializeGuardRuntime(targetPath, options)
|
|
145
|
+
const { projectPath, config, currentReport } = runtimeState
|
|
146
|
+
|
|
147
|
+
let tempDir: string | undefined
|
|
148
|
+
try {
|
|
149
|
+
if (options.baseRef) {
|
|
150
|
+
tempDir = extractFilesAtRef(projectPath, options.baseRef)
|
|
151
|
+
return createDiffGuardResult({
|
|
152
|
+
projectPath,
|
|
153
|
+
currentReport,
|
|
154
|
+
options,
|
|
155
|
+
tempDir,
|
|
156
|
+
config,
|
|
157
|
+
baseRef: options.baseRef,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const inlineBaseline = options.baseline ? normalizeBaseline(options.baseline) : undefined
|
|
162
|
+
const fileBaseline = inlineBaseline ? undefined : readBaselineFromFile(projectPath, options.baselinePath)
|
|
163
|
+
const baseline = inlineBaseline ?? fileBaseline?.baseline
|
|
164
|
+
const baselinePath = fileBaseline?.path
|
|
165
|
+
|
|
166
|
+
if (!baseline) {
|
|
167
|
+
throw new Error('Guard requires a comparison point: provide baseRef or a baseline (inline or file).')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return createBaselineGuardResult({
|
|
171
|
+
projectPath,
|
|
172
|
+
currentReport,
|
|
173
|
+
options,
|
|
174
|
+
baseline,
|
|
175
|
+
baselinePath,
|
|
176
|
+
})
|
|
177
|
+
} finally {
|
|
178
|
+
if (tempDir) cleanupTempDir(tempDir)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function initializeGuardRuntime(targetPath: string, options: GuardOptions): Promise<GuardRuntimeState> {
|
|
183
|
+
const projectPath = resolve(targetPath)
|
|
184
|
+
const config = await loadConfig(projectPath)
|
|
185
|
+
const currentFiles = analyzeProject(projectPath, config, options.analysis)
|
|
186
|
+
const currentReport = buildReport(projectPath, currentFiles)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
projectPath,
|
|
190
|
+
config,
|
|
191
|
+
currentReport,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createDiffGuardResult(input: DiffGuardResultInput): GuardResult {
|
|
196
|
+
const { projectPath, currentReport, options, tempDir, config, baseRef } = input
|
|
197
|
+
const baseFiles = analyzeProject(tempDir, config, options.analysis)
|
|
198
|
+
const baseReport = buildReport(tempDir, baseFiles)
|
|
199
|
+
const remappedBase = remapBaseReportPaths(baseReport, tempDir, projectPath)
|
|
200
|
+
const diff = computeDiff(remappedBase, currentReport, baseRef)
|
|
201
|
+
const metrics = buildMetricsFromDiff(diff)
|
|
202
|
+
const evaluation = evaluateGuard({
|
|
203
|
+
metrics,
|
|
204
|
+
budget: options.budget,
|
|
205
|
+
bySeverity: options.bySeverity,
|
|
206
|
+
enforceNoRegression: {
|
|
207
|
+
score: true,
|
|
208
|
+
totalIssues: true,
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
scannedAt: new Date().toISOString(),
|
|
214
|
+
projectPath,
|
|
215
|
+
mode: 'diff',
|
|
216
|
+
passed: evaluation.passed,
|
|
217
|
+
baseRef,
|
|
218
|
+
metrics,
|
|
219
|
+
checks: evaluation.checks,
|
|
220
|
+
current: currentReport,
|
|
221
|
+
diff,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createBaselineGuardResult(input: BaselineGuardResultInput): GuardResult {
|
|
226
|
+
const { projectPath, currentReport, options, baseline, baselinePath } = input
|
|
227
|
+
const metrics = buildMetricsFromBaseline(currentReport, baseline)
|
|
228
|
+
const evaluation = evaluateGuard({
|
|
229
|
+
metrics,
|
|
230
|
+
budget: options.budget,
|
|
231
|
+
bySeverity: options.bySeverity,
|
|
232
|
+
enforceNoRegression: {
|
|
233
|
+
score: baseline.score !== undefined,
|
|
234
|
+
totalIssues: baseline.totalIssues !== undefined,
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
scannedAt: new Date().toISOString(),
|
|
240
|
+
projectPath,
|
|
241
|
+
mode: 'baseline',
|
|
242
|
+
passed: evaluation.passed,
|
|
243
|
+
baselinePath,
|
|
244
|
+
metrics,
|
|
245
|
+
checks: evaluation.checks,
|
|
246
|
+
current: currentReport,
|
|
247
|
+
}
|
|
248
|
+
}
|