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.
Files changed (48) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +49 -2
  3. package/_shared/discovery.md +2 -1
  4. package/_shared/spec-first-ordering.md +6 -2
  5. package/_shared/test-selection.md +130 -0
  6. package/bin/codeops-test.mjs +855 -0
  7. package/bin/lib/test-branch-policy.d.mts +51 -0
  8. package/bin/lib/test-branch-policy.mjs +185 -0
  9. package/bin/lib/test-map-validate.d.mts +42 -0
  10. package/bin/lib/test-map-validate.mjs +618 -0
  11. package/bin/lib/test-map.d.mts +114 -0
  12. package/bin/lib/test-map.mjs +384 -0
  13. package/bin/lib/test-provenance.d.mts +73 -0
  14. package/bin/lib/test-provenance.mjs +188 -0
  15. package/bin/lib/test-runner.d.mts +72 -0
  16. package/bin/lib/test-runner.mjs +350 -0
  17. package/bin/lib/test-selection-changes.d.mts +76 -0
  18. package/bin/lib/test-selection-changes.mjs +272 -0
  19. package/bin/lib/test-selection-explain.mjs +75 -0
  20. package/bin/lib/test-selection-glob.d.mts +32 -0
  21. package/bin/lib/test-selection-glob.mjs +206 -0
  22. package/bin/lib/test-selection.d.mts +80 -0
  23. package/bin/lib/test-selection.mjs +418 -0
  24. package/package.json +4 -2
  25. package/schemas/test-map.schema.json +164 -0
  26. package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
  27. package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
  28. package/scripts/discovery-toggle.mjs +236 -0
  29. package/skills/analyze-project/SKILL.md +9 -2
  30. package/skills/exec-plan/SKILL.md +16 -8
  31. package/skills/exec-plan/execution-protocol.md +15 -2
  32. package/skills/git-commit/SKILL.md +1 -1
  33. package/skills/make-plan/SKILL.md +10 -3
  34. package/skills/make-plan/quality-checklist.md +4 -0
  35. package/skills/make-plan/templates.md +19 -1
  36. package/skills/organize-tests/SKILL.md +169 -0
  37. package/skills/organize-tests/references/agents-testing-block.md +80 -0
  38. package/skills/organize-tests/references/analysis-protocol.md +94 -0
  39. package/skills/organize-tests/references/ci-github-actions.md +178 -0
  40. package/skills/organize-tests/references/language-adapters.md +54 -0
  41. package/skills/organize-tests/references/map-health.md +47 -0
  42. package/skills/organize-tests/references/project-wrapper.md +72 -0
  43. package/skills/organize-tests/references/shadow-mode.md +60 -0
  44. package/skills/setup-codeops/SKILL.md +30 -3
  45. package/standards/coding-standards-full.md +28 -0
  46. package/standards/coding-standards.md +29 -0
  47. package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
  48. 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 }