opencode-codeops 2.4.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.
- package/CHANGELOG.md +19 -0
- package/README.md +49 -2
- package/_shared/discovery.md +2 -1
- package/_shared/spec-first-ordering.md +6 -2
- package/_shared/test-selection.md +130 -0
- package/bin/codeops-test.mjs +855 -0
- package/bin/lib/test-branch-policy.d.mts +51 -0
- package/bin/lib/test-branch-policy.mjs +185 -0
- package/bin/lib/test-map-validate.d.mts +42 -0
- package/bin/lib/test-map-validate.mjs +618 -0
- package/bin/lib/test-map.d.mts +114 -0
- package/bin/lib/test-map.mjs +384 -0
- package/bin/lib/test-provenance.d.mts +73 -0
- package/bin/lib/test-provenance.mjs +188 -0
- package/bin/lib/test-runner.d.mts +72 -0
- package/bin/lib/test-runner.mjs +350 -0
- package/bin/lib/test-selection-changes.d.mts +76 -0
- package/bin/lib/test-selection-changes.mjs +272 -0
- package/bin/lib/test-selection-explain.mjs +75 -0
- package/bin/lib/test-selection-glob.d.mts +32 -0
- package/bin/lib/test-selection-glob.mjs +206 -0
- package/bin/lib/test-selection.d.mts +80 -0
- package/bin/lib/test-selection.mjs +418 -0
- package/package.json +4 -2
- package/schemas/test-map.schema.json +164 -0
- package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
- package/scripts/discovery-toggle.mjs +236 -0
- package/skills/analyze-project/SKILL.md +9 -2
- package/skills/exec-plan/SKILL.md +16 -8
- package/skills/exec-plan/execution-protocol.md +15 -2
- package/skills/git-commit/SKILL.md +1 -1
- package/skills/make-plan/SKILL.md +10 -3
- package/skills/make-plan/quality-checklist.md +4 -0
- package/skills/make-plan/templates.md +19 -1
- package/skills/organize-tests/SKILL.md +169 -0
- package/skills/organize-tests/references/agents-testing-block.md +80 -0
- package/skills/organize-tests/references/analysis-protocol.md +94 -0
- package/skills/organize-tests/references/ci-github-actions.md +178 -0
- package/skills/organize-tests/references/language-adapters.md +54 -0
- package/skills/organize-tests/references/map-health.md +47 -0
- package/skills/organize-tests/references/project-wrapper.md +72 -0
- package/skills/organize-tests/references/shadow-mode.md +60 -0
- package/skills/setup-codeops/SKILL.md +30 -3
- package/standards/coding-standards-full.md +28 -0
- package/standards/coding-standards.md +29 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- 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 }
|