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,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Git change detection and baseline resolution for the selector.
|
|
4
|
+
*
|
|
5
|
+
* The selector never guesses where a change began. This module resolves one
|
|
6
|
+
* base from an explicit value, an environment hint, the last successful full-run
|
|
7
|
+
* marker, or the branch policy, and then collects every changed path — committed,
|
|
8
|
+
* staged, unstaged, renamed, deleted, and untracked.
|
|
9
|
+
*
|
|
10
|
+
* All shell work goes through `spawnSync` with an argv array. No path is ever
|
|
11
|
+
* interpolated into a shell string, and the Git calls are read-only.
|
|
12
|
+
*
|
|
13
|
+
* @module lib/test-selection-changes
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Error raised when a baseline cannot be resolved or Git cannot answer.
|
|
20
|
+
*
|
|
21
|
+
* The `reason` value is stable and becomes the selection result's fallback
|
|
22
|
+
* reason (`marker-missing`, `unresolved-base`, `git-error`).
|
|
23
|
+
*/
|
|
24
|
+
export class SelectionError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} reason Stable reason code.
|
|
27
|
+
* @param {string} message Human-readable explanation.
|
|
28
|
+
*/
|
|
29
|
+
constructor(reason, message) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = "SelectionError"
|
|
32
|
+
this.reason = reason
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run one read-only Git command.
|
|
38
|
+
*
|
|
39
|
+
* @param {string[]} args Git arguments after the program name.
|
|
40
|
+
* @param {object} [options] Execution options.
|
|
41
|
+
* @param {string} [options.cwd] Repository root.
|
|
42
|
+
* @returns {{status: number, stdout: string, stderr: string}} Process result.
|
|
43
|
+
*/
|
|
44
|
+
export function defaultRunGit(args, { cwd } = {}) {
|
|
45
|
+
const result = spawnSync("git", args, {
|
|
46
|
+
cwd,
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
49
|
+
})
|
|
50
|
+
return {
|
|
51
|
+
status: result.status ?? 1,
|
|
52
|
+
stdout: result.stdout ?? "",
|
|
53
|
+
stderr: result.stderr ?? "",
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse `git diff --name-status -z` output.
|
|
59
|
+
*
|
|
60
|
+
* A rename or copy entry carries two paths after its status token; every other
|
|
61
|
+
* entry carries one. Both sides of a rename count as changed so a source move
|
|
62
|
+
* never hides its old or new owner.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} output NUL-separated Git output.
|
|
65
|
+
* @returns {{changed: string[], renames: Array<{from: string, to: string, score: string}>, deleted: string[]}}
|
|
66
|
+
* @example
|
|
67
|
+
* parseNameStatusZ("R100\0a.ts\0b.ts\0D\0old.ts\0")
|
|
68
|
+
*/
|
|
69
|
+
export function parseNameStatusZ(output) {
|
|
70
|
+
const parts = String(output ?? "").split("\0")
|
|
71
|
+
const changed = []
|
|
72
|
+
const renames = []
|
|
73
|
+
const deleted = []
|
|
74
|
+
const seen = new Set()
|
|
75
|
+
|
|
76
|
+
const push = (path) => {
|
|
77
|
+
if (!path || seen.has(path)) return
|
|
78
|
+
seen.add(path)
|
|
79
|
+
changed.push(path)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
83
|
+
const status = parts[index]
|
|
84
|
+
if (!status) continue
|
|
85
|
+
const letter = status[0]
|
|
86
|
+
if (letter === "R" || letter === "C") {
|
|
87
|
+
const from = parts[index + 1]
|
|
88
|
+
const to = parts[index + 2]
|
|
89
|
+
index += 2
|
|
90
|
+
if (from && to) {
|
|
91
|
+
push(from)
|
|
92
|
+
push(to)
|
|
93
|
+
renames.push({ from, to, score: status.slice(1) })
|
|
94
|
+
}
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const path = parts[index + 1]
|
|
98
|
+
index += 1
|
|
99
|
+
if (!path) continue
|
|
100
|
+
push(path)
|
|
101
|
+
if (letter === "D") deleted.push(path)
|
|
102
|
+
}
|
|
103
|
+
return { changed, renames, deleted }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse NUL-separated `git ls-files -z` output into paths.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} output NUL-separated output.
|
|
110
|
+
* @returns {string[]} Non-empty paths.
|
|
111
|
+
*/
|
|
112
|
+
export function parseNullList(output) {
|
|
113
|
+
return String(output ?? "")
|
|
114
|
+
.split("\0")
|
|
115
|
+
.filter((value) => value.length > 0)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check whether a value looks like a Git object hash.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} value Candidate value.
|
|
122
|
+
* @returns {boolean} `true` for a 40- or 64-character hexadecimal string.
|
|
123
|
+
*/
|
|
124
|
+
function isObjectHash(value) {
|
|
125
|
+
return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(value)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Normalize a branch name for classification.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} branch Raw branch or ref.
|
|
132
|
+
* @returns {string} Branch name without `refs/heads/` or `refs/remotes/`.
|
|
133
|
+
*/
|
|
134
|
+
function branchName(branch) {
|
|
135
|
+
return String(branch ?? "")
|
|
136
|
+
.replace(/^refs\/heads\//, "")
|
|
137
|
+
.replace(/^refs\/remotes\/[^/]+\//, "")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the base for change detection.
|
|
142
|
+
*
|
|
143
|
+
* Priority is explicit `--baseline`/`--since`, `CODEOPS_TEST_BASE`, the
|
|
144
|
+
* last-full marker, then the branch policy. An integration branch with no valid
|
|
145
|
+
* marker fails closed; it never falls back to `HEAD`, because an empty diff
|
|
146
|
+
* would silently skip every test.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} input Resolution input.
|
|
149
|
+
* @param {string} [input.baseline] Explicit tree or ref.
|
|
150
|
+
* @param {string} [input.lastFullSha] Last successful full-run SHA.
|
|
151
|
+
* @param {string} [input.branch] Current branch or ref.
|
|
152
|
+
* @param {boolean} [input.isTag=false] Whether the ref is a release tag.
|
|
153
|
+
* @param {Record<string, string>} [input.env] Environment values.
|
|
154
|
+
* @param {(args: string[], options?: object) => {status: number, stdout: string}} [input.runGit] Git runner.
|
|
155
|
+
* @returns {{kind: "tree"|"ref", value: string}} Resolved base.
|
|
156
|
+
* @throws {SelectionError} When no safe base exists.
|
|
157
|
+
* @example
|
|
158
|
+
* resolveBaseline({ branch: "feature/x", runGit: defaultRunGit })
|
|
159
|
+
*/
|
|
160
|
+
export function resolveBaseline({
|
|
161
|
+
baseline,
|
|
162
|
+
lastFullSha,
|
|
163
|
+
branch,
|
|
164
|
+
isTag = false,
|
|
165
|
+
env = {},
|
|
166
|
+
runGit = defaultRunGit,
|
|
167
|
+
} = {}) {
|
|
168
|
+
const explicit = baseline ?? env.CODEOPS_TEST_BASE
|
|
169
|
+
if (explicit) {
|
|
170
|
+
const check = runGit(["rev-parse", "--verify", `${explicit}^{object}`])
|
|
171
|
+
if (check.status !== 0) throw new SelectionError("unresolved-base", `cannot resolve base ${explicit}`)
|
|
172
|
+
return { kind: isObjectHash(explicit) ? "tree" : "ref", value: explicit }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const name = branchName(branch)
|
|
176
|
+
const marker = lastFullSha ?? env.CODEOPS_LAST_FULL_SHA ?? null
|
|
177
|
+
|
|
178
|
+
if (name === "main" || name === "master") {
|
|
179
|
+
if (!marker) throw new SelectionError("marker-missing", `${name} needs a last successful full-run marker`)
|
|
180
|
+
const ancestor = runGit(["merge-base", "--is-ancestor", marker, "HEAD"])
|
|
181
|
+
if (ancestor.status !== 0) {
|
|
182
|
+
throw new SelectionError("marker-missing", `marker ${marker} is not an ancestor of HEAD`)
|
|
183
|
+
}
|
|
184
|
+
return { kind: "ref", value: marker }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (isTag || /^v\d/.test(name)) {
|
|
188
|
+
const previous = runGit(["describe", "--tags", "--abbrev=0", "--match", "v*", `${name}^`])
|
|
189
|
+
if (previous.status === 0 && previous.stdout.trim()) {
|
|
190
|
+
return { kind: "ref", value: previous.stdout.trim() }
|
|
191
|
+
}
|
|
192
|
+
throw new SelectionError("unresolved-base", `no previous release tag before ${name}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (name.startsWith("release/") || name.startsWith("release-") || name.startsWith("hotfix/") || name.startsWith("hotfix-")) {
|
|
196
|
+
const previous = runGit(["describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^"])
|
|
197
|
+
if (previous.status === 0 && previous.stdout.trim()) {
|
|
198
|
+
return { kind: "ref", value: previous.stdout.trim() }
|
|
199
|
+
}
|
|
200
|
+
return mergeBaseWithIntegration({ runGit, branch: name })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return mergeBaseWithIntegration({ runGit, branch: name })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Find the merge base with the first available integration branch.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} input Resolution input.
|
|
210
|
+
* @param {(args: string[], options?: object) => {status: number, stdout: string}} input.runGit Git runner.
|
|
211
|
+
* @param {string} input.branch Branch being resolved (for the error message).
|
|
212
|
+
* @returns {{kind: "ref", value: string}} Merge-base commit.
|
|
213
|
+
* @throws {SelectionError} When neither integration branch exists.
|
|
214
|
+
*/
|
|
215
|
+
function mergeBaseWithIntegration({ runGit, branch }) {
|
|
216
|
+
for (const integration of ["main", "master"]) {
|
|
217
|
+
const result = runGit(["merge-base", "HEAD", integration])
|
|
218
|
+
const value = result.stdout.trim()
|
|
219
|
+
if (result.status === 0 && value) return { kind: "ref", value }
|
|
220
|
+
}
|
|
221
|
+
throw new SelectionError("unresolved-base", `cannot find an integration base for ${branch}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Collect every changed path for a selection run.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} input Collection input.
|
|
228
|
+
* @param {string} input.root Repository root.
|
|
229
|
+
* @param {string} [input.baseline] Explicit base.
|
|
230
|
+
* @param {string} [input.lastFullSha] Last successful full-run SHA.
|
|
231
|
+
* @param {string} [input.branch] Current branch.
|
|
232
|
+
* @param {boolean} [input.isTag=false] Whether the ref is a tag.
|
|
233
|
+
* @param {string[]} [input.include=[]] Extra paths that must be considered changed.
|
|
234
|
+
* @param {Record<string, string>} [input.env] Environment values.
|
|
235
|
+
* @param {(args: string[], options?: object) => {status: number, stdout: string, stderr: string}} [input.runGit] Git runner.
|
|
236
|
+
* @returns {{base: {kind: string, value: string}, changed: string[], renames: Array<object>, deleted: string[], warnings: object[]}}
|
|
237
|
+
* @throws {SelectionError} When Git cannot answer.
|
|
238
|
+
* @example
|
|
239
|
+
* const changes = collectChanges({ root: process.cwd(), baseline: treeSha, include: ["spec.test.ts"] })
|
|
240
|
+
*/
|
|
241
|
+
export function collectChanges({
|
|
242
|
+
root,
|
|
243
|
+
baseline,
|
|
244
|
+
lastFullSha,
|
|
245
|
+
branch,
|
|
246
|
+
isTag = false,
|
|
247
|
+
include = [],
|
|
248
|
+
env = {},
|
|
249
|
+
runGit = defaultRunGit,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const base = resolveBaseline({ baseline, lastFullSha, branch, isTag, env, runGit })
|
|
252
|
+
|
|
253
|
+
const diff = runGit(["diff", "--name-status", "-z", base.value], { cwd: root })
|
|
254
|
+
if (diff.status !== 0) {
|
|
255
|
+
throw new SelectionError("git-error", `git diff against ${base.value} failed`)
|
|
256
|
+
}
|
|
257
|
+
const parsed = parseNameStatusZ(diff.stdout)
|
|
258
|
+
|
|
259
|
+
const untracked = runGit(["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root })
|
|
260
|
+
if (untracked.status !== 0) {
|
|
261
|
+
throw new SelectionError("git-error", "git ls-files failed")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const changed = [...new Set([...parsed.changed, ...parseNullList(untracked.stdout), ...include])].sort()
|
|
265
|
+
return {
|
|
266
|
+
base,
|
|
267
|
+
changed,
|
|
268
|
+
renames: parsed.renames,
|
|
269
|
+
deleted: parsed.deleted,
|
|
270
|
+
warnings: [],
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable rendering of a selection result.
|
|
4
|
+
*
|
|
5
|
+
* JSON is the machine contract; people need a compact summary they can scan in
|
|
6
|
+
* a terminal. This module keeps formatting out of the selector so both outputs
|
|
7
|
+
* describe exactly the same result object.
|
|
8
|
+
*
|
|
9
|
+
* @module lib/test-selection-explain
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render the base evidence as one short label.
|
|
14
|
+
*
|
|
15
|
+
* @param {{kind?: string, value?: string}|null} base Resolved base.
|
|
16
|
+
* @returns {string} Human-readable base.
|
|
17
|
+
*/
|
|
18
|
+
function formatBase(base) {
|
|
19
|
+
if (!base?.value) return "unresolved"
|
|
20
|
+
return `${base.kind ?? "ref"} ${base.value}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render a selection result as a compact multi-line summary.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} result Selection result from `selectTests`.
|
|
27
|
+
* @returns {string} Human-readable summary.
|
|
28
|
+
* @example
|
|
29
|
+
* console.log(formatSelection(result))
|
|
30
|
+
*/
|
|
31
|
+
export function formatSelection(result) {
|
|
32
|
+
const lines = [`mode: ${result.mode}`, `gate: ${result.gate}`, `base: ${formatBase(result.base)}`]
|
|
33
|
+
|
|
34
|
+
if (result.mode === "full") {
|
|
35
|
+
lines.push(`reason: ${result.reason ?? "unknown"}`)
|
|
36
|
+
if (result.unknownPaths?.length) lines.push(`unknown: ${result.unknownPaths.join(", ")}`)
|
|
37
|
+
if (result.fullCommand) {
|
|
38
|
+
const command = Array.isArray(result.fullCommand[0])
|
|
39
|
+
? result.fullCommand.map((argv) => argv.join(" ")).join(" && ")
|
|
40
|
+
: result.fullCommand.join(" ")
|
|
41
|
+
lines.push(`command: ${command}`)
|
|
42
|
+
} else {
|
|
43
|
+
lines.push("command: none configured")
|
|
44
|
+
}
|
|
45
|
+
return lines.join("\n")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (result.mode === "none") {
|
|
49
|
+
lines.push(`reason: ${result.reason ?? "docs-only"}`)
|
|
50
|
+
return lines.join("\n")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push(`selected: ${result.selected.length}/${result.selected.length + result.skipped.length + result.deferred.length} groups`)
|
|
54
|
+
for (const entry of result.selected) {
|
|
55
|
+
lines.push(` ${entry.id} ${entry.reasons.join(", ")}`)
|
|
56
|
+
}
|
|
57
|
+
if (result.deferred.length > 0) {
|
|
58
|
+
lines.push("deferred:")
|
|
59
|
+
for (const entry of result.deferred) lines.push(` ${entry.id} ${entry.reason}`)
|
|
60
|
+
}
|
|
61
|
+
if (result.skipped.length > 0) lines.push(`skipped: ${result.skipped.join(", ")}`)
|
|
62
|
+
lines.push(`budget: ${result.budget.estimatedSec}s (${result.budget.status})`)
|
|
63
|
+
for (const warning of result.warnings ?? []) lines.push(`warning: ${warning.code} ${warning.message}`)
|
|
64
|
+
return lines.join("\n")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render a result as stable JSON.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} result Selection result.
|
|
71
|
+
* @returns {string} Pretty-printed JSON with a trailing newline.
|
|
72
|
+
*/
|
|
73
|
+
export function formatSelectionJson(result) {
|
|
74
|
+
return `${JSON.stringify(result, null, 2)}\n`
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the repository glob matcher.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Validation result for one glob pattern. */
|
|
6
|
+
export type GlobValidation = { ok: true } | { ok: false; message: string }
|
|
7
|
+
|
|
8
|
+
/** Validate syntax without compiling. */
|
|
9
|
+
export declare function validateGlobPattern(pattern: unknown): GlobValidation
|
|
10
|
+
|
|
11
|
+
/** Compile a glob to an anchored regular expression. */
|
|
12
|
+
export declare function globToRegExp(
|
|
13
|
+
pattern: string,
|
|
14
|
+
options?: { caseSensitive?: boolean }
|
|
15
|
+
): RegExp
|
|
16
|
+
|
|
17
|
+
/** Match one normalized repository path. */
|
|
18
|
+
export declare function matchesGlob(
|
|
19
|
+
path: string,
|
|
20
|
+
pattern: string,
|
|
21
|
+
options?: { caseSensitive?: boolean }
|
|
22
|
+
): boolean
|
|
23
|
+
|
|
24
|
+
/** Match one path against a pattern list. */
|
|
25
|
+
export declare function matchesAnyGlob(
|
|
26
|
+
path: string,
|
|
27
|
+
patterns: string[],
|
|
28
|
+
options?: { caseSensitive?: boolean }
|
|
29
|
+
): boolean
|
|
30
|
+
|
|
31
|
+
/** Check whether two globs can share a path. */
|
|
32
|
+
export declare function globsIntersect(left: string, right: string): boolean
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Small, dependency-free glob matcher for repository paths.
|
|
4
|
+
*
|
|
5
|
+
* The selector must not pull a general-purpose glob library into the plugin:
|
|
6
|
+
* the supported syntax is intentionally narrow, and every pattern comes from a
|
|
7
|
+
* committed map that a reviewer can read. This module documents that syntax and
|
|
8
|
+
* rejects constructs it does not implement instead of silently treating them as
|
|
9
|
+
* literals.
|
|
10
|
+
*
|
|
11
|
+
* Supported: `*` (within one segment), `**` (across segments), `?` (one
|
|
12
|
+
* character), character classes such as `[abc]`, `[a-z]`, `[!abc]`, and `\`
|
|
13
|
+
* escapes. Not supported: braces, extglobs, and leading negation groups.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/test-selection-glob
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|]/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate one glob pattern against the supported syntax.
|
|
22
|
+
*
|
|
23
|
+
* @param {unknown} pattern Candidate pattern.
|
|
24
|
+
* @returns {{ok: true} | {ok: false, message: string}} Validation result.
|
|
25
|
+
* @example
|
|
26
|
+
* validateGlobPattern("src/**\/*.ts") // { ok: true }
|
|
27
|
+
*/
|
|
28
|
+
export function validateGlobPattern(pattern) {
|
|
29
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
30
|
+
return { ok: false, message: "glob must be a non-empty string" }
|
|
31
|
+
}
|
|
32
|
+
if (pattern.startsWith("!")) {
|
|
33
|
+
return { ok: false, message: "negation groups are not supported" }
|
|
34
|
+
}
|
|
35
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
36
|
+
const character = pattern[index]
|
|
37
|
+
if (character === "\\") {
|
|
38
|
+
index += 1
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (character === "{" || character === "}") {
|
|
42
|
+
return { ok: false, message: "brace expansion is not supported" }
|
|
43
|
+
}
|
|
44
|
+
if (character === "(" && index > 0 && "?*+@!".includes(pattern[index - 1])) {
|
|
45
|
+
return { ok: false, message: "extglobs are not supported" }
|
|
46
|
+
}
|
|
47
|
+
if (character === "[") {
|
|
48
|
+
const end = findClassEnd(pattern, index + 1)
|
|
49
|
+
if (end === -1) return { ok: false, message: "unterminated character class" }
|
|
50
|
+
index = end
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { ok: true }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find the closing bracket of a character class.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} pattern Glob pattern.
|
|
60
|
+
* @param {number} start Index just after `[`.
|
|
61
|
+
* @returns {number} Index of `]`, or -1 when the class is unterminated.
|
|
62
|
+
*/
|
|
63
|
+
function findClassEnd(pattern, start) {
|
|
64
|
+
for (let index = start; index < pattern.length; index += 1) {
|
|
65
|
+
if (pattern[index] === "\\") {
|
|
66
|
+
index += 1
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (pattern[index] === "]") return index
|
|
70
|
+
}
|
|
71
|
+
return -1
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert one glob pattern to an anchored regular expression.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} pattern Glob pattern.
|
|
78
|
+
* @param {object} [options] Matching options.
|
|
79
|
+
* @param {boolean} [options.caseSensitive=true] Whether case matters.
|
|
80
|
+
* @returns {RegExp} Anchored expression matching a normalized repo path.
|
|
81
|
+
*/
|
|
82
|
+
export function globToRegExp(pattern, { caseSensitive = true } = {}) {
|
|
83
|
+
const validation = validateGlobPattern(pattern)
|
|
84
|
+
if (!validation.ok) {
|
|
85
|
+
throw new Error(`invalid glob ${JSON.stringify(pattern)}: ${validation.message}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let source = "^"
|
|
89
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
90
|
+
const character = pattern[index]
|
|
91
|
+
|
|
92
|
+
if (character === "\\") {
|
|
93
|
+
const next = pattern[index + 1]
|
|
94
|
+
if (next === undefined) {
|
|
95
|
+
source += "\\\\"
|
|
96
|
+
} else {
|
|
97
|
+
source += REGEXP_SPECIAL.test(next) ? `\\${next}` : next
|
|
98
|
+
index += 1
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (character === "*" && pattern[index + 1] === "*") {
|
|
104
|
+
index += 1
|
|
105
|
+
if (pattern[index + 1] === "/") {
|
|
106
|
+
index += 1
|
|
107
|
+
source += "(?:.*/)?"
|
|
108
|
+
} else {
|
|
109
|
+
source += ".*"
|
|
110
|
+
}
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (character === "*") {
|
|
115
|
+
source += "[^/]*"
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (character === "?") {
|
|
120
|
+
source += "[^/]"
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (character === "[") {
|
|
125
|
+
const end = findClassEnd(pattern, index + 1)
|
|
126
|
+
let body = pattern.slice(index + 1, end)
|
|
127
|
+
if (body.startsWith("!")) body = `^${body.slice(1)}`
|
|
128
|
+
source += `[${body.replace(/\\/g, "\\\\")}]`
|
|
129
|
+
index = end
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
source += REGEXP_SPECIAL.test(character) ? `\\${character}` : character
|
|
134
|
+
}
|
|
135
|
+
source += "$"
|
|
136
|
+
return new RegExp(source, caseSensitive ? "" : "i")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Match one normalized repository path against one glob.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} path Repo-relative path with forward slashes.
|
|
143
|
+
* @param {string} pattern Glob pattern.
|
|
144
|
+
* @param {object} [options] Matching options.
|
|
145
|
+
* @param {boolean} [options.caseSensitive=true] Whether case matters.
|
|
146
|
+
* @returns {boolean} `true` when the pattern matches the whole path.
|
|
147
|
+
*/
|
|
148
|
+
export function matchesGlob(path, pattern, options = {}) {
|
|
149
|
+
return globToRegExp(pattern, options).test(path)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Match one path against any pattern in a list.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} path Repo-relative path.
|
|
156
|
+
* @param {string[]} patterns Glob patterns.
|
|
157
|
+
* @param {object} [options] Matching options.
|
|
158
|
+
* @returns {boolean} `true` when at least one pattern matches.
|
|
159
|
+
*/
|
|
160
|
+
export function matchesAnyGlob(path, patterns, options = {}) {
|
|
161
|
+
return patterns.some((pattern) => matchesGlob(path, pattern, options))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract the literal directory prefix of a glob, before the first wildcard.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} pattern Glob pattern.
|
|
168
|
+
* @returns {string} Prefix ending at the last literal `/`, or an empty string.
|
|
169
|
+
*/
|
|
170
|
+
function globPrefix(pattern) {
|
|
171
|
+
let literal = ""
|
|
172
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
173
|
+
const character = pattern[index]
|
|
174
|
+
if ("*?[".includes(character)) break
|
|
175
|
+
if (character === "\\") {
|
|
176
|
+
literal += pattern[index + 1] ?? ""
|
|
177
|
+
index += 1
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
literal += character
|
|
181
|
+
}
|
|
182
|
+
const slash = literal.lastIndexOf("/")
|
|
183
|
+
return slash === -1 ? "" : literal.slice(0, slash + 1)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decide whether two globs can match a common path.
|
|
188
|
+
*
|
|
189
|
+
* This is a conservative prefix check, not a set-intersection proof. It is used
|
|
190
|
+
* to stop a documented ignore rule from swallowing an owned source or a
|
|
191
|
+
* full-on path, so a false positive is a safe error the author can resolve by
|
|
192
|
+
* narrowing the pattern.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} left First glob.
|
|
195
|
+
* @param {string} right Second glob.
|
|
196
|
+
* @returns {boolean} `true` when the patterns overlap or one contains the other.
|
|
197
|
+
* @example
|
|
198
|
+
* globsIntersect("web/**", "web/billing/**") // true
|
|
199
|
+
*/
|
|
200
|
+
export function globsIntersect(left, right) {
|
|
201
|
+
if (left === right) return true
|
|
202
|
+
const leftPrefix = globPrefix(left)
|
|
203
|
+
const rightPrefix = globPrefix(right)
|
|
204
|
+
if (leftPrefix === "" || rightPrefix === "") return false
|
|
205
|
+
return leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)
|
|
206
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the pure selector.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TestMap, TestMapGroup, TestMapPolicy } from "./test-map.mjs"
|
|
6
|
+
import type { SelectionBase } from "./test-selection-changes.mjs"
|
|
7
|
+
|
|
8
|
+
/** Gates accepted by the selector. */
|
|
9
|
+
export type SelectionGate = "task" | "phase" | "pr" | "release" | "acceptance" | "nightly"
|
|
10
|
+
|
|
11
|
+
/** Partial gates that may narrow the run. */
|
|
12
|
+
export declare const PARTIAL_GATES: readonly SelectionGate[]
|
|
13
|
+
|
|
14
|
+
/** Every accepted gate. */
|
|
15
|
+
export declare const ALL_GATES: readonly SelectionGate[]
|
|
16
|
+
|
|
17
|
+
/** One selected group. */
|
|
18
|
+
export interface SelectedGroup {
|
|
19
|
+
id: string
|
|
20
|
+
slice: string
|
|
21
|
+
reasons: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One deferred group. */
|
|
25
|
+
export interface DeferredGroup {
|
|
26
|
+
id: string
|
|
27
|
+
reason: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One expanded command. */
|
|
31
|
+
export interface SelectionCommand {
|
|
32
|
+
id: string
|
|
33
|
+
argv: string[]
|
|
34
|
+
cwd: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Budget summary. */
|
|
38
|
+
export interface SelectionBudget {
|
|
39
|
+
estimatedSec: number
|
|
40
|
+
status: "within-target" | "acceptable" | "over-budget" | "not-applicable"
|
|
41
|
+
breakdown?: Array<{ id: string; durationSec: number }>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Selection result shared by every mode. */
|
|
45
|
+
export interface SelectionResult {
|
|
46
|
+
schema: number
|
|
47
|
+
mode: "groups" | "full" | "none"
|
|
48
|
+
gate: SelectionGate
|
|
49
|
+
base: SelectionBase | null
|
|
50
|
+
mapHash: string | null
|
|
51
|
+
changed: string[]
|
|
52
|
+
selected: SelectedGroup[]
|
|
53
|
+
deferred: DeferredGroup[]
|
|
54
|
+
skipped: string[]
|
|
55
|
+
commands: SelectionCommand[]
|
|
56
|
+
budget: SelectionBudget
|
|
57
|
+
warnings: Array<{ code: string; message: string; breakdown?: object[] }>
|
|
58
|
+
reason: string | null
|
|
59
|
+
fullCommand: string[] | string[][] | null
|
|
60
|
+
unknownPaths: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Select groups for one gate. */
|
|
64
|
+
export declare function selectTests(input: {
|
|
65
|
+
map: TestMap | null
|
|
66
|
+
mapHash?: string | null
|
|
67
|
+
changed?: string[]
|
|
68
|
+
files?: string[] | null
|
|
69
|
+
gate?: SelectionGate
|
|
70
|
+
include?: string[]
|
|
71
|
+
explicitFull?: boolean
|
|
72
|
+
env?: Record<string, string>
|
|
73
|
+
base?: SelectionBase | null
|
|
74
|
+
}): SelectionResult
|
|
75
|
+
|
|
76
|
+
/** Check whether a group relies on target discovery. */
|
|
77
|
+
export declare function groupUsesTargetDiscovery(group: TestMapGroup): boolean
|
|
78
|
+
|
|
79
|
+
/** Policy type re-export for consumers. */
|
|
80
|
+
export type { TestMapPolicy }
|