@eduardbar/drift 1.3.0 → 1.4.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 +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/review-pr.yml +34 -41
- package/AGENTS.md +75 -251
- package/CHANGELOG.md +28 -0
- package/README.md +148 -41
- package/dist/benchmark.d.ts +1 -1
- package/dist/benchmark.js +71 -52
- package/dist/cli.js +243 -8
- package/dist/config.js +16 -2
- package/dist/diff.js +42 -50
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -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 +13 -0
- package/dist/output-metadata.js +17 -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 +4 -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/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 +3 -2
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/benchmark.ts +75 -53
- package/src/cli.ts +285 -13
- package/src/config.ts +19 -2
- package/src/diff.ts +57 -48
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +35 -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 +30 -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 +78 -409
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +10 -2
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +31 -4
- package/tests/trust.test.ts +18 -0
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { printConsole, printDiff } from './printer.js'
|
|
|
14
14
|
import { loadConfig } from './config.js'
|
|
15
15
|
import { extractFilesAtRef, cleanupTempDir } from './git.js'
|
|
16
16
|
import { computeDiff } from './diff.js'
|
|
17
|
+
import { runGuard } from './guard.js'
|
|
17
18
|
import { generateHtmlReport } from './report.js'
|
|
18
19
|
import { generateBadge } from './badge.js'
|
|
19
20
|
import { emitCIAnnotations, printCISummary } from './ci.js'
|
|
@@ -42,10 +43,15 @@ import {
|
|
|
42
43
|
detectBranchName,
|
|
43
44
|
} from './trust.js'
|
|
44
45
|
import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js'
|
|
46
|
+
import { runBenchmarkCli } from './benchmark.js'
|
|
47
|
+
import { runInit, INIT_PRESETS } from './init.js'
|
|
48
|
+
import { runDoctor } from './doctor.js'
|
|
49
|
+
import { resolveOutputFormat } from './format.js'
|
|
50
|
+
import { toSarif, diffToSarif } from './sarif.js'
|
|
45
51
|
import type { DriftDiff, DriftTrustReport, DriftAnalysisOptions, MergeRiskLevel } from './types.js'
|
|
52
|
+
import type { GuardResult, GuardThresholds } from './guard-types.js'
|
|
46
53
|
import type { TrustGatePolicyExplanation } from './trust.js'
|
|
47
54
|
import type { SnapshotHistory } from './snapshot.js'
|
|
48
|
-
|
|
49
55
|
const program = new Command()
|
|
50
56
|
|
|
51
57
|
type ResourceOptionFlags = {
|
|
@@ -84,6 +90,96 @@ function addResourceOptions(command: Command): Command {
|
|
|
84
90
|
.option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode')
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
function parseOptionalNumber(rawValue: string | undefined, flagName: string): number | undefined {
|
|
94
|
+
if (rawValue == null) return undefined
|
|
95
|
+
const value = Number(rawValue)
|
|
96
|
+
if (!Number.isFinite(value)) {
|
|
97
|
+
throw new Error(`${flagName} must be a valid number`)
|
|
98
|
+
}
|
|
99
|
+
return value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseBySeverity(rawValue: string | undefined): GuardThresholds | undefined {
|
|
103
|
+
if (rawValue == null) return undefined
|
|
104
|
+
|
|
105
|
+
const spec = rawValue.trim()
|
|
106
|
+
if (!spec) {
|
|
107
|
+
throw new Error('--by-severity must not be empty. Expected format: error=0,warning=2,info=5')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const thresholds: GuardThresholds = {}
|
|
111
|
+
const seen = new Set<string>()
|
|
112
|
+
|
|
113
|
+
for (const segment of spec.split(',')) {
|
|
114
|
+
const pair = segment.trim()
|
|
115
|
+
if (!pair) continue
|
|
116
|
+
|
|
117
|
+
const equalIndex = pair.indexOf('=')
|
|
118
|
+
if (equalIndex <= 0 || equalIndex === pair.length - 1) {
|
|
119
|
+
throw new Error(`Invalid --by-severity entry '${pair}'. Expected key=value (e.g. warning=2).`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const key = pair.slice(0, equalIndex).trim().toLowerCase()
|
|
123
|
+
const rawThreshold = pair.slice(equalIndex + 1).trim()
|
|
124
|
+
|
|
125
|
+
if (key !== 'error' && key !== 'warning' && key !== 'info') {
|
|
126
|
+
throw new Error(`Invalid --by-severity key '${key}'. Allowed keys: error, warning, info.`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (seen.has(key)) {
|
|
130
|
+
throw new Error(`Duplicate --by-severity key '${key}'.`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const threshold = Number(rawThreshold)
|
|
134
|
+
if (!Number.isFinite(threshold)) {
|
|
135
|
+
throw new Error(`Invalid --by-severity value for '${key}': '${rawThreshold}'. Must be a valid number.`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const severityKey: keyof GuardThresholds = key
|
|
139
|
+
thresholds[severityKey] = threshold
|
|
140
|
+
seen.add(severityKey)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (seen.size === 0) {
|
|
144
|
+
throw new Error('--by-severity must include at least one threshold. Example: error=0,warning=2')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return thresholds
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatSigned(value: number): string {
|
|
151
|
+
return value > 0 ? `+${value}` : `${value}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function printGuardSummary(result: GuardResult): void {
|
|
155
|
+
const modeLabel = result.mode === 'diff' ? `diff (${result.baseRef ?? 'unknown base'})` : 'baseline'
|
|
156
|
+
const statusLabel = result.passed ? 'PASS' : 'FAIL'
|
|
157
|
+
|
|
158
|
+
process.stdout.write('\n')
|
|
159
|
+
process.stdout.write(`Guard mode: ${modeLabel}\n`)
|
|
160
|
+
process.stdout.write(`Result: ${statusLabel}\n`)
|
|
161
|
+
process.stdout.write(`Score delta: ${formatSigned(result.metrics.scoreDelta)}\n`)
|
|
162
|
+
process.stdout.write(`Total issues delta: ${formatSigned(result.metrics.totalIssuesDelta)}\n`)
|
|
163
|
+
process.stdout.write(
|
|
164
|
+
`Severity delta: error=${formatSigned(result.metrics.severityDelta.error)}, warning=${formatSigned(result.metrics.severityDelta.warning)}, info=${formatSigned(result.metrics.severityDelta.info)}\n`,
|
|
165
|
+
)
|
|
166
|
+
if (result.mode === 'baseline' && result.baselinePath) {
|
|
167
|
+
process.stdout.write(`Baseline file: ${result.baselinePath}\n`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (result.checks.length === 0) {
|
|
171
|
+
process.stdout.write('Checks: none configured\n')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
process.stdout.write('Checks:\n')
|
|
176
|
+
for (const check of result.checks) {
|
|
177
|
+
process.stdout.write(
|
|
178
|
+
` - [${check.passed ? 'PASS' : 'FAIL'}] ${check.id}: ${check.message} (actual=${check.actual}, limit=${check.limit})\n`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
87
183
|
function parseTrustGateOverrides(options: { minTrust?: string; maxRisk?: string }): { minTrust?: number; maxRisk?: MergeRiskLevel } {
|
|
88
184
|
const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined
|
|
89
185
|
if (options.minTrust && Number.isNaN(cliMinTrust)) {
|
|
@@ -135,11 +231,12 @@ addResourceOptions(
|
|
|
135
231
|
.command('scan [path]', { isDefault: true })
|
|
136
232
|
.description('Scan a directory for vibe coding drift')
|
|
137
233
|
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
234
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
138
235
|
.option('--json', 'Output raw JSON report')
|
|
139
236
|
.option('--ai', 'Output AI-optimized JSON for LLM consumption')
|
|
140
237
|
.option('--fix', 'Show fix suggestions for each issue')
|
|
141
238
|
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
142
|
-
.action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
|
|
239
|
+
.action(async (targetPath: string | undefined, options: { output?: string; format?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
|
|
143
240
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
144
241
|
|
|
145
242
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
@@ -148,17 +245,38 @@ addResourceOptions(
|
|
|
148
245
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
149
246
|
const report = buildReport(resolvedPath, files)
|
|
150
247
|
|
|
151
|
-
|
|
248
|
+
const format = resolveOutputFormat({
|
|
249
|
+
command: 'scan',
|
|
250
|
+
format: options.format,
|
|
251
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
252
|
+
legacyAliases: [
|
|
253
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
254
|
+
{ flag: 'ai', used: options.ai, mapsTo: 'ai' },
|
|
255
|
+
],
|
|
256
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
if (format === 'sarif') {
|
|
260
|
+
process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (format === 'ai') {
|
|
152
265
|
const aiOutput = formatAIOutput(report)
|
|
153
266
|
process.stdout.write(JSON.stringify(aiOutput, null, 2))
|
|
154
267
|
return
|
|
155
268
|
}
|
|
156
269
|
|
|
157
|
-
if (
|
|
270
|
+
if (format === 'json') {
|
|
158
271
|
process.stdout.write(JSON.stringify(report, null, 2))
|
|
159
272
|
return
|
|
160
273
|
}
|
|
161
274
|
|
|
275
|
+
if (format === 'markdown') {
|
|
276
|
+
process.stdout.write(`${formatMarkdown(report)}\n`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
162
280
|
printConsole(report, { showFix: options.fix })
|
|
163
281
|
|
|
164
282
|
if (options.output) {
|
|
@@ -176,12 +294,35 @@ addResourceOptions(
|
|
|
176
294
|
}),
|
|
177
295
|
)
|
|
178
296
|
|
|
297
|
+
program
|
|
298
|
+
.command('init')
|
|
299
|
+
.description('Initialize drift configuration with presets and scaffolding')
|
|
300
|
+
.option('--preset <type>', `Scaffold config with preset: ${INIT_PRESETS.join(', ')}`)
|
|
301
|
+
.option('--ci', 'Generate GitHub Actions workflow for drift review')
|
|
302
|
+
.option('--baseline', 'Create drift-baseline.json with current project score')
|
|
303
|
+
.action(async (options: { preset?: string; ci?: boolean; baseline?: boolean }) => {
|
|
304
|
+
const projectRoot = resolve('.')
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await runInit(projectRoot, {
|
|
308
|
+
preset: options.preset,
|
|
309
|
+
ci: options.ci,
|
|
310
|
+
baseline: options.baseline,
|
|
311
|
+
})
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
314
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
315
|
+
process.exit(1)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
179
319
|
addResourceOptions(
|
|
180
320
|
program
|
|
181
321
|
.command('diff [ref]')
|
|
182
322
|
.description('Compare current state against a git ref (default: HEAD~1)')
|
|
323
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
183
324
|
.option('--json', 'Output raw JSON diff')
|
|
184
|
-
.action(async (ref: string | undefined, options: { json?: boolean } & ResourceOptionFlags) => {
|
|
325
|
+
.action(async (ref: string | undefined, options: { format?: string; json?: boolean } & ResourceOptionFlags) => {
|
|
185
326
|
const baseRef = ref ?? 'HEAD~1'
|
|
186
327
|
const projectPath = resolve('.')
|
|
187
328
|
const analysisOptions = resolveAnalysisOptions(options)
|
|
@@ -191,6 +332,14 @@ addResourceOptions(
|
|
|
191
332
|
try {
|
|
192
333
|
process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`)
|
|
193
334
|
|
|
335
|
+
const format = resolveOutputFormat({
|
|
336
|
+
command: 'diff',
|
|
337
|
+
format: options.format,
|
|
338
|
+
supported: ['console', 'json', 'sarif'],
|
|
339
|
+
legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
|
|
340
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
341
|
+
})
|
|
342
|
+
|
|
194
343
|
// Scan current state
|
|
195
344
|
const config = await loadConfig(projectPath)
|
|
196
345
|
const currentFiles = analyzeProject(projectPath, config, analysisOptions)
|
|
@@ -213,7 +362,9 @@ addResourceOptions(
|
|
|
213
362
|
|
|
214
363
|
const diff = computeDiff(remappedBase, currentReport, baseRef)
|
|
215
364
|
|
|
216
|
-
if (
|
|
365
|
+
if (format === 'sarif') {
|
|
366
|
+
process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`)
|
|
367
|
+
} else if (format === 'json') {
|
|
217
368
|
process.stdout.write(JSON.stringify(diff, null, 2) + '\n')
|
|
218
369
|
} else {
|
|
219
370
|
printDiff(diff)
|
|
@@ -228,21 +379,93 @@ addResourceOptions(
|
|
|
228
379
|
}),
|
|
229
380
|
)
|
|
230
381
|
|
|
382
|
+
addResourceOptions(
|
|
383
|
+
program
|
|
384
|
+
.command('guard [path]')
|
|
385
|
+
.description('Evaluate drift guard thresholds against diff or baseline')
|
|
386
|
+
.option('--base <ref>', 'Git base ref for diff guard mode')
|
|
387
|
+
.option('--baseline <file>', 'Baseline file path (default: drift-baseline.json)')
|
|
388
|
+
.option('--budget <n>', 'Allowed score delta budget')
|
|
389
|
+
.option('--by-severity <spec>', 'Severity thresholds: error=0,warning=2,info=5')
|
|
390
|
+
.option('--json', 'Output raw JSON guard result')
|
|
391
|
+
.action(async (
|
|
392
|
+
targetPath: string | undefined,
|
|
393
|
+
options: {
|
|
394
|
+
base?: string
|
|
395
|
+
baseline?: string
|
|
396
|
+
budget?: string
|
|
397
|
+
bySeverity?: string
|
|
398
|
+
json?: boolean
|
|
399
|
+
} & ResourceOptionFlags,
|
|
400
|
+
) => {
|
|
401
|
+
try {
|
|
402
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
403
|
+
const budget = parseOptionalNumber(options.budget, '--budget')
|
|
404
|
+
const bySeverity = parseBySeverity(options.bySeverity)
|
|
405
|
+
|
|
406
|
+
const result = await runGuard(resolvedPath, {
|
|
407
|
+
baseRef: options.base,
|
|
408
|
+
baselinePath: options.baseline,
|
|
409
|
+
budget,
|
|
410
|
+
bySeverity,
|
|
411
|
+
analysis: resolveAnalysisOptions(options),
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
if (options.json) {
|
|
415
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
416
|
+
} else {
|
|
417
|
+
printGuardSummary(result)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!result.passed) {
|
|
421
|
+
process.exit(1)
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
425
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
426
|
+
process.exit(1)
|
|
427
|
+
}
|
|
428
|
+
}),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
program
|
|
432
|
+
.command('benchmark')
|
|
433
|
+
.description('Run benchmark harness for scan/review/trust commands')
|
|
434
|
+
.allowUnknownOption(true)
|
|
435
|
+
.action(async () => {
|
|
436
|
+
await runBenchmarkCli(process.argv.slice(3))
|
|
437
|
+
})
|
|
438
|
+
|
|
231
439
|
program
|
|
232
440
|
.command('review')
|
|
233
441
|
.description('Review drift against a base ref and output PR markdown')
|
|
234
442
|
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
443
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
235
444
|
.option('--json', 'Output structured review JSON')
|
|
236
445
|
.option('--comment', 'Output markdown comment body')
|
|
237
446
|
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
238
|
-
.action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
|
|
447
|
+
.action(async (options: { base: string; format?: string; json?: boolean; comment?: boolean; failOn?: string }) => {
|
|
239
448
|
try {
|
|
240
449
|
const review = await generateReview(resolve('.'), options.base)
|
|
450
|
+
const format = resolveOutputFormat({
|
|
451
|
+
command: 'review',
|
|
452
|
+
format: options.format,
|
|
453
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
454
|
+
legacyAliases: [
|
|
455
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
456
|
+
{ flag: 'comment', used: options.comment, mapsTo: 'markdown' },
|
|
457
|
+
],
|
|
458
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
459
|
+
})
|
|
241
460
|
|
|
242
|
-
if (
|
|
461
|
+
if (format === 'sarif') {
|
|
462
|
+
process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`)
|
|
463
|
+
} else if (format === 'json') {
|
|
243
464
|
process.stdout.write(JSON.stringify(review, null, 2) + '\n')
|
|
465
|
+
} else if (format === 'markdown') {
|
|
466
|
+
process.stdout.write(`${review.markdown}\n`)
|
|
244
467
|
} else {
|
|
245
|
-
process.stdout.write(
|
|
468
|
+
process.stdout.write(`${review.summary}\n\n${review.markdown}\n`)
|
|
246
469
|
}
|
|
247
470
|
|
|
248
471
|
const failOn = options.failOn ? Number(options.failOn) : undefined
|
|
@@ -261,6 +484,7 @@ addResourceOptions(
|
|
|
261
484
|
.command('trust [path]')
|
|
262
485
|
.description('Compute merge trust baseline from drift signals')
|
|
263
486
|
.option('--base <ref>', 'Git base ref for diff-aware trust scoring')
|
|
487
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
264
488
|
.option('--json', 'Output structured trust JSON')
|
|
265
489
|
.option('--markdown', 'Output trust report as markdown (PR comment ready)')
|
|
266
490
|
.option('-o, --output <file>', 'Write trust output to file')
|
|
@@ -277,6 +501,7 @@ addResourceOptions(
|
|
|
277
501
|
targetPath: string | undefined,
|
|
278
502
|
options: {
|
|
279
503
|
base?: string
|
|
504
|
+
format?: string
|
|
280
505
|
json?: boolean
|
|
281
506
|
markdown?: boolean
|
|
282
507
|
output?: string
|
|
@@ -364,7 +589,23 @@ addResourceOptions(
|
|
|
364
589
|
},
|
|
365
590
|
})
|
|
366
591
|
|
|
367
|
-
const
|
|
592
|
+
const format = resolveOutputFormat({
|
|
593
|
+
command: 'trust',
|
|
594
|
+
format: options.format,
|
|
595
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
596
|
+
legacyAliases: [
|
|
597
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
598
|
+
{ flag: 'markdown', used: options.markdown, mapsTo: 'markdown' },
|
|
599
|
+
],
|
|
600
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const rendered = format === 'sarif'
|
|
604
|
+
? `${JSON.stringify(toSarif(report), null, 2)}\n`
|
|
605
|
+
: `${renderTrustOutput(trust, {
|
|
606
|
+
json: format === 'json',
|
|
607
|
+
markdown: format === 'markdown',
|
|
608
|
+
})}\n`
|
|
368
609
|
|
|
369
610
|
process.stdout.write(rendered)
|
|
370
611
|
|
|
@@ -469,6 +710,20 @@ program
|
|
|
469
710
|
}
|
|
470
711
|
})
|
|
471
712
|
|
|
713
|
+
program
|
|
714
|
+
.command('doctor')
|
|
715
|
+
.description('Run project environment diagnostics')
|
|
716
|
+
.option('--json', 'Output structured doctor JSON')
|
|
717
|
+
.action(async (opts: { json?: boolean }) => {
|
|
718
|
+
try {
|
|
719
|
+
await runDoctor(process.cwd(), { json: opts.json })
|
|
720
|
+
} catch (err) {
|
|
721
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
722
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
723
|
+
process.exitCode = 1
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
|
|
472
727
|
program
|
|
473
728
|
.command('kpi <path>')
|
|
474
729
|
.description('Aggregate trust KPIs from trust JSON artifacts')
|
|
@@ -543,14 +798,31 @@ addResourceOptions(
|
|
|
543
798
|
program
|
|
544
799
|
.command('ci [path]')
|
|
545
800
|
.description('Emit GitHub Actions annotations and step summary')
|
|
801
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
802
|
+
.option('--json', 'Output raw JSON report (legacy alias for --format json)')
|
|
546
803
|
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
547
|
-
.action(async (targetPath: string | undefined, options: { minScore: string } & ResourceOptionFlags) => {
|
|
804
|
+
.action(async (targetPath: string | undefined, options: { format?: string; json?: boolean; minScore: string } & ResourceOptionFlags) => {
|
|
548
805
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
549
806
|
const config = await loadConfig(resolvedPath)
|
|
550
807
|
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
551
808
|
const report = buildReport(resolvedPath, files)
|
|
552
|
-
|
|
553
|
-
|
|
809
|
+
|
|
810
|
+
const format = resolveOutputFormat({
|
|
811
|
+
command: 'ci',
|
|
812
|
+
format: options.format,
|
|
813
|
+
supported: ['console', 'json', 'sarif'],
|
|
814
|
+
legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
|
|
815
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
if (format === 'sarif') {
|
|
819
|
+
process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`)
|
|
820
|
+
} else if (format === 'json') {
|
|
821
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
822
|
+
} else {
|
|
823
|
+
emitCIAnnotations(report)
|
|
824
|
+
printCISummary(report)
|
|
825
|
+
}
|
|
554
826
|
const minScore = Number(options.minScore)
|
|
555
827
|
if (minScore > 0 && report.totalScore > minScore) {
|
|
556
828
|
process.exit(1)
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,22 @@ import { join, resolve } from 'node:path'
|
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import type { DriftConfig } from './types.js'
|
|
5
5
|
|
|
6
|
+
function normalizeLegacyConfig(config: DriftConfig): DriftConfig {
|
|
7
|
+
if (config.modules !== undefined) {
|
|
8
|
+
return config
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const legacyModules = config.moduleBoundaries ?? config.boundaries
|
|
12
|
+
if (!legacyModules || legacyModules.length === 0) {
|
|
13
|
+
return config
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...config,
|
|
18
|
+
modules: legacyModules,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* Load drift.config.ts / .js / .json from the given project root.
|
|
8
24
|
* Returns undefined if no config file is found.
|
|
@@ -27,7 +43,8 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
|
|
|
27
43
|
|
|
28
44
|
if (ext === 'json') {
|
|
29
45
|
const { readFileSync } = await import('node:fs')
|
|
30
|
-
|
|
46
|
+
const rawConfig = JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
|
|
47
|
+
return normalizeLegacyConfig(rawConfig)
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
// .ts / .js — dynamic import via file URL
|
|
@@ -35,7 +52,7 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
|
|
|
35
52
|
const mod = await import(fileUrl)
|
|
36
53
|
const config: DriftConfig = mod.default ?? mod
|
|
37
54
|
|
|
38
|
-
return config
|
|
55
|
+
return normalizeLegacyConfig(config)
|
|
39
56
|
} catch { // drift-ignore
|
|
40
57
|
// drift-ignore: catch-swallow — config is optional; load failure is non-fatal
|
|
41
58
|
}
|
package/src/diff.ts
CHANGED
|
@@ -12,6 +12,58 @@ function normalizeIssueText(value: string): string {
|
|
|
12
12
|
.trim()
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const SNIPPET_PREFIX_LENGTH = 80
|
|
16
|
+
|
|
17
|
+
interface IssueMatchState {
|
|
18
|
+
matchedBaseIndexes: Set<number>
|
|
19
|
+
matchedCurrentIndexes: Set<number>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function strictIssueKey(i: DriftIssue): string {
|
|
23
|
+
return `${i.rule}:${i.line}:${i.column}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizedIssueKey(i: DriftIssue): string {
|
|
27
|
+
const normalizedMessage = normalizeIssueText(i.message)
|
|
28
|
+
const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, SNIPPET_PREFIX_LENGTH)
|
|
29
|
+
return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildIssueIndex(
|
|
33
|
+
issues: DriftIssue[],
|
|
34
|
+
getKey: (issue: DriftIssue) => string,
|
|
35
|
+
skip?: Set<number>,
|
|
36
|
+
): Map<string, number[]> {
|
|
37
|
+
const index = new Map<string, number[]>()
|
|
38
|
+
for (const [idx, issue] of issues.entries()) {
|
|
39
|
+
if (skip?.has(idx)) continue
|
|
40
|
+
const key = getKey(issue)
|
|
41
|
+
const bucket = index.get(key)
|
|
42
|
+
if (bucket) bucket.push(idx)
|
|
43
|
+
else index.set(key, [idx])
|
|
44
|
+
}
|
|
45
|
+
return index
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchIssues(
|
|
49
|
+
currentIssues: DriftIssue[],
|
|
50
|
+
index: Map<string, number[]>,
|
|
51
|
+
state: IssueMatchState,
|
|
52
|
+
getKey: (issue: DriftIssue) => string,
|
|
53
|
+
): void {
|
|
54
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
55
|
+
if (state.matchedCurrentIndexes.has(currentIndex)) continue
|
|
56
|
+
const bucket = index.get(getKey(issue))
|
|
57
|
+
if (!bucket || bucket.length === 0) continue
|
|
58
|
+
|
|
59
|
+
const matchedIndex = bucket.shift()
|
|
60
|
+
if (matchedIndex === undefined) continue
|
|
61
|
+
|
|
62
|
+
state.matchedBaseIndexes.add(matchedIndex)
|
|
63
|
+
state.matchedCurrentIndexes.add(currentIndex)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
15
67
|
/**
|
|
16
68
|
* Compute the diff between two DriftReports.
|
|
17
69
|
*
|
|
@@ -36,58 +88,15 @@ function computeFileDiff(
|
|
|
36
88
|
const baseIssues = baseFile?.issues ?? []
|
|
37
89
|
const currentIssues = currentFile?.issues ?? []
|
|
38
90
|
|
|
39
|
-
const strictIssueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
-
const normalizedIssueKey = (i: DriftIssue) => {
|
|
41
|
-
const normalizedMessage = normalizeIssueText(i.message)
|
|
42
|
-
const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, 80)
|
|
43
|
-
return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
91
|
const matchedBaseIndexes = new Set<number>()
|
|
47
92
|
const matchedCurrentIndexes = new Set<number>()
|
|
93
|
+
const matchState = { matchedBaseIndexes, matchedCurrentIndexes }
|
|
48
94
|
|
|
49
|
-
const baseStrictIndex =
|
|
50
|
-
|
|
51
|
-
const key = strictIssueKey(issue)
|
|
52
|
-
const bucket = baseStrictIndex.get(key)
|
|
53
|
-
if (bucket) bucket.push(index)
|
|
54
|
-
else baseStrictIndex.set(key, [index])
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
58
|
-
const key = strictIssueKey(issue)
|
|
59
|
-
const bucket = baseStrictIndex.get(key)
|
|
60
|
-
if (!bucket || bucket.length === 0) continue
|
|
61
|
-
|
|
62
|
-
const matchedIndex = bucket.shift()
|
|
63
|
-
if (matchedIndex === undefined) continue
|
|
64
|
-
|
|
65
|
-
matchedBaseIndexes.add(matchedIndex)
|
|
66
|
-
matchedCurrentIndexes.add(currentIndex)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const baseNormalizedIndex = new Map<string, number[]>()
|
|
70
|
-
for (const [index, issue] of baseIssues.entries()) {
|
|
71
|
-
if (matchedBaseIndexes.has(index)) continue
|
|
72
|
-
const key = normalizedIssueKey(issue)
|
|
73
|
-
const bucket = baseNormalizedIndex.get(key)
|
|
74
|
-
if (bucket) bucket.push(index)
|
|
75
|
-
else baseNormalizedIndex.set(key, [index])
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
79
|
-
if (matchedCurrentIndexes.has(currentIndex)) continue
|
|
80
|
-
|
|
81
|
-
const key = normalizedIssueKey(issue)
|
|
82
|
-
const bucket = baseNormalizedIndex.get(key)
|
|
83
|
-
if (!bucket || bucket.length === 0) continue
|
|
95
|
+
const baseStrictIndex = buildIssueIndex(baseIssues, strictIssueKey)
|
|
96
|
+
matchIssues(currentIssues, baseStrictIndex, matchState, strictIssueKey)
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
matchedBaseIndexes.add(matchedIndex)
|
|
89
|
-
matchedCurrentIndexes.add(currentIndex)
|
|
90
|
-
}
|
|
98
|
+
const baseNormalizedIndex = buildIssueIndex(baseIssues, normalizedIssueKey, matchedBaseIndexes)
|
|
99
|
+
matchIssues(currentIssues, baseNormalizedIndex, matchState, normalizedIssueKey)
|
|
91
100
|
|
|
92
101
|
const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
|
|
93
102
|
const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
|