opencode-codeops 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +39 -0
  3. package/_shared/spec-first-ordering.md +6 -2
  4. package/_shared/test-selection.md +130 -0
  5. package/bin/codeops-test.mjs +855 -0
  6. package/bin/lib/test-branch-policy.d.mts +51 -0
  7. package/bin/lib/test-branch-policy.mjs +185 -0
  8. package/bin/lib/test-map-validate.d.mts +42 -0
  9. package/bin/lib/test-map-validate.mjs +618 -0
  10. package/bin/lib/test-map.d.mts +114 -0
  11. package/bin/lib/test-map.mjs +384 -0
  12. package/bin/lib/test-provenance.d.mts +73 -0
  13. package/bin/lib/test-provenance.mjs +188 -0
  14. package/bin/lib/test-runner.d.mts +72 -0
  15. package/bin/lib/test-runner.mjs +350 -0
  16. package/bin/lib/test-selection-changes.d.mts +76 -0
  17. package/bin/lib/test-selection-changes.mjs +272 -0
  18. package/bin/lib/test-selection-explain.mjs +75 -0
  19. package/bin/lib/test-selection-glob.d.mts +32 -0
  20. package/bin/lib/test-selection-glob.mjs +206 -0
  21. package/bin/lib/test-selection.d.mts +80 -0
  22. package/bin/lib/test-selection.mjs +418 -0
  23. package/package.json +4 -2
  24. package/schemas/test-map.schema.json +164 -0
  25. package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
  26. package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
  27. package/skills/analyze-project/SKILL.md +8 -1
  28. package/skills/exec-plan/SKILL.md +16 -8
  29. package/skills/exec-plan/execution-protocol.md +15 -2
  30. package/skills/git-commit/SKILL.md +1 -1
  31. package/skills/make-plan/SKILL.md +10 -3
  32. package/skills/make-plan/quality-checklist.md +4 -0
  33. package/skills/make-plan/templates.md +19 -1
  34. package/skills/organize-tests/SKILL.md +169 -0
  35. package/skills/organize-tests/references/agents-testing-block.md +80 -0
  36. package/skills/organize-tests/references/analysis-protocol.md +94 -0
  37. package/skills/organize-tests/references/ci-github-actions.md +178 -0
  38. package/skills/organize-tests/references/language-adapters.md +54 -0
  39. package/skills/organize-tests/references/map-health.md +47 -0
  40. package/skills/organize-tests/references/project-wrapper.md +72 -0
  41. package/skills/organize-tests/references/shadow-mode.md +60 -0
  42. package/standards/coding-standards-full.md +28 -0
  43. package/standards/coding-standards.md +29 -0
  44. package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
  45. package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
