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,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Release provenance records and artifact verification.
|
|
4
|
+
*
|
|
5
|
+
* A provenance record is the evidence that lets production promote one artifact
|
|
6
|
+
* without repeating the full suite. It is deliberately strict: a missing full
|
|
7
|
+
* flag, a different commit, a different map, or a rebuilt artifact must fail the
|
|
8
|
+
* verifier rather than pass on a plausible-looking file.
|
|
9
|
+
*
|
|
10
|
+
* The record contains no test output, environment values, or secrets.
|
|
11
|
+
*
|
|
12
|
+
* @module lib/test-provenance
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto"
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
17
|
+
|
|
18
|
+
/** Gates whose evidence covers a release artifact. */
|
|
19
|
+
const FULL_GATES = Object.freeze(["release", "acceptance"])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute the SHA-256 digest of a file.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} path File path.
|
|
25
|
+
* @returns {string} Digest in the form `sha256:<hex>`.
|
|
26
|
+
* @throws {Error} When the file cannot be read.
|
|
27
|
+
* @example
|
|
28
|
+
* sha256File("dist/app.tar.gz") // "sha256:..."
|
|
29
|
+
*/
|
|
30
|
+
export function sha256File(path) {
|
|
31
|
+
return `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check whether a value is a Git object hash.
|
|
36
|
+
*
|
|
37
|
+
* @param {unknown} value Candidate value.
|
|
38
|
+
* @returns {boolean} `true` for a 40- or 64-character hexadecimal string.
|
|
39
|
+
*/
|
|
40
|
+
function isCommit(value) {
|
|
41
|
+
return typeof value === "string" && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build one provenance record.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} input Record input.
|
|
48
|
+
* @param {string} input.commit Tested commit.
|
|
49
|
+
* @param {string} [input.ref] Tested ref.
|
|
50
|
+
* @param {string} input.gate Gate name.
|
|
51
|
+
* @param {string|null} input.mapHash Map hash, or null for a no-map project.
|
|
52
|
+
* @param {string[]} [input.groups=[]] Groups covered by the full gate.
|
|
53
|
+
* @param {string|null} [input.fullRunId=null] CI full-run identifier.
|
|
54
|
+
* @param {string|null} [input.artifactPath=null] Artifact path.
|
|
55
|
+
* @param {string|null} [input.artifactDigest=null] Artifact digest override.
|
|
56
|
+
* @param {string|null} [input.sourceCommit=null] Commit the gate tested.
|
|
57
|
+
* @param {"source"|"artifact"} [input.artifactTested="source"] What the gate tested.
|
|
58
|
+
* @param {boolean} [input.reproducible=false] Whether a rebuild reproduced the digest.
|
|
59
|
+
* @param {object|null} [input.coverage=null] Coverage evidence.
|
|
60
|
+
* @param {object|null} [input.acceptance=null] Acceptance evidence.
|
|
61
|
+
* @param {string} [input.result="passed"] Gate result.
|
|
62
|
+
* @param {string} [input.startedAt] ISO start time.
|
|
63
|
+
* @param {string} [input.finishedAt] ISO finish time.
|
|
64
|
+
* @returns {object} Provenance record.
|
|
65
|
+
* @throws {Error} When the commit is not a Git hash or the gate is unknown.
|
|
66
|
+
* @example
|
|
67
|
+
* const record = createProvenanceRecord({ commit, gate: "release", mapHash, artifactPath: "dist/app.tgz" })
|
|
68
|
+
*/
|
|
69
|
+
export function createProvenanceRecord({
|
|
70
|
+
commit,
|
|
71
|
+
ref = null,
|
|
72
|
+
gate,
|
|
73
|
+
mapHash,
|
|
74
|
+
groups = [],
|
|
75
|
+
fullRunId = null,
|
|
76
|
+
artifactPath = null,
|
|
77
|
+
artifactDigest = null,
|
|
78
|
+
sourceCommit = null,
|
|
79
|
+
artifactTested = "source",
|
|
80
|
+
reproducible = false,
|
|
81
|
+
coverage = null,
|
|
82
|
+
acceptance = null,
|
|
83
|
+
result = "passed",
|
|
84
|
+
startedAt = new Date().toISOString(),
|
|
85
|
+
finishedAt = new Date().toISOString(),
|
|
86
|
+
}) {
|
|
87
|
+
if (!isCommit(commit)) throw new Error("provenance commit must be a 40- or 64-character Git hash")
|
|
88
|
+
if (!FULL_GATES.includes(gate)) throw new Error(`provenance gate must be release or acceptance, got ${String(gate)}`)
|
|
89
|
+
|
|
90
|
+
let artifact = null
|
|
91
|
+
if (artifactPath || artifactDigest) {
|
|
92
|
+
if (!artifactPath && !artifactDigest) throw new Error("artifact verification needs a path or digest")
|
|
93
|
+
if (artifactPath && !existsSync(artifactPath) && !artifactDigest) {
|
|
94
|
+
throw new Error(`artifact not found: ${artifactPath}`)
|
|
95
|
+
}
|
|
96
|
+
artifact = {
|
|
97
|
+
path: artifactPath,
|
|
98
|
+
digest: artifactDigest ?? sha256File(artifactPath),
|
|
99
|
+
sourceCommit: sourceCommit ?? commit,
|
|
100
|
+
tested: artifactTested === "artifact" ? "artifact" : "source",
|
|
101
|
+
reproducible: reproducible === true,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
schema: 1,
|
|
107
|
+
commit,
|
|
108
|
+
ref,
|
|
109
|
+
gate,
|
|
110
|
+
mapHash: mapHash ?? null,
|
|
111
|
+
selection: mapHash ? "map" : "no-map",
|
|
112
|
+
full: true,
|
|
113
|
+
groups: [...groups].sort(),
|
|
114
|
+
fullRunId,
|
|
115
|
+
artifact,
|
|
116
|
+
coverage,
|
|
117
|
+
coverageStatus: coverage ? "provided" : "not-provided",
|
|
118
|
+
acceptance,
|
|
119
|
+
acceptanceStatus: acceptance ? "provided" : "not-provided",
|
|
120
|
+
startedAt,
|
|
121
|
+
finishedAt,
|
|
122
|
+
result,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Compare a record against expected release facts.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} record Provenance record.
|
|
130
|
+
* @param {object} [expected] Expected facts.
|
|
131
|
+
* @param {string} [expected.commit] Expected tested commit.
|
|
132
|
+
* @param {string} [expected.mapHash] Expected map hash.
|
|
133
|
+
* @param {string} [expected.artifactPath] Artifact to re-hash.
|
|
134
|
+
* @param {string} [expected.artifactDigest] Expected artifact digest.
|
|
135
|
+
* @returns {{ok: boolean, errors: Array<{field: string, message: string}>}} Verification result.
|
|
136
|
+
* @example
|
|
137
|
+
* const result = verifyProvenance(record, { artifactPath: "dist/app.tar.gz" })
|
|
138
|
+
* if (!result.ok) process.exitCode = 1
|
|
139
|
+
*/
|
|
140
|
+
export function verifyProvenance(record, expected = {}) {
|
|
141
|
+
const errors = []
|
|
142
|
+
const fail = (field, message) => errors.push({ field, message })
|
|
143
|
+
|
|
144
|
+
if (!record || typeof record !== "object" || record.schema !== 1) {
|
|
145
|
+
fail("schema", "record must use schema 1")
|
|
146
|
+
return { ok: false, errors }
|
|
147
|
+
}
|
|
148
|
+
if (!isCommit(record.commit)) fail("commit", "record commit is not a Git hash")
|
|
149
|
+
if (expected.commit && record.commit !== expected.commit) {
|
|
150
|
+
fail("commit", `expected ${expected.commit}, got ${record.commit}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (record.mapHash === null) {
|
|
154
|
+
if (record.selection !== "no-map") fail("selection", "a null map hash requires selection no-map")
|
|
155
|
+
} else {
|
|
156
|
+
if (record.selection !== "map") fail("selection", "a map hash requires selection map")
|
|
157
|
+
if (expected.mapHash && record.mapHash !== expected.mapHash) {
|
|
158
|
+
fail("mapHash", `expected ${expected.mapHash}, got ${record.mapHash}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (FULL_GATES.includes(record.gate) && record.full !== true) {
|
|
163
|
+
fail("full", "release and acceptance records must have full: true")
|
|
164
|
+
}
|
|
165
|
+
if (record.result !== "passed") fail("result", `gate result is ${String(record.result)}, not passed`)
|
|
166
|
+
|
|
167
|
+
if (record.artifact) {
|
|
168
|
+
if (!record.artifact.digest) fail("artifact.digest", "artifact evidence needs a digest")
|
|
169
|
+
if (record.artifact.sourceCommit !== record.commit) {
|
|
170
|
+
fail("artifact.sourceCommit", "artifact source commit must match the tested commit")
|
|
171
|
+
}
|
|
172
|
+
if (record.artifact.tested !== "artifact" && record.artifact.reproducible !== true) {
|
|
173
|
+
fail("artifact.reproducible", "a source-tested artifact needs a reproducible rebuild")
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const expectedDigest =
|
|
178
|
+
expected.artifactDigest ??
|
|
179
|
+
(expected.artifactPath && existsSync(expected.artifactPath) ? sha256File(expected.artifactPath) : null)
|
|
180
|
+
if (expectedDigest) {
|
|
181
|
+
if (!record.artifact?.digest) fail("artifact.digest", "record has no artifact digest")
|
|
182
|
+
else if (record.artifact.digest !== expectedDigest) {
|
|
183
|
+
fail("artifact.digest", `expected ${expectedDigest}, got ${record.artifact.digest}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { ok: errors.length === 0, errors }
|
|
188
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the argv-safe test runner.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One command result. */
|
|
6
|
+
export interface RunResult {
|
|
7
|
+
id: string
|
|
8
|
+
status: "passed" | "failed" | "timeout"
|
|
9
|
+
exitCode: number
|
|
10
|
+
durationMs: number
|
|
11
|
+
logPath: string | null
|
|
12
|
+
output: string
|
|
13
|
+
tail: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Error raised when a command cannot run safely. */
|
|
17
|
+
export declare class RunnerError extends Error {
|
|
18
|
+
code: string
|
|
19
|
+
constructor(code: string, message: string)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default per-group timeout. */
|
|
23
|
+
export declare const DEFAULT_GROUP_TIMEOUT_SECONDS: number
|
|
24
|
+
|
|
25
|
+
/** Default full-suite timeout. */
|
|
26
|
+
export declare const DEFAULT_FULL_TIMEOUT_SECONDS: number
|
|
27
|
+
|
|
28
|
+
/** Resolve the captured-log directory. */
|
|
29
|
+
export declare function resolveLogDir(env?: Record<string, string>): string
|
|
30
|
+
|
|
31
|
+
/** Resolve and contain a command working directory. */
|
|
32
|
+
export declare function resolveWorkingDirectory(root: string, cwd?: string): string
|
|
33
|
+
|
|
34
|
+
/** Keep the last N lines of a log. */
|
|
35
|
+
export declare function tailLines(text: string, count?: number): string
|
|
36
|
+
|
|
37
|
+
/** Run one argv command. */
|
|
38
|
+
export declare function runArgv(input: {
|
|
39
|
+
id: string
|
|
40
|
+
argv: string[]
|
|
41
|
+
cwd?: string
|
|
42
|
+
root: string
|
|
43
|
+
timeoutSeconds?: number
|
|
44
|
+
env?: Record<string, string>
|
|
45
|
+
gate?: string
|
|
46
|
+
keepLogs?: boolean
|
|
47
|
+
serial?: number
|
|
48
|
+
}): Promise<RunResult>
|
|
49
|
+
|
|
50
|
+
/** Read a retained log. */
|
|
51
|
+
export declare function readRetainedLog(logPath: string | null): string
|
|
52
|
+
|
|
53
|
+
/** Run commands sequentially or in a bounded pool. */
|
|
54
|
+
export declare function runCommands(
|
|
55
|
+
commands: Array<{ id: string; argv: string[]; cwd?: string; parallelSafe?: boolean }>,
|
|
56
|
+
options: {
|
|
57
|
+
root: string
|
|
58
|
+
concurrency?: number
|
|
59
|
+
failFast?: boolean
|
|
60
|
+
timeoutSeconds?: number
|
|
61
|
+
env?: Record<string, string>
|
|
62
|
+
gate?: string
|
|
63
|
+
keepLogs?: boolean
|
|
64
|
+
}
|
|
65
|
+
): Promise<RunResult[]>
|
|
66
|
+
|
|
67
|
+
/** Run one requirement probe. */
|
|
68
|
+
export declare function probeRequirement(input: {
|
|
69
|
+
argv: string[]
|
|
70
|
+
root: string
|
|
71
|
+
env?: Record<string, string>
|
|
72
|
+
}): Promise<boolean>
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Argv-safe command execution for the test selector.
|
|
4
|
+
*
|
|
5
|
+
* The runner never builds a shell string. It spawns the program named by the
|
|
6
|
+
* first argv token with the remaining tokens as arguments, so a path containing
|
|
7
|
+
* spaces, semicolons, or `$()` stays one inert argument.
|
|
8
|
+
*
|
|
9
|
+
* Logs are temporary artifacts. A passing command deletes its log; a failing or
|
|
10
|
+
* timed-out command keeps the log, reports its path, and exposes the last lines
|
|
11
|
+
* so a developer can act without re-running the suite.
|
|
12
|
+
*
|
|
13
|
+
* @module lib/test-runner
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process"
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"
|
|
18
|
+
import { tmpdir } from "node:os"
|
|
19
|
+
import { isAbsolute, join, relative, resolve } from "node:path"
|
|
20
|
+
|
|
21
|
+
/** Default per-group timeout in seconds. */
|
|
22
|
+
export const DEFAULT_GROUP_TIMEOUT_SECONDS = 600
|
|
23
|
+
|
|
24
|
+
/** Default full-suite timeout in seconds. */
|
|
25
|
+
export const DEFAULT_FULL_TIMEOUT_SECONDS = 1800
|
|
26
|
+
|
|
27
|
+
/** Maximum captured output retained in memory per stream. */
|
|
28
|
+
const MAX_CAPTURE_BYTES = 2 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
/** Number of trailing log lines reported on failure. */
|
|
31
|
+
const FAILURE_TAIL_LINES = 50
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Error raised when a command cannot be run safely.
|
|
35
|
+
*
|
|
36
|
+
* `code` is stable (`cwd-escape`, `missing-command`) so the CLI can choose the
|
|
37
|
+
* documented exit code.
|
|
38
|
+
*/
|
|
39
|
+
export class RunnerError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} code Stable error code.
|
|
42
|
+
* @param {string} message Human-readable explanation.
|
|
43
|
+
*/
|
|
44
|
+
constructor(code, message) {
|
|
45
|
+
super(message)
|
|
46
|
+
this.name = "RunnerError"
|
|
47
|
+
this.code = code
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the directory that holds captured logs.
|
|
53
|
+
*
|
|
54
|
+
* @param {Record<string, string>} [env] Environment values.
|
|
55
|
+
* @returns {string} Existing temporary directory.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveLogDir(env = process.env) {
|
|
58
|
+
const directory = env.CODEOPS_TMPDIR || tmpdir()
|
|
59
|
+
mkdirSync(directory, { recursive: true })
|
|
60
|
+
return directory
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve and contain a command working directory.
|
|
65
|
+
*
|
|
66
|
+
* Both the lexical path and the real path must stay under the repository root,
|
|
67
|
+
* which also blocks a symlinked subdirectory from escaping.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} root Repository root.
|
|
70
|
+
* @param {string} [cwd="."] Requested working directory.
|
|
71
|
+
* @returns {string} Absolute, contained working directory.
|
|
72
|
+
* @throws {RunnerError} When the directory escapes the root or does not exist.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveWorkingDirectory(root, cwd = ".") {
|
|
75
|
+
const absoluteRoot = realpathSync(resolve(root))
|
|
76
|
+
const candidate = isAbsolute(cwd) ? resolve(cwd) : resolve(absoluteRoot, cwd)
|
|
77
|
+
const relation = relative(absoluteRoot, candidate)
|
|
78
|
+
if (relation.startsWith("..") || isAbsolute(relation)) {
|
|
79
|
+
throw new RunnerError("cwd-escape", `cwd ${cwd} escapes the repository root`)
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(candidate)) throw new RunnerError("missing-cwd", `cwd ${cwd} does not exist`)
|
|
82
|
+
const real = realpathSync(candidate)
|
|
83
|
+
const realRelation = relative(absoluteRoot, real)
|
|
84
|
+
if (realRelation.startsWith("..") || isAbsolute(realRelation)) {
|
|
85
|
+
throw new RunnerError("cwd-escape", `cwd ${cwd} resolves outside the repository root`)
|
|
86
|
+
}
|
|
87
|
+
return real
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Keep only the last N lines of a log.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} text Log text.
|
|
94
|
+
* @param {number} [count=FAILURE_TAIL_LINES] Number of lines to keep.
|
|
95
|
+
* @returns {string} Trailing lines.
|
|
96
|
+
*/
|
|
97
|
+
export function tailLines(text, count = FAILURE_TAIL_LINES) {
|
|
98
|
+
return String(text ?? "")
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter((line, index, lines) => !(index === lines.length - 1 && line === ""))
|
|
101
|
+
.slice(-count)
|
|
102
|
+
.join("\n")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a safe log file name from a group ID.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} id Group or command ID.
|
|
109
|
+
* @param {number} serial Monotonic serial for uniqueness.
|
|
110
|
+
* @returns {string} File name with no path separators.
|
|
111
|
+
*/
|
|
112
|
+
function logFileName(id, serial) {
|
|
113
|
+
const safe = String(id).replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 80)
|
|
114
|
+
return `codeops-test-${safe}-${process.pid}-${serial}.log`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run one argv command asynchronously.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} input Execution input.
|
|
121
|
+
* @param {string} input.id Group or command ID.
|
|
122
|
+
* @param {string[]} input.argv Program and arguments.
|
|
123
|
+
* @param {string} [input.cwd="."] Working directory inside the root.
|
|
124
|
+
* @param {string} input.root Repository root.
|
|
125
|
+
* @param {number} [input.timeoutSeconds] Timeout override.
|
|
126
|
+
* @param {Record<string, string>} [input.env] Extra environment values.
|
|
127
|
+
* @param {string} [input.gate] Gate name for diagnostics.
|
|
128
|
+
* @param {boolean} [input.keepLogs=false] Keep success logs.
|
|
129
|
+
* @param {number} [input.serial=0] Serial used in the log name.
|
|
130
|
+
* @returns {Promise<{id: string, status: "passed"|"failed"|"timeout", exitCode: number, durationMs: number, logPath: string|null, output: string, tail: string}>}
|
|
131
|
+
* @throws {RunnerError} When the command cannot start safely.
|
|
132
|
+
* @example
|
|
133
|
+
* const result = await runArgv({ id: "a.unit", argv: ["node", "--test", "a.test.js"], root: process.cwd() })
|
|
134
|
+
*/
|
|
135
|
+
export async function runArgv({
|
|
136
|
+
id,
|
|
137
|
+
argv,
|
|
138
|
+
cwd = ".",
|
|
139
|
+
root,
|
|
140
|
+
timeoutSeconds = DEFAULT_GROUP_TIMEOUT_SECONDS,
|
|
141
|
+
env = {},
|
|
142
|
+
gate,
|
|
143
|
+
keepLogs = false,
|
|
144
|
+
serial = 0,
|
|
145
|
+
}) {
|
|
146
|
+
if (!Array.isArray(argv) || argv.length === 0 || typeof argv[0] !== "string") {
|
|
147
|
+
throw new RunnerError("missing-command", `group ${id} has no runnable command`)
|
|
148
|
+
}
|
|
149
|
+
const workingDirectory = resolveWorkingDirectory(root, cwd)
|
|
150
|
+
const logPath = join(resolveLogDir(env), logFileName(id, serial))
|
|
151
|
+
const started = Date.now()
|
|
152
|
+
|
|
153
|
+
return new Promise((resolvePromise) => {
|
|
154
|
+
let stdout = ""
|
|
155
|
+
let stderr = ""
|
|
156
|
+
let timedOut = false
|
|
157
|
+
|
|
158
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
159
|
+
cwd: workingDirectory,
|
|
160
|
+
shell: false,
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
env: {
|
|
163
|
+
...process.env,
|
|
164
|
+
...env,
|
|
165
|
+
CODEOPS_TEST_GROUP: id,
|
|
166
|
+
...(gate ? { CODEOPS_TEST_GATE: gate } : {}),
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const append = (current, chunk) => {
|
|
171
|
+
const next = current + chunk.toString("utf8")
|
|
172
|
+
return next.length > MAX_CAPTURE_BYTES ? next.slice(-MAX_CAPTURE_BYTES) : next
|
|
173
|
+
}
|
|
174
|
+
child.stdout.on("data", (chunk) => {
|
|
175
|
+
stdout = append(stdout, chunk)
|
|
176
|
+
})
|
|
177
|
+
child.stderr.on("data", (chunk) => {
|
|
178
|
+
stderr = append(stderr, chunk)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const timer =
|
|
182
|
+
timeoutSeconds > 0
|
|
183
|
+
? setTimeout(() => {
|
|
184
|
+
timedOut = true
|
|
185
|
+
child.kill("SIGKILL")
|
|
186
|
+
}, timeoutSeconds * 1000)
|
|
187
|
+
: null
|
|
188
|
+
|
|
189
|
+
child.on("error", (error) => {
|
|
190
|
+
if (timer) clearTimeout(timer)
|
|
191
|
+
const output = `cannot start ${argv[0]}: ${error.message}\n`
|
|
192
|
+
writeFileSync(logPath, output)
|
|
193
|
+
resolvePromise({
|
|
194
|
+
id,
|
|
195
|
+
status: "failed",
|
|
196
|
+
exitCode: 127,
|
|
197
|
+
durationMs: Date.now() - started,
|
|
198
|
+
logPath,
|
|
199
|
+
output,
|
|
200
|
+
tail: output.trim(),
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
child.on("close", (code) => {
|
|
205
|
+
if (timer) clearTimeout(timer)
|
|
206
|
+
const output = `${stdout}${stderr}`
|
|
207
|
+
const status = timedOut ? "timeout" : code === 0 ? "passed" : "failed"
|
|
208
|
+
writeFileSync(logPath, output)
|
|
209
|
+
|
|
210
|
+
if (status === "passed" && !keepLogs) {
|
|
211
|
+
unlinkSync(logPath)
|
|
212
|
+
resolvePromise({
|
|
213
|
+
id,
|
|
214
|
+
status,
|
|
215
|
+
exitCode: 0,
|
|
216
|
+
durationMs: Date.now() - started,
|
|
217
|
+
logPath: null,
|
|
218
|
+
output,
|
|
219
|
+
tail: "",
|
|
220
|
+
})
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const header = timedOut ? `timeout after ${timeoutSeconds}s\n` : ""
|
|
225
|
+
resolvePromise({
|
|
226
|
+
id,
|
|
227
|
+
status,
|
|
228
|
+
exitCode: timedOut ? 124 : code ?? 1,
|
|
229
|
+
durationMs: Date.now() - started,
|
|
230
|
+
logPath,
|
|
231
|
+
output: `${header}${output}`,
|
|
232
|
+
tail: tailLines(`${header}${output}`),
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read a retained log when a caller needs more than the tail.
|
|
240
|
+
*
|
|
241
|
+
* @param {string|null} logPath Retained log path.
|
|
242
|
+
* @returns {string} Log text, or an empty string when unavailable.
|
|
243
|
+
*/
|
|
244
|
+
export function readRetainedLog(logPath) {
|
|
245
|
+
if (!logPath || !existsSync(logPath)) return ""
|
|
246
|
+
return readFileSync(logPath, "utf8")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Run commands sequentially or in a bounded pool.
|
|
251
|
+
*
|
|
252
|
+
* Concurrency is allowed only when every command declares `parallelSafe`. A
|
|
253
|
+
* mixed set runs sequentially, because one shared resource would otherwise make
|
|
254
|
+
* results depend on scheduling.
|
|
255
|
+
*
|
|
256
|
+
* @param {Array<{id: string, argv: string[], cwd?: string, parallelSafe?: boolean}>} commands Commands to run.
|
|
257
|
+
* @param {object} options Execution options.
|
|
258
|
+
* @param {string} options.root Repository root.
|
|
259
|
+
* @param {number} [options.concurrency=1] Maximum concurrent commands.
|
|
260
|
+
* @param {boolean} [options.failFast=true] Stop after the first failure.
|
|
261
|
+
* @param {number} [options.timeoutSeconds] Per-command timeout.
|
|
262
|
+
* @param {Record<string, string>} [options.env] Extra environment values.
|
|
263
|
+
* @param {string} [options.gate] Gate name.
|
|
264
|
+
* @param {boolean} [options.keepLogs=false] Keep success logs.
|
|
265
|
+
* @returns {Promise<Array<object>>} Results in completion order.
|
|
266
|
+
* @example
|
|
267
|
+
* const results = await runCommands([{ id: "a", argv: ["node", "--test", "a.test.js"] }], { root: "." })
|
|
268
|
+
*/
|
|
269
|
+
export async function runCommands(
|
|
270
|
+
commands,
|
|
271
|
+
{ root, concurrency = 1, failFast = true, timeoutSeconds, env = {}, gate, keepLogs = false } = {},
|
|
272
|
+
) {
|
|
273
|
+
const results = []
|
|
274
|
+
const parallel = concurrency > 1 && commands.every((command) => command.parallelSafe === true)
|
|
275
|
+
|
|
276
|
+
if (!parallel) {
|
|
277
|
+
for (const [index, command] of commands.entries()) {
|
|
278
|
+
const result = await runArgv({
|
|
279
|
+
...command,
|
|
280
|
+
root,
|
|
281
|
+
timeoutSeconds,
|
|
282
|
+
env,
|
|
283
|
+
gate,
|
|
284
|
+
keepLogs,
|
|
285
|
+
serial: index,
|
|
286
|
+
})
|
|
287
|
+
results.push(result)
|
|
288
|
+
if (failFast && result.status !== "passed") break
|
|
289
|
+
}
|
|
290
|
+
return results
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let cursor = 0
|
|
294
|
+
let stopped = false
|
|
295
|
+
const worker = async () => {
|
|
296
|
+
while (!stopped && cursor < commands.length) {
|
|
297
|
+
const index = cursor
|
|
298
|
+
cursor += 1
|
|
299
|
+
const result = await runArgv({
|
|
300
|
+
...commands[index],
|
|
301
|
+
root,
|
|
302
|
+
timeoutSeconds,
|
|
303
|
+
env,
|
|
304
|
+
gate,
|
|
305
|
+
keepLogs,
|
|
306
|
+
serial: index,
|
|
307
|
+
})
|
|
308
|
+
results.push(result)
|
|
309
|
+
if (failFast && result.status !== "passed") stopped = true
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, commands.length) }, () => worker()))
|
|
313
|
+
return results
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Run one requirement probe.
|
|
318
|
+
*
|
|
319
|
+
* A failing probe is reported as `false`; the caller fails the dependent group
|
|
320
|
+
* instead of skipping it.
|
|
321
|
+
*
|
|
322
|
+
* @param {object} input Probe input.
|
|
323
|
+
* @param {string[]} input.argv Probe argv.
|
|
324
|
+
* @param {string} input.root Repository root.
|
|
325
|
+
* @param {Record<string, string>} [input.env] Extra environment values.
|
|
326
|
+
* @returns {Promise<boolean>} `true` when the probe exits zero.
|
|
327
|
+
*/
|
|
328
|
+
export async function probeRequirement({ argv, root, env = {} }) {
|
|
329
|
+
const workingDirectory = resolveWorkingDirectory(root, ".")
|
|
330
|
+
return new Promise((resolvePromise) => {
|
|
331
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
332
|
+
cwd: workingDirectory,
|
|
333
|
+
shell: false,
|
|
334
|
+
stdio: "ignore",
|
|
335
|
+
env: { ...process.env, ...env },
|
|
336
|
+
})
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
child.kill("SIGKILL")
|
|
339
|
+
resolvePromise(false)
|
|
340
|
+
}, 30_000)
|
|
341
|
+
child.on("error", () => {
|
|
342
|
+
clearTimeout(timer)
|
|
343
|
+
resolvePromise(false)
|
|
344
|
+
})
|
|
345
|
+
child.on("close", (code) => {
|
|
346
|
+
clearTimeout(timer)
|
|
347
|
+
resolvePromise(code === 0)
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for change detection and baseline resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** A resolved Git base. */
|
|
6
|
+
export interface SelectionBase {
|
|
7
|
+
kind: "tree" | "ref"
|
|
8
|
+
value: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A rename entry from `git diff --name-status`. */
|
|
12
|
+
export interface SelectionRename {
|
|
13
|
+
from: string
|
|
14
|
+
to: string
|
|
15
|
+
score: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Parsed Git name-status output. */
|
|
19
|
+
export interface ParsedNameStatus {
|
|
20
|
+
changed: string[]
|
|
21
|
+
renames: SelectionRename[]
|
|
22
|
+
deleted: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Git runner result. */
|
|
26
|
+
export interface GitResult {
|
|
27
|
+
status: number
|
|
28
|
+
stdout: string
|
|
29
|
+
stderr?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Error raised when a base cannot be resolved. */
|
|
33
|
+
export declare class SelectionError extends Error {
|
|
34
|
+
reason: string
|
|
35
|
+
constructor(reason: string, message: string)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Run a read-only Git command. */
|
|
39
|
+
export declare function defaultRunGit(
|
|
40
|
+
args: string[],
|
|
41
|
+
options?: { cwd?: string }
|
|
42
|
+
): GitResult
|
|
43
|
+
|
|
44
|
+
/** Parse NUL-separated name-status output. */
|
|
45
|
+
export declare function parseNameStatusZ(output: string): ParsedNameStatus
|
|
46
|
+
|
|
47
|
+
/** Parse NUL-separated path lists. */
|
|
48
|
+
export declare function parseNullList(output: string): string[]
|
|
49
|
+
|
|
50
|
+
/** Resolve the selection base. */
|
|
51
|
+
export declare function resolveBaseline(input?: {
|
|
52
|
+
baseline?: string
|
|
53
|
+
lastFullSha?: string
|
|
54
|
+
branch?: string
|
|
55
|
+
isTag?: boolean
|
|
56
|
+
env?: Record<string, string>
|
|
57
|
+
runGit?: (args: string[], options?: { cwd?: string }) => GitResult
|
|
58
|
+
}): SelectionBase
|
|
59
|
+
|
|
60
|
+
/** Collect every changed path for a selection run. */
|
|
61
|
+
export declare function collectChanges(input: {
|
|
62
|
+
root: string
|
|
63
|
+
baseline?: string
|
|
64
|
+
lastFullSha?: string
|
|
65
|
+
branch?: string
|
|
66
|
+
isTag?: boolean
|
|
67
|
+
include?: string[]
|
|
68
|
+
env?: Record<string, string>
|
|
69
|
+
runGit?: (args: string[], options?: { cwd?: string }) => GitResult
|
|
70
|
+
}): {
|
|
71
|
+
base: SelectionBase
|
|
72
|
+
changed: string[]
|
|
73
|
+
renames: SelectionRename[]
|
|
74
|
+
deleted: string[]
|
|
75
|
+
warnings: object[]
|
|
76
|
+
}
|