@@ -0,0 +1,855 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `codeops-test` — select and run affected test groups.
4
+ *
5
+ * The CLI is intentionally thin: the map loader, validator, selector, and
6
+ * runner own the behavior. This file parses argv, wires the pieces together,
7
+ * chooses the documented exit code, and formats output.
8
+ *
9
+ * All commands are argv-safe and read-only except log creation and the optional
10
+ * `--record` file. The tool never edits a test, a map, or CI configuration.
11
+ *
12
+ * @module bin/codeops-test
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process"
16
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
17
+ import { resolve } from "node:path"
18
+
19
+ import { classifyRef, buildPlan, nightlyDecision } from "./lib/test-branch-policy.mjs"
20
+ import { loadMap, MapLoadError, normalizeRepoPath } from "./lib/test-map.mjs"
21
+ import { exitCodeFor, validateMap } from "./lib/test-map-validate.mjs"
22
+ import { formatSelection, formatSelectionJson } from "./lib/test-selection-explain.mjs"
23
+ import { selectTests, ALL_GATES } from "./lib/test-selection.mjs"
24
+ import { collectChanges, SelectionError } from "./lib/test-selection-changes.mjs"
25
+ import { createProvenanceRecord, verifyProvenance } from "./lib/test-provenance.mjs"
26
+ import {
27
+ DEFAULT_FULL_TIMEOUT_SECONDS,
28
+ probeRequirement,
29
+ runArgv,
30
+ runCommands,
31
+ } from "./lib/test-runner.mjs"
32
+
33
+ const USAGE = `Usage: codeops-test <command> [options]
34
+
35
+ Commands:
36
+ select Compute a test selection (does not execute)
37
+ run Compute and execute the selected groups
38
+ check Validate the map and report ownership/health issues
39
+ plan Emit a CI matrix for a gate
40
+ shadow Compare selected and full runs
41
+ provenance Write or verify release evidence
42
+
43
+ Options:
44
+ --gate <task|phase|pr|release|acceptance|nightly>
45
+ --baseline <tree|ref> Explicit base (also --since)
46
+ --root <path> Repository root
47
+ --map <path> Alternate map file or directory
48
+ --json Machine-readable output
49
+ --explain Add reasons to select/run output
50
+ --full Force the full suite
51
+ --include <path> Treat a path as changed (repeatable)
52
+ --group <id> Run only the named selected group (repeatable)
53
+ --selection <file> Run from a precomputed selection
54
+ --full-command <argv...> Full command tokens (repeatable)
55
+ --last-full-sha <sha> Last successful full-run marker
56
+ --no-fallback Fail instead of falling back to full
57
+ --keep-logs Keep successful logs
58
+ --metrics Emit content-free metrics
59
+ --strict Promote check warnings to errors
60
+ --fail-fast / --no-fail-fast
61
+ --concurrency <n> Maximum parallel groups (default 1)
62
+ --timeout <seconds> Per-group timeout
63
+ --record <file> Write evidence JSON
64
+ --artifact <path> Artifact for provenance
65
+ --quiet Compact output
66
+ `
67
+
68
+ /** Error raised for malformed command-line input. */
69
+ class UsageError extends Error {}
70
+
71
+ /** Options that consume one value. */
72
+ const VALUE_OPTIONS = new Set([
73
+ "--gate",
74
+ "--baseline",
75
+ "--since",
76
+ "--root",
77
+ "--map",
78
+ "--include",
79
+ "--group",
80
+ "--selection",
81
+ "--last-full-sha",
82
+ "--concurrency",
83
+ "--timeout",
84
+ "--record",
85
+ "--artifact",
86
+ "--coverage",
87
+ "--acceptance",
88
+ "--full-run-id",
89
+ "--source-commit",
90
+ "--commit",
91
+ ])
92
+
93
+ /** Boolean options. */
94
+ const BOOLEAN_OPTIONS = new Set([
95
+ "--json",
96
+ "--explain",
97
+ "--full",
98
+ "--no-fallback",
99
+ "--keep-logs",
100
+ "--metrics",
101
+ "--strict",
102
+ "--fail-fast",
103
+ "--no-fail-fast",
104
+ "--quiet",
105
+ ])
106
+
107
+ /**
108
+ * Parse argv into one options object.
109
+ *
110
+ * `--full-command` consumes every following token up to the next option, so a
111
+ * multi-token command stays one argv. Repeating the flag adds another command.
112
+ *
113
+ * @param {string[]} argv Raw arguments after the program name.
114
+ * @returns {object} Parsed options.
115
+ * @throws {UsageError} On unknown, duplicated, or incomplete options.
116
+ */
117
+ function parseArgs(argv) {
118
+ const command = argv[0]
119
+ if (!command || command === "--help" || command === "-h") {
120
+ return { command: "help" }
121
+ }
122
+ if (!["select", "run", "check", "plan", "shadow", "provenance"].includes(command)) {
123
+ throw new UsageError(`unknown command: ${command}`)
124
+ }
125
+
126
+ const options = {
127
+ command,
128
+ include: [],
129
+ group: [],
130
+ fullCommands: [],
131
+ failFast: true,
132
+ concurrency: 1,
133
+ }
134
+
135
+ for (let index = 1; index < argv.length; index += 1) {
136
+ const token = argv[index]
137
+
138
+ if (command === "provenance" && index === 1 && (token === "write" || token === "verify")) {
139
+ options.action = token
140
+ continue
141
+ }
142
+
143
+ if (token === "--full-command") {
144
+ const tokens = []
145
+ const isKnownOption = (value) => VALUE_OPTIONS.has(value) || BOOLEAN_OPTIONS.has(value) || value === "--full-command"
146
+ while (index + 1 < argv.length && !isKnownOption(argv[index + 1])) {
147
+ tokens.push(argv[index + 1])
148
+ index += 1
149
+ }
150
+ if (tokens.length === 0) throw new UsageError("--full-command needs at least one argv token")
151
+ options.fullCommands.push(tokens)
152
+ continue
153
+ }
154
+
155
+ if (BOOLEAN_OPTIONS.has(token)) {
156
+ if (token === "--fail-fast") options.failFast = true
157
+ else if (token === "--no-fail-fast") options.failFast = false
158
+ else options[toKey(token)] = true
159
+ continue
160
+ }
161
+
162
+ if (VALUE_OPTIONS.has(token)) {
163
+ const value = argv[index + 1]
164
+ if (value === undefined || value.startsWith("--")) throw new UsageError(`${token} needs a value`)
165
+ index += 1
166
+ if (token === "--include") options.include.push(value)
167
+ else if (token === "--group") options.group.push(value)
168
+ else if (token === "--since") options.baseline = value
169
+ else if (token === "--concurrency") options.concurrency = parsePositiveInteger(token, value)
170
+ else if (token === "--timeout") options.timeout = parsePositiveInteger(token, value)
171
+ else options[toKey(token)] = value
172
+ continue
173
+ }
174
+
175
+ throw new UsageError(`unknown option: ${token}`)
176
+ }
177
+
178
+ if (options.gate && !ALL_GATES.includes(options.gate)) {
179
+ throw new UsageError(`unknown gate: ${options.gate}`)
180
+ }
181
+ if (options.command === "provenance" && !["write", "verify"].includes(options.action)) {
182
+ throw new UsageError("provenance needs a write or verify action")
183
+ }
184
+ return options
185
+ }
186
+
187
+ /**
188
+ * Convert an option flag to its property name.
189
+ *
190
+ * @param {string} flag Flag such as `--last-full-sha`.
191
+ * @returns {string} Camel-case property name.
192
+ */
193
+ function toKey(flag) {
194
+ return flag
195
+ .replace(/^--/, "")
196
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
197
+ }
198
+
199
+ /**
200
+ * Parse a positive integer option.
201
+ *
202
+ * @param {string} flag Option name for the error message.
203
+ * @param {string} value Raw value.
204
+ * @returns {number} Parsed integer.
205
+ * @throws {UsageError} When the value is not a positive integer.
206
+ */
207
+ function parsePositiveInteger(flag, value) {
208
+ const parsed = Number(value)
209
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new UsageError(`${flag} must be a positive integer`)
210
+ return parsed
211
+ }
212
+
213
+ /**
214
+ * Resolve the repository root from an explicit path or Git.
215
+ *
216
+ * @param {string} [explicit] Explicit root.
217
+ * @returns {string} Absolute root.
218
+ */
219
+ function resolveRoot(explicit) {
220
+ if (explicit) return resolve(explicit)
221
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" })
222
+ if (result.status === 0 && result.stdout.trim()) return resolve(result.stdout.trim())
223
+ return process.cwd()
224
+ }
225
+
226
+ /**
227
+ * Read the current branch or a detached tag name.
228
+ *
229
+ * @param {string} root Repository root.
230
+ * @returns {string} Branch name.
231
+ */
232
+ function currentBranch(root) {
233
+ const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, encoding: "utf8" })
234
+ const value = branch.stdout?.trim() ?? ""
235
+ if (value && value !== "HEAD") return value
236
+ const tag = spawnSync("git", ["describe", "--tags", "--exact-match"], { cwd: root, encoding: "utf8" })
237
+ return tag.status === 0 && tag.stdout.trim() ? tag.stdout.trim() : value
238
+ }
239
+
240
+ /**
241
+ * Choose a gate from the branch when `--gate` is absent.
242
+ *
243
+ * @param {string} branch Current branch or tag.
244
+ * @returns {string} Gate name.
245
+ */
246
+ function defaultGate(branch) {
247
+ return classifyRef(branch).gate
248
+ }
249
+
250
+ /**
251
+ * List tracked files for health checks and `{tests}` expansion.
252
+ *
253
+ * @param {string} root Repository root.
254
+ * @returns {string[]} Repository-relative paths.
255
+ */
256
+ function listTrackedFiles(root) {
257
+ const result = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8" })
258
+ if (result.status !== 0) return []
259
+ return result.stdout.split("\0").filter(Boolean).map(normalizeRepoPath)
260
+ }
261
+
262
+ /**
263
+ * Build a full-suite result when the map cannot be used.
264
+ *
265
+ * @param {object} input Fallback input.
266
+ * @param {string} input.reason Stable reason.
267
+ * @param {string} [input.gate] Gate.
268
+ * @param {object|null} input.policy Map policy.
269
+ * @param {string|null} input.mapHash Map hash.
270
+ * @param {string[]} [input.changed] Changed paths.
271
+ * @returns {object} Full selection result.
272
+ */
273
+ function fallbackResult({ reason, gate, policy, mapHash, changed = [] }) {
274
+ const full = policy?.full ?? null
275
+ const commands = !Array.isArray(full)
276
+ ? []
277
+ : full.every((token) => typeof token === "string")
278
+ ? [{ id: "full", argv: [...full], cwd: "." }]
279
+ : full.map((argv, index) => ({ id: `full.${index + 1}`, argv: [...argv], cwd: "." }))
280
+ return {
281
+ schema: 1,
282
+ mode: "full",
283
+ gate,
284
+ base: null,
285
+ mapHash,
286
+ changed,
287
+ selected: [],
288
+ deferred: [],
289
+ skipped: [],
290
+ commands,
291
+ budget: { estimatedSec: 0, status: "not-applicable" },
292
+ warnings: [],
293
+ reason,
294
+ fullCommand: full,
295
+ unknownPaths: [],
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Load the map and compute a selection for the current options.
301
+ *
302
+ * @param {object} options Parsed options.
303
+ * @returns {{root: string, map: object|null, mapHash: string|null, result: object, mapError: string|null}}
304
+ */
305
+ function computeSelection(options) {
306
+ const root = resolveRoot(options.root)
307
+ let loaded
308
+ try {
309
+ loaded = loadMap({ root, mapPath: options.map })
310
+ } catch (error) {
311
+ if (!(error instanceof MapLoadError)) throw error
312
+ return {
313
+ root,
314
+ map: null,
315
+ mapHash: null,
316
+ mapError: error.code,
317
+ result: fallbackResult({ reason: error.code, gate: options.gate ?? "task", policy: null, mapHash: null }),
318
+ }
319
+ }
320
+
321
+ const map = loaded.mode === "map" ? loaded.map : null
322
+ const mapHash = loaded.mode === "map" ? loaded.mapHash : null
323
+ const branch = currentBranch(root)
324
+ const gate = options.gate ?? defaultGate(branch)
325
+ const files = listTrackedFiles(root)
326
+
327
+ if (map) {
328
+ const report = validateMap(map, { files })
329
+ if (report.errors.length > 0) {
330
+ return {
331
+ root,
332
+ map,
333
+ mapHash,
334
+ mapError: report.errors[0].code,
335
+ result: fallbackResult({ reason: report.errors[0].code, gate, policy: map.policy, mapHash }),
336
+ }
337
+ }
338
+ }
339
+
340
+ let changes
341
+ try {
342
+ changes = collectChanges({
343
+ root,
344
+ baseline: options.baseline,
345
+ lastFullSha: options.lastFullSha,
346
+ branch,
347
+ include: options.include,
348
+ env: process.env,
349
+ })
350
+ } catch (error) {
351
+ if (!(error instanceof SelectionError)) throw error
352
+ return {
353
+ root,
354
+ map,
355
+ mapHash,
356
+ mapError: error.reason,
357
+ result: fallbackResult({
358
+ reason: error.reason,
359
+ gate,
360
+ policy: map?.policy ?? null,
361
+ mapHash,
362
+ changed: options.include,
363
+ }),
364
+ }
365
+ }
366
+
367
+ const result = selectTests({
368
+ map,
369
+ mapHash,
370
+ changed: changes.changed,
371
+ files,
372
+ gate,
373
+ include: options.include,
374
+ explicitFull: options.full === true,
375
+ env: process.env,
376
+ base: changes.base,
377
+ })
378
+ return { root, map, mapHash, result, mapError: null }
379
+ }
380
+
381
+ /**
382
+ * Print a selection in the requested format.
383
+ *
384
+ * @param {object} result Selection result.
385
+ * @param {object} options Parsed options.
386
+ */
387
+ function printSelection(result, options) {
388
+ process.stdout.write(options.json ? formatSelectionJson(result) : `${formatSelection(result)}\n`)
389
+ }
390
+
391
+ /**
392
+ * Print one content-free metrics line.
393
+ *
394
+ * @param {object} result Selection result.
395
+ * @param {object} options Parsed options.
396
+ */
397
+ function printMetrics(result, options) {
398
+ if (!options.metrics) return
399
+ const total = result.selected.length + result.deferred.length + result.skipped.length
400
+ process.stdout.write(
401
+ `metrics: gate=${result.gate} selected=${result.selected.length} total=${total} budget=${result.budget.status} fallback=${result.reason ?? "none"} mapHash=${result.mapHash ?? "none"}\n`,
402
+ )
403
+ }
404
+
405
+ /**
406
+ * Execute the `select` command.
407
+ *
408
+ * @param {object} options Parsed options.
409
+ * @returns {number} Exit code.
410
+ */
411
+ function commandSelect(options) {
412
+ const { result, mapError } = computeSelection(options)
413
+ printSelection(result, options)
414
+ printMetrics(result, options)
415
+ if (options.noFallback && result.mode === "full" && result.reason !== "forced") {
416
+ return mapError ? 3 : 5
417
+ }
418
+ return 0
419
+ }
420
+
421
+ /**
422
+ * Load a precomputed selection and verify it matches the current map.
423
+ *
424
+ * @param {string} file Selection file.
425
+ * @param {string} root Repository root.
426
+ * @returns {object} Selection JSON.
427
+ * @throws {UsageError} When the file is unreadable or stale.
428
+ */
429
+ function readSelection(file, root) {
430
+ const absolute = resolve(root, file)
431
+ if (!existsSync(absolute)) throw new UsageError(`selection file not found: ${file}`)
432
+ try {
433
+ return JSON.parse(readFileSync(absolute, "utf8"))
434
+ } catch (error) {
435
+ throw new UsageError(`selection file is not valid JSON: ${error.message}`)
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Execute the `run` command.
441
+ *
442
+ * @param {object} options Parsed options.
443
+ * @returns {Promise<number>} Exit code.
444
+ */
445
+ async function commandRun(options) {
446
+ const computed = computeSelection(options)
447
+ const { root, map } = computed
448
+ let result = computed.result
449
+
450
+ if (options.selection) {
451
+ const selected = readSelection(options.selection, root)
452
+ if (selected.mapHash !== computed.mapHash) {
453
+ process.stderr.write("selection map hash does not match the current map\n")
454
+ return 2
455
+ }
456
+ result = selected
457
+ }
458
+
459
+ if (map) {
460
+ const report = validateMap(map, { files: listTrackedFiles(root) })
461
+ if (report.errors.length > 0) {
462
+ process.stderr.write(`invalid map: ${report.errors[0].code} ${report.errors[0].message}\n`)
463
+ return 3
464
+ }
465
+ }
466
+
467
+ if (result.mode === "none") {
468
+ process.stdout.write("no tests required\n")
469
+ return 0
470
+ }
471
+
472
+ let commands = result.commands ?? []
473
+ if (result.mode === "full") {
474
+ commands = options.fullCommands.length > 0
475
+ ? options.fullCommands.map((argv, index) => ({ id: `full.${index + 1}`, argv, cwd: "." }))
476
+ : commands
477
+ if (commands.length === 0) {
478
+ process.stderr.write("full selection has no configured command; pass --full-command <argv>\n")
479
+ return 5
480
+ }
481
+ }
482
+
483
+ if (options.group.length > 0) {
484
+ const selectedIds = new Set((result.selected ?? []).map((entry) => entry.id))
485
+ const unknown = options.group.filter((id) => !selectedIds.has(id))
486
+ if (unknown.length > 0) {
487
+ process.stderr.write(`group not selected: ${unknown.join(", ")}\n`)
488
+ return 2
489
+ }
490
+ commands = commands.filter((command) => options.group.includes(command.id))
491
+ }
492
+
493
+ const groupById = new Map((map?.groups ?? []).map((group) => [group.id, group]))
494
+ const executable = []
495
+ const results = []
496
+ const probes = new Map()
497
+ for (const command of commands) {
498
+ const group = groupById.get(command.id)
499
+ if (group) {
500
+ let missing = null
501
+ for (const requirement of group.requires ?? []) {
502
+ const probe = map?.policy?.requirements?.[requirement]?.check
503
+ if (!probe) {
504
+ missing = requirement
505
+ break
506
+ }
507
+ if (!probes.has(requirement)) probes.set(requirement, await probeRequirement({ argv: probe, root }))
508
+ if (!probes.get(requirement)) {
509
+ missing = requirement
510
+ break
511
+ }
512
+ }
513
+ if (missing) {
514
+ results.push({
515
+ id: command.id,
516
+ status: "failed",
517
+ exitCode: 1,
518
+ durationMs: 0,
519
+ logPath: null,
520
+ output: `requires.missing: ${missing}\n`,
521
+ tail: `requires.missing: ${missing}`,
522
+ })
523
+ if (options.failFast) break
524
+ continue
525
+ }
526
+ }
527
+ executable.push({ ...command, parallelSafe: group?.parallelSafe === true })
528
+ }
529
+
530
+ const timeoutSeconds = options.timeout ?? (result.mode === "full" ? DEFAULT_FULL_TIMEOUT_SECONDS : undefined)
531
+ const runResults = await runCommands(executable, {
532
+ root,
533
+ concurrency: options.concurrency,
534
+ failFast: options.failFast,
535
+ timeoutSeconds,
536
+ env: process.env,
537
+ gate: result.gate,
538
+ keepLogs: options.keepLogs === true,
539
+ })
540
+ results.push(...runResults)
541
+
542
+ let failed = false
543
+ for (const entry of results) {
544
+ if (entry.status === "passed") {
545
+ if (!options.quiet) process.stdout.write(`${entry.id} PASS ${entry.durationMs}ms\n`)
546
+ continue
547
+ }
548
+ failed = true
549
+ process.stdout.write(`${entry.id} FAIL ${entry.durationMs}ms\n`)
550
+ if (entry.tail) process.stdout.write(`${entry.tail}\n`)
551
+ if (entry.logPath) process.stdout.write(`log: ${entry.logPath}\n`)
552
+ }
553
+
554
+ if (result.mode === "full" && !options.quiet) {
555
+ process.stdout.write(`mode: full (${result.reason ?? "forced"})\n`)
556
+ }
557
+ process.stdout.write(failed ? "FAIL\n" : "PASS\n")
558
+ printMetrics(result, options)
559
+
560
+ if (options.record) {
561
+ const recordPath = resolve(root, options.record)
562
+ writeFileSync(
563
+ recordPath,
564
+ `${JSON.stringify({ schema: 1, selection: result, results, gate: result.gate, result: failed ? "failed" : "passed" }, null, 2)}\n`,
565
+ )
566
+ }
567
+ return failed ? 1 : 0
568
+ }
569
+
570
+ /**
571
+ * Execute the `check` command.
572
+ *
573
+ * @param {object} options Parsed options.
574
+ * @returns {number} Exit code.
575
+ */
576
+ function commandCheck(options) {
577
+ const root = resolveRoot(options.root)
578
+ let loaded
579
+ try {
580
+ loaded = loadMap({ root, mapPath: options.map })
581
+ } catch (error) {
582
+ if (!(error instanceof MapLoadError)) throw error
583
+ process.stdout.write(`${error.code}: ${error.message}\n`)
584
+ return 3
585
+ }
586
+ if (loaded.mode === "no-map") {
587
+ process.stdout.write("no test map\n")
588
+ return 0
589
+ }
590
+
591
+ const report = validateMap(loaded.map, { files: listTrackedFiles(root) })
592
+ if (options.json) {
593
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
594
+ } else {
595
+ for (const entry of report.errors) process.stdout.write(`${entry.code}: ${entry.message}\n`)
596
+ for (const entry of report.warnings) process.stdout.write(`${entry.code}: ${entry.message}\n`)
597
+ if (report.errors.length === 0 && report.warnings.length === 0) process.stdout.write("map ok\n")
598
+ }
599
+ return exitCodeFor(report, { strict: options.strict === true })
600
+ }
601
+
602
+ /**
603
+ * Execute the `plan` command.
604
+ *
605
+ * @param {object} options Parsed options.
606
+ * @returns {number} Exit code.
607
+ */
608
+ function commandPlan(options) {
609
+ const computed = computeSelection(options)
610
+ let result = computed.result
611
+ const marker = options.lastFullSha ?? process.env.CODEOPS_LAST_FULL_SHA ?? null
612
+
613
+ if (result.gate === "nightly") {
614
+ const decision = nightlyDecision({ changed: result.changed, map: computed.map, marker })
615
+ if (decision.mode === "skip") {
616
+ result = {
617
+ ...result,
618
+ mode: "skip",
619
+ selected: [],
620
+ deferred: [],
621
+ commands: [],
622
+ reason: "no-relevant-change",
623
+ budget: { estimatedSec: 0, status: "not-applicable" },
624
+ }
625
+ } else if (decision.mode === "full" && result.mode !== "full") {
626
+ result = fallbackResult({
627
+ reason: "nightly.no-marker",
628
+ gate: result.gate,
629
+ policy: computed.map?.policy ?? null,
630
+ mapHash: computed.mapHash,
631
+ changed: result.changed,
632
+ })
633
+ }
634
+ }
635
+
636
+ const plan = buildPlan({ selection: result, gate: result.gate, total: computed.map?.groups.length ?? 0 })
637
+ process.stdout.write(options.json ? `${JSON.stringify(plan, null, 2)}\n` : `${formatSelection(result)}\n`)
638
+ if (options.noFallback && result.mode === "full" && result.reason !== "forced") return 5
639
+ return 0
640
+ }
641
+
642
+ /**
643
+ * Execute the `shadow` command.
644
+ *
645
+ * @param {object} options Parsed options.
646
+ * @returns {Promise<number>} Exit code.
647
+ */
648
+ async function commandShadow(options) {
649
+ const { root, map, result } = computeSelection(options)
650
+ const selectedResults = await runCommands(result.commands ?? [], {
651
+ root,
652
+ concurrency: options.concurrency,
653
+ failFast: false,
654
+ env: process.env,
655
+ gate: result.gate,
656
+ keepLogs: options.keepLogs === true,
657
+ })
658
+ const selectedPassed = selectedResults.every((entry) => entry.status === "passed")
659
+
660
+ let fullCommands = options.fullCommands
661
+ if (fullCommands.length === 0 && Array.isArray(map?.policy?.full)) {
662
+ fullCommands = map.policy.full.every((token) => typeof token === "string")
663
+ ? [map.policy.full]
664
+ : map.policy.full
665
+ }
666
+ if (fullCommands.length === 0) {
667
+ process.stderr.write("shadow needs a configured full command or --full-command <argv>\n")
668
+ return 5
669
+ }
670
+
671
+ const fullResults = []
672
+ for (const [index, argv] of fullCommands.entries()) {
673
+ fullResults.push(
674
+ await runArgv({
675
+ id: `full.${index + 1}`,
676
+ argv,
677
+ root,
678
+ env: process.env,
679
+ gate: result.gate,
680
+ keepLogs: options.keepLogs === true,
681
+ serial: index,
682
+ }),
683
+ )
684
+ }
685
+ const fullPassed = fullResults.every((entry) => entry.status === "passed")
686
+ const verdict = selectedPassed && fullPassed
687
+ ? "zero-missed"
688
+ : !selectedPassed && fullPassed
689
+ ? "selected-failure"
690
+ : selectedPassed && !fullPassed
691
+ ? "escaped"
692
+ : "both-failed"
693
+
694
+ const record = {
695
+ schema: 1,
696
+ verdict,
697
+ gate: result.gate,
698
+ changed: result.changed,
699
+ mapHash: result.mapHash,
700
+ selected: (result.selected ?? []).map((entry) => entry.id),
701
+ selectedResult: selectedPassed ? "passed" : "failed",
702
+ fullResult: fullPassed ? "passed" : "failed",
703
+ escaped: selectedPassed && !fullPassed ? ["full"] : [],
704
+ enabled: false,
705
+ failures: [...selectedResults, ...fullResults]
706
+ .filter((entry) => entry.status !== "passed")
707
+ .map((entry) => ({ id: entry.id, status: entry.status, tail: entry.tail, logPath: entry.logPath })),
708
+ }
709
+
710
+ if (options.record) writeFileSync(resolve(root, options.record), `${JSON.stringify(record, null, 2)}\n`)
711
+ if (options.json) process.stdout.write(`${JSON.stringify(record, null, 2)}\n`)
712
+ else {
713
+ process.stdout.write(`verdict: ${verdict}\nselected: ${record.selectedResult}\nfull: ${record.fullResult}\n`)
714
+ for (const failure of record.failures) {
715
+ if (failure.tail) process.stdout.write(`${failure.id}: ${failure.tail}\n`)
716
+ if (failure.logPath) process.stdout.write(`log: ${failure.logPath}\n`)
717
+ }
718
+ }
719
+ return verdict === "zero-missed" ? 0 : 1
720
+ }
721
+
722
+ /**
723
+ * Execute the `provenance` command.
724
+ *
725
+ * @param {object} options Parsed options.
726
+ * @returns {number} Exit code.
727
+ */
728
+ function commandProvenance(options) {
729
+ const root = resolveRoot(options.root)
730
+ if (!options.record) {
731
+ process.stderr.write("provenance needs --record <file>\n")
732
+ return 2
733
+ }
734
+ const recordPath = resolve(root, options.record)
735
+
736
+ if (options.action === "write") {
737
+ const commit = options.sourceCommit ?? spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout.trim()
738
+ let mapHash = null
739
+ let groups = []
740
+ try {
741
+ const loaded = loadMap({ root, mapPath: options.map })
742
+ if (loaded.mode === "map") {
743
+ mapHash = loaded.mapHash
744
+ groups = loaded.map.groups.map((group) => group.id)
745
+ }
746
+ } catch (error) {
747
+ if (!(error instanceof MapLoadError)) throw error
748
+ process.stderr.write(`${error.code}: ${error.message}\n`)
749
+ return 3
750
+ }
751
+
752
+ const readJsonOption = (value) => {
753
+ if (!value) return null
754
+ try {
755
+ return JSON.parse(readFileSync(resolve(root, value), "utf8"))
756
+ } catch (error) {
757
+ throw new UsageError(`cannot read ${value}: ${error.message}`)
758
+ }
759
+ }
760
+
761
+ const record = createProvenanceRecord({
762
+ commit,
763
+ gate: options.gate ?? "release",
764
+ mapHash,
765
+ groups,
766
+ fullRunId: options.fullRunId ?? process.env.GITHUB_RUN_ID ?? null,
767
+ artifactPath: options.artifact ? resolve(root, options.artifact) : null,
768
+ sourceCommit: options.sourceCommit ?? commit,
769
+ reproducible: true,
770
+ coverage: readJsonOption(options.coverage),
771
+ acceptance: readJsonOption(options.acceptance),
772
+ })
773
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`)
774
+ process.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : `provenance written: ${recordPath}\n`)
775
+ return 0
776
+ }
777
+
778
+ let record
779
+ try {
780
+ record = JSON.parse(readFileSync(recordPath, "utf8"))
781
+ } catch (error) {
782
+ process.stderr.write(`cannot read provenance record: ${error.message}\n`)
783
+ return 2
784
+ }
785
+
786
+ let expectedMapHash = null
787
+ try {
788
+ const loaded = loadMap({ root, mapPath: options.map })
789
+ if (loaded.mode === "map" && record.mapHash !== null) expectedMapHash = loaded.mapHash
790
+ } catch (error) {
791
+ if (!(error instanceof MapLoadError)) throw error
792
+ }
793
+
794
+ const result = verifyProvenance(record, {
795
+ commit: options.commit,
796
+ mapHash: expectedMapHash,
797
+ artifactPath: options.artifact ? resolve(root, options.artifact) : undefined,
798
+ })
799
+ if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
800
+ else if (result.ok) process.stdout.write("provenance verified\n")
801
+ else {
802
+ for (const error of result.errors) process.stdout.write(`${error.field}: ${error.message}\n`)
803
+ }
804
+ return result.ok ? 0 : 1
805
+ }
806
+
807
+ /**
808
+ * Run the CLI.
809
+ *
810
+ * @param {string[]} argv Raw arguments.
811
+ * @returns {Promise<number>} Exit code.
812
+ */
813
+ async function main(argv) {
814
+ const options = parseArgs(argv)
815
+ if (options.command === "help") {
816
+ process.stdout.write(USAGE)
817
+ return 0
818
+ }
819
+ switch (options.command) {
820
+ case "select":
821
+ return commandSelect(options)
822
+ case "run":
823
+ return commandRun(options)
824
+ case "check":
825
+ return commandCheck(options)
826
+ case "plan":
827
+ return commandPlan(options)
828
+ case "shadow":
829
+ return commandShadow(options)
830
+ case "provenance":
831
+ return commandProvenance(options)
832
+ default:
833
+ throw new UsageError(`unknown command: ${options.command}`)
834
+ }
835
+ }
836
+
837
+ try {
838
+ process.exitCode = await main(process.argv.slice(2))
839
+ } catch (error) {
840
+ if (error instanceof UsageError) {
841
+ process.stderr.write(`${error.message}\n\n${USAGE}`)
842
+ process.exitCode = 2
843
+ } else if (error instanceof MapLoadError) {
844
+ process.stderr.write(`${error.code}: ${error.message}\n`)
845
+ process.exitCode = 3
846
+ } else if (error instanceof SelectionError) {
847
+ process.stderr.write(`${error.reason}: ${error.message}\n`)
848
+ process.exitCode = 5
849
+ } else {
850
+ process.stderr.write(`${error.message}\n`)
851
+ process.exitCode = 1
852
+ }
853
+ }
854
+
855
+ export { main, parseArgs }