opencode-codeops 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +39 -0
- 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/skills/analyze-project/SKILL.md +8 -1
- 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/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,618 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test-map validation and health checks.
|
|
4
|
+
*
|
|
5
|
+
* Validation answers two different questions:
|
|
6
|
+
*
|
|
7
|
+
* 1. Is the map structurally usable? Bad schemas, commands, paths, and
|
|
8
|
+
* dependency cycles are errors because selection cannot proceed safely.
|
|
9
|
+
* 2. Is the map healthy? Unclassified files, shared test ownership, and slices
|
|
10
|
+
* without a fast or release tier are warnings that become errors under
|
|
11
|
+
* `--strict`.
|
|
12
|
+
*
|
|
13
|
+
* The split matters: a recoverable selection problem must fall back to the full
|
|
14
|
+
* suite rather than blocking a developer, while release CI can demand a clean
|
|
15
|
+
* map. `exitCodeFor` is the single place that turns the report into the stable
|
|
16
|
+
* exit codes.
|
|
17
|
+
*
|
|
18
|
+
* @module lib/test-map-validate
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { basename } from "node:path"
|
|
22
|
+
|
|
23
|
+
import { isSafeRepoPath, normalizePolicy, normalizeRepoPath } from "./test-map.mjs"
|
|
24
|
+
import {
|
|
25
|
+
globsIntersect,
|
|
26
|
+
matchesAnyGlob,
|
|
27
|
+
validateGlobPattern,
|
|
28
|
+
} from "./test-selection-glob.mjs"
|
|
29
|
+
|
|
30
|
+
/** Group tiers understood by the selector. */
|
|
31
|
+
export const GROUP_TIERS = Object.freeze(["task", "phase", "release", "acceptance"])
|
|
32
|
+
|
|
33
|
+
/** Branch classes understood by the classifier. */
|
|
34
|
+
export const BRANCH_CLASSES = Object.freeze(["integration", "feature", "release", "acceptance"])
|
|
35
|
+
|
|
36
|
+
/** Acceptance modes understood by the provenance and plan layers. */
|
|
37
|
+
export const ACCEPTANCE_MODES = Object.freeze(["automated", "manual", "external"])
|
|
38
|
+
|
|
39
|
+
/** Per-slice acceptance exception modes. */
|
|
40
|
+
export const SLICE_ACCEPTANCE_MODES = Object.freeze(["manual", "external", "na"])
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rules that are warnings in normal mode and errors under `--strict`.
|
|
44
|
+
*
|
|
45
|
+
* Every other rule is a malformed-map error. Keeping this list explicit makes
|
|
46
|
+
* the severity contract auditable and prevents a new rule from silently
|
|
47
|
+
* becoming fatal.
|
|
48
|
+
*/
|
|
49
|
+
export const WARNING_CODES = Object.freeze([
|
|
50
|
+
"map.overlap",
|
|
51
|
+
"map.orphan-test",
|
|
52
|
+
"map.unknown-source",
|
|
53
|
+
"map.slice-no-task",
|
|
54
|
+
"map.slice-no-release",
|
|
55
|
+
"map.group.slow",
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/** Conventional test roots used when the policy does not override them. */
|
|
59
|
+
const CONVENTIONAL_TEST_ROOTS = ["test/", "tests/", "spec/", "__tests__/"]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* File-name shapes that identify a test even when no group claims it.
|
|
63
|
+
*
|
|
64
|
+
* A test root is a directory, so every file below it looks like a candidate.
|
|
65
|
+
* Requiring a test-like name keeps ordinary sources and fixtures from being
|
|
66
|
+
* reported as orphan tests.
|
|
67
|
+
*/
|
|
68
|
+
const TEST_FILE_NAME = /(\.test\.|\.spec\.|_test\.|^test_.*\.(py|js|ts|tsx|jsx)$|Tests?\.(java|kt|cs|php)$|\.rs$)/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build one diagnostic entry.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} code Stable machine code.
|
|
74
|
+
* @param {string} message Human-readable explanation.
|
|
75
|
+
* @param {string} [path] Related repository path.
|
|
76
|
+
* @returns {{code: string, message: string, path?: string}} Diagnostic.
|
|
77
|
+
*/
|
|
78
|
+
function diagnostic(code, message, path) {
|
|
79
|
+
return path ? { code, message, path } : { code, message }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check that a value is a flat argv array of non-empty strings.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} value Candidate argv.
|
|
86
|
+
* @returns {boolean} `true` for a command that can be spawned directly.
|
|
87
|
+
*/
|
|
88
|
+
function isArgv(value) {
|
|
89
|
+
return (
|
|
90
|
+
Array.isArray(value) &&
|
|
91
|
+
value.length > 0 &&
|
|
92
|
+
value.every((token) => typeof token === "string" && token.length > 0)
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check that a full command is a flat argv or an array of flat argv arrays.
|
|
98
|
+
*
|
|
99
|
+
* A single string, or a one-element array containing spaces, would need shell
|
|
100
|
+
* parsing and is therefore rejected. Shell metacharacters are also rejected so
|
|
101
|
+
* the runner never has to decide whether a token is safe.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} value Candidate full command.
|
|
104
|
+
* @returns {boolean} `true` when the value is argv-safe.
|
|
105
|
+
*/
|
|
106
|
+
function isFullCommand(value) {
|
|
107
|
+
if (!Array.isArray(value) || value.length === 0) return false
|
|
108
|
+
if (value.every((token) => typeof token === "string")) return isArgv(value) && !value.some(hasShellSyntax)
|
|
109
|
+
if (value.every((item) => Array.isArray(item))) return value.every((item) => isArgv(item) && !item.some(hasShellSyntax))
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect whitespace or shell metacharacters inside one argv token.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} token Command token.
|
|
117
|
+
* @returns {boolean} `true` when the token would require shell interpretation.
|
|
118
|
+
*/
|
|
119
|
+
function hasShellSyntax(token) {
|
|
120
|
+
return /[\s;&|`$<>]/.test(token)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate an acceptance record attached to a slice group.
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} value Candidate record.
|
|
127
|
+
* @returns {boolean} `true` when mode and reason are valid.
|
|
128
|
+
*/
|
|
129
|
+
function isSliceAcceptance(value) {
|
|
130
|
+
return (
|
|
131
|
+
value &&
|
|
132
|
+
typeof value === "object" &&
|
|
133
|
+
SLICE_ACCEPTANCE_MODES.includes(value.mode) &&
|
|
134
|
+
typeof value.reason === "string" &&
|
|
135
|
+
value.reason.trim().length > 0
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate the map structure and, when a tracked-file list is supplied, its
|
|
141
|
+
* ownership and slice health.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} map Loaded map with `schema`, `policy`, and `groups`.
|
|
144
|
+
* @param {object} [options] Validation options.
|
|
145
|
+
* @param {string[]|null} [options.files=null] Tracked repository files for health checks.
|
|
146
|
+
* @returns {{errors: Array<{code: string, message: string, path?: string}>, warnings: Array<{code: string, message: string, path?: string}>}}
|
|
147
|
+
* @example
|
|
148
|
+
* const report = validateMap(loaded.map, { files: trackedFiles })
|
|
149
|
+
*/
|
|
150
|
+
export function validateMap(map, { files = null } = {}) {
|
|
151
|
+
const errors = []
|
|
152
|
+
const warnings = []
|
|
153
|
+
const add = (code, message, path) => {
|
|
154
|
+
const entry = diagnostic(code, message, path)
|
|
155
|
+
if (WARNING_CODES.includes(code)) warnings.push(entry)
|
|
156
|
+
else errors.push(entry)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
160
|
+
add("map.schema", "the map must be an object")
|
|
161
|
+
return { errors, warnings }
|
|
162
|
+
}
|
|
163
|
+
if (map.schema !== 1) add("map.schema", `unsupported map schema: ${String(map.schema)}`)
|
|
164
|
+
|
|
165
|
+
const policy = map.policy && typeof map.policy === "object" ? normalizePolicy(map.policy) : null
|
|
166
|
+
if (!policy) {
|
|
167
|
+
add("map.schema", "the map must contain a policy object")
|
|
168
|
+
return { errors, warnings }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
validatePolicy(policy, add)
|
|
172
|
+
const groups = validateGroups(map, policy, add)
|
|
173
|
+
validatePolicyReferences(policy, groups, add)
|
|
174
|
+
validateDependencies(groups, add)
|
|
175
|
+
validateIgnores(policy, groups, add)
|
|
176
|
+
validateSlices(groups, add)
|
|
177
|
+
|
|
178
|
+
if (Array.isArray(files)) validateHealth(groups, policy, files, add)
|
|
179
|
+
|
|
180
|
+
return { errors, warnings }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate policy fields shared by both map layouts.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} policy Normalized policy.
|
|
187
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
188
|
+
*/
|
|
189
|
+
function validatePolicy(policy, add) {
|
|
190
|
+
if (!isFullCommand(policy.full)) {
|
|
191
|
+
add("map.full", "policy.full must be a non-empty argv array or an array of argv arrays without shell syntax")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const budgets = policy.budgets
|
|
195
|
+
if (
|
|
196
|
+
!budgets ||
|
|
197
|
+
typeof budgets.taskTargetSeconds !== "number" ||
|
|
198
|
+
typeof budgets.taskAcceptableSeconds !== "number" ||
|
|
199
|
+
budgets.taskTargetSeconds < 0 ||
|
|
200
|
+
budgets.taskAcceptableSeconds < budgets.taskTargetSeconds
|
|
201
|
+
) {
|
|
202
|
+
add("map.schema", "policy.budgets must contain non-negative target and acceptable seconds")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof policy.fullThreshold !== "number" || policy.fullThreshold < 0 || policy.fullThreshold > 1) {
|
|
206
|
+
add("map.threshold", "policy.fullThreshold must be a number between 0 and 1")
|
|
207
|
+
}
|
|
208
|
+
if (typeof policy.caseSensitive !== "boolean") {
|
|
209
|
+
add("map.schema", "policy.caseSensitive must be a boolean")
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
validateBranchPolicy(policy.branches, add)
|
|
213
|
+
|
|
214
|
+
if (!Array.isArray(policy.ignorePaths)) {
|
|
215
|
+
add("map.schema", "policy.ignorePaths must be an array")
|
|
216
|
+
} else {
|
|
217
|
+
for (const entry of policy.ignorePaths) {
|
|
218
|
+
if (!entry || typeof entry !== "object" || !isSafeRepoPath(entry.glob)) {
|
|
219
|
+
add("map.path", "ignorePaths entries must contain a safe glob", entry?.glob)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (typeof entry.reason !== "string" || entry.reason.trim().length === 0) {
|
|
223
|
+
add("map.ignore-reason", `ignore path ${entry.glob} needs a reason`, entry.glob)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!Array.isArray(policy.fullOnPaths) || !policy.fullOnPaths.every((path) => isSafeRepoPath(path))) {
|
|
229
|
+
add("map.path", "policy.fullOnPaths must contain safe repository paths")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!Array.isArray(policy.prSmoke) || !policy.prSmoke.every((id) => typeof id === "string")) {
|
|
233
|
+
add("map.schema", "policy.prSmoke must be an array of group IDs")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (policy.testRoots !== null) {
|
|
237
|
+
if (!Array.isArray(policy.testRoots) || !policy.testRoots.every((root) => isSafeRepoPath(root))) {
|
|
238
|
+
add("map.path", "policy.testRoots must contain safe test-root paths")
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const acceptance = policy.acceptance
|
|
243
|
+
if (
|
|
244
|
+
!acceptance ||
|
|
245
|
+
typeof acceptance !== "object" ||
|
|
246
|
+
!ACCEPTANCE_MODES.includes(acceptance.mode) ||
|
|
247
|
+
!Array.isArray(acceptance.groups ?? [])
|
|
248
|
+
) {
|
|
249
|
+
add("map.schema", "policy.acceptance must declare a valid mode and a groups array")
|
|
250
|
+
} else if ((acceptance.groups ?? []).some((id) => typeof id !== "string")) {
|
|
251
|
+
add("map.schema", "policy.acceptance.groups must contain group IDs")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!policy.requirements || typeof policy.requirements !== "object" || Array.isArray(policy.requirements)) {
|
|
255
|
+
add("map.requires", "policy.requirements must be an object")
|
|
256
|
+
} else {
|
|
257
|
+
for (const [name, probe] of Object.entries(policy.requirements)) {
|
|
258
|
+
if (!probe || typeof probe !== "object" || !isArgv(probe.check)) {
|
|
259
|
+
add("map.requires", `requirement ${name} needs an argv check`, name)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validate branch pattern lists and the unknown-class fallback.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} branches Branch policy.
|
|
269
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
270
|
+
*/
|
|
271
|
+
function validateBranchPolicy(branches, add) {
|
|
272
|
+
if (!branches || typeof branches !== "object") {
|
|
273
|
+
add("map.branch", "policy.branches must be an object")
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
for (const key of ["integration", "feature", "release", "acceptance", "releaseTags"]) {
|
|
277
|
+
const patterns = branches[key]
|
|
278
|
+
if (!Array.isArray(patterns)) {
|
|
279
|
+
add("map.branch", `policy.branches.${key} must be an array`)
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
for (const pattern of patterns) {
|
|
283
|
+
const result = validateGlobPattern(pattern)
|
|
284
|
+
if (!result.ok) add("map.branch", `invalid branch pattern ${JSON.stringify(pattern)}: ${result.message}`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!BRANCH_CLASSES.includes(branches.unknown)) {
|
|
288
|
+
add("map.branch", `policy.branches.unknown must be one of ${BRANCH_CLASSES.join(", ")}`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate each group's fields and return the usable group list.
|
|
294
|
+
*
|
|
295
|
+
* Groups with structural defects are skipped by later dependency and health
|
|
296
|
+
* checks so one bad entry does not produce a cascade of confusing errors.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} map Map under validation.
|
|
299
|
+
* @param {object} policy Normalized policy.
|
|
300
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
301
|
+
* @returns {object[]} Structurally usable groups.
|
|
302
|
+
*/
|
|
303
|
+
function validateGroups(map, policy, add) {
|
|
304
|
+
if (!Array.isArray(map.groups)) {
|
|
305
|
+
add("map.group", "the map must contain a groups array")
|
|
306
|
+
return []
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const seen = new Set()
|
|
310
|
+
const usable = []
|
|
311
|
+
|
|
312
|
+
for (const group of map.groups) {
|
|
313
|
+
if (!group || typeof group !== "object") {
|
|
314
|
+
add("map.group", "each group must be an object")
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const label = typeof group.id === "string" ? group.id : "(unnamed group)"
|
|
318
|
+
let valid = true
|
|
319
|
+
|
|
320
|
+
if (typeof group.id !== "string" || group.id.trim() === "") {
|
|
321
|
+
add("map.group", "group.id must be a non-empty string")
|
|
322
|
+
valid = false
|
|
323
|
+
} else if (seen.has(group.id)) {
|
|
324
|
+
add("map.duplicate-id", `duplicate group id: ${group.id}`, group.id)
|
|
325
|
+
valid = false
|
|
326
|
+
} else {
|
|
327
|
+
seen.add(group.id)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (typeof group.slice !== "string" || group.slice.trim() === "") {
|
|
331
|
+
add("map.group", `group ${label} needs a slice`)
|
|
332
|
+
valid = false
|
|
333
|
+
}
|
|
334
|
+
if (!GROUP_TIERS.includes(group.tier)) {
|
|
335
|
+
add("map.group", `group ${label} has invalid tier ${String(group.tier)}`)
|
|
336
|
+
valid = false
|
|
337
|
+
}
|
|
338
|
+
if (!isArgv(group.run?.command)) {
|
|
339
|
+
add("map.command", `group ${label} needs a non-empty argv command`)
|
|
340
|
+
valid = false
|
|
341
|
+
}
|
|
342
|
+
if (group.run?.cwd !== undefined && !isSafeRepoPath(group.run.cwd)) {
|
|
343
|
+
add("map.cwd", `group ${label} cwd must stay inside the repository`, group.run.cwd)
|
|
344
|
+
valid = false
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const sources = group.sources
|
|
348
|
+
if (!Array.isArray(sources) || !sources.every((path) => typeof path === "string")) {
|
|
349
|
+
add("map.group", `group ${label} needs a sources array`)
|
|
350
|
+
valid = false
|
|
351
|
+
} else if (sources.length === 0 && group.alwaysRun !== true && !group.id?.startsWith("shared.")) {
|
|
352
|
+
add("map.group", `group ${label} has no sources and is not always-run or shared`)
|
|
353
|
+
}
|
|
354
|
+
for (const path of Array.isArray(sources) ? sources : []) {
|
|
355
|
+
if (!isSafeRepoPath(path)) add("map.path", `group ${label} has unsafe source ${JSON.stringify(path)}`, path)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const tests = group.tests
|
|
359
|
+
const targetBased = !isArgv(group.run?.command) || !group.run.command.some((token) => token.includes("{tests}"))
|
|
360
|
+
if (tests !== undefined && (!Array.isArray(tests) || !tests.every((path) => typeof path === "string"))) {
|
|
361
|
+
add("map.group", `group ${label} tests must be an array of paths`)
|
|
362
|
+
valid = false
|
|
363
|
+
} else if (tests === undefined && !targetBased) {
|
|
364
|
+
add("map.group", `group ${label} is file-based but has no tests`)
|
|
365
|
+
valid = false
|
|
366
|
+
}
|
|
367
|
+
for (const path of Array.isArray(tests) ? tests : []) {
|
|
368
|
+
if (!isSafeRepoPath(path)) add("map.path", `group ${label} has unsafe test ${JSON.stringify(path)}`, path)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (group.dependsOn !== undefined && (!Array.isArray(group.dependsOn) || !group.dependsOn.every((id) => typeof id === "string"))) {
|
|
372
|
+
add("map.group", `group ${label} dependsOn must be an array of IDs`)
|
|
373
|
+
valid = false
|
|
374
|
+
}
|
|
375
|
+
if (group.alwaysRun !== undefined && typeof group.alwaysRun !== "boolean") {
|
|
376
|
+
add("map.group", `group ${label} alwaysRun must be a boolean`)
|
|
377
|
+
}
|
|
378
|
+
if (group.alwaysRun === true && group.tier !== "task") {
|
|
379
|
+
add("map.always-run-tier", `group ${label} is alwaysRun but not task tier`)
|
|
380
|
+
valid = false
|
|
381
|
+
}
|
|
382
|
+
if (group.durationSec !== undefined && (typeof group.durationSec !== "number" || group.durationSec < 0)) {
|
|
383
|
+
add("map.group", `group ${label} durationSec must be a non-negative number`)
|
|
384
|
+
}
|
|
385
|
+
if (group.parallelSafe !== undefined && typeof group.parallelSafe !== "boolean") {
|
|
386
|
+
add("map.group", `group ${label} parallelSafe must be a boolean`)
|
|
387
|
+
}
|
|
388
|
+
if (group.acceptance !== undefined && !isSliceAcceptance(group.acceptance)) {
|
|
389
|
+
add("map.acceptance", `group ${label} has an invalid acceptance record`)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const requires = group.requires
|
|
393
|
+
if (requires !== undefined) {
|
|
394
|
+
if (!Array.isArray(requires) || !requires.every((name) => typeof name === "string")) {
|
|
395
|
+
add("map.requires", `group ${label} requires must be an array of names`)
|
|
396
|
+
} else {
|
|
397
|
+
for (const name of requires) {
|
|
398
|
+
if (!policy.requirements?.[name]?.check) {
|
|
399
|
+
add("map.requires", `group ${label} requires ${name} but policy.requirements has no probe`, name)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (valid) usable.push(group)
|
|
406
|
+
}
|
|
407
|
+
return usable
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate policy references to group IDs.
|
|
412
|
+
*
|
|
413
|
+
* Smoke groups and acceptance groups are optional, but a typo in either list
|
|
414
|
+
* silently changes what the gate runs. Report it as a dependency error instead.
|
|
415
|
+
*
|
|
416
|
+
* @param {object} policy Normalized policy.
|
|
417
|
+
* @param {object[]} groups Usable groups.
|
|
418
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
419
|
+
*/
|
|
420
|
+
function validatePolicyReferences(policy, groups, add) {
|
|
421
|
+
const ids = new Set(groups.map((group) => group.id))
|
|
422
|
+
for (const id of policy.prSmoke ?? []) {
|
|
423
|
+
if (!ids.has(id)) add("map.dependency", `prSmoke references unknown group ${id}`, id)
|
|
424
|
+
}
|
|
425
|
+
for (const id of policy.acceptance?.groups ?? []) {
|
|
426
|
+
if (!ids.has(id)) add("map.dependency", `acceptance.groups references unknown group ${id}`, id)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Validate dependency references and detect cycles.
|
|
432
|
+
*
|
|
433
|
+
* @param {object[]} groups Usable groups.
|
|
434
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
435
|
+
*/
|
|
436
|
+
function validateDependencies(groups, add) {
|
|
437
|
+
const ids = new Set(groups.map((group) => group.id))
|
|
438
|
+
for (const group of groups) {
|
|
439
|
+
for (const dependency of group.dependsOn ?? []) {
|
|
440
|
+
if (!ids.has(dependency)) {
|
|
441
|
+
add("map.dependency", `group ${group.id} depends on unknown group ${dependency}`, dependency)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const visiting = new Set()
|
|
447
|
+
const visited = new Set()
|
|
448
|
+
const visit = (id, trail) => {
|
|
449
|
+
if (visiting.has(id)) {
|
|
450
|
+
add("map.cycle", `dependency cycle: ${[...trail, id].join(" -> ")}`)
|
|
451
|
+
return true
|
|
452
|
+
}
|
|
453
|
+
if (visited.has(id)) return false
|
|
454
|
+
visiting.add(id)
|
|
455
|
+
const group = groups.find((candidate) => candidate.id === id)
|
|
456
|
+
for (const dependency of group?.dependsOn ?? []) {
|
|
457
|
+
if (visit(dependency, [...trail, id])) return true
|
|
458
|
+
}
|
|
459
|
+
visiting.delete(id)
|
|
460
|
+
visited.add(id)
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
for (const group of groups) {
|
|
464
|
+
if (visit(group.id, [])) break
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Reject ignore rules that swallow an owned source, test, or full-on path.
|
|
470
|
+
*
|
|
471
|
+
* @param {object} policy Normalized policy.
|
|
472
|
+
* @param {object[]} groups Usable groups.
|
|
473
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
474
|
+
*/
|
|
475
|
+
function validateIgnores(policy, groups, add) {
|
|
476
|
+
const ignores = Array.isArray(policy.ignorePaths) ? policy.ignorePaths.filter((entry) => entry?.glob) : []
|
|
477
|
+
if (ignores.length === 0) return
|
|
478
|
+
|
|
479
|
+
const protectedGlobs = [
|
|
480
|
+
...groups.flatMap((group) => [...(group.sources ?? []), ...(group.tests ?? [])]),
|
|
481
|
+
...(Array.isArray(policy.fullOnPaths) ? policy.fullOnPaths : []),
|
|
482
|
+
]
|
|
483
|
+
for (const entry of ignores) {
|
|
484
|
+
for (const protectedGlob of protectedGlobs) {
|
|
485
|
+
if (globsIntersect(entry.glob, protectedGlob)) {
|
|
486
|
+
add("map.ignore-overlap", `ignore ${entry.glob} overlaps owned path ${protectedGlob}`, entry.glob)
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Check the slice contract: a fast task tier and a release/acceptance path.
|
|
495
|
+
*
|
|
496
|
+
* @param {object[]} groups Usable groups.
|
|
497
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
498
|
+
*/
|
|
499
|
+
function validateSlices(groups, add) {
|
|
500
|
+
const slices = new Map()
|
|
501
|
+
for (const group of groups) {
|
|
502
|
+
if (!slices.has(group.slice)) slices.set(group.slice, [])
|
|
503
|
+
slices.get(group.slice).push(group)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const [slice, members] of slices) {
|
|
507
|
+
if (!members.some((group) => group.tier === "task")) {
|
|
508
|
+
add("map.slice-no-task", `slice ${slice} has no task-tier group`, slice)
|
|
509
|
+
}
|
|
510
|
+
const hasRelease = members.some((group) => group.tier === "release" || group.tier === "acceptance")
|
|
511
|
+
const hasAcceptanceRecord = members.some((group) => isSliceAcceptance(group.acceptance))
|
|
512
|
+
if (!hasRelease && !hasAcceptanceRecord) {
|
|
513
|
+
add("map.slice-no-release", `slice ${slice} has no release/acceptance group or acceptance record`, slice)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Compute the test roots used for orphan and ownership checks.
|
|
520
|
+
*
|
|
521
|
+
* @param {object[]} groups Usable groups.
|
|
522
|
+
* @param {object} policy Normalized policy.
|
|
523
|
+
* @returns {string[]} Repo-relative directory prefixes with trailing slashes.
|
|
524
|
+
*/
|
|
525
|
+
function deriveTestRoots(groups, policy) {
|
|
526
|
+
if (Array.isArray(policy.testRoots)) {
|
|
527
|
+
return policy.testRoots.map((root) => (root.endsWith("/") ? root : `${root}/`))
|
|
528
|
+
}
|
|
529
|
+
const roots = new Set(CONVENTIONAL_TEST_ROOTS)
|
|
530
|
+
for (const group of groups) {
|
|
531
|
+
for (const pattern of group.tests ?? []) {
|
|
532
|
+
const wildcard = pattern.search(/[*?[]/)
|
|
533
|
+
const prefix = wildcard === -1 ? pattern : pattern.slice(0, wildcard)
|
|
534
|
+
const slash = prefix.lastIndexOf("/")
|
|
535
|
+
if (slash > 0) roots.add(prefix.slice(0, slash + 1))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return [...roots]
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Check file ownership, empty groups, orphans, and unclassified sources.
|
|
543
|
+
*
|
|
544
|
+
* This runs only when the caller supplies the tracked-file list, because the
|
|
545
|
+
* selector must never scan the whole repository during normal selection.
|
|
546
|
+
*
|
|
547
|
+
* @param {object[]} groups Usable groups.
|
|
548
|
+
* @param {object} policy Normalized policy.
|
|
549
|
+
* @param {string[]} files Tracked repository files.
|
|
550
|
+
* @param {(code: string, message: string, path?: string) => void} add Diagnostic sink.
|
|
551
|
+
*/
|
|
552
|
+
function validateHealth(groups, policy, files, add) {
|
|
553
|
+
const normalized = files.map(normalizeRepoPath)
|
|
554
|
+
const options = { caseSensitive: policy.caseSensitive !== false }
|
|
555
|
+
const testRoots = deriveTestRoots(groups, policy)
|
|
556
|
+
const inTestArea = (file) => testRoots.some((root) => file.startsWith(root))
|
|
557
|
+
const looksLikeTest = (file) => TEST_FILE_NAME.test(basename(file))
|
|
558
|
+
const isTestFile = (file) =>
|
|
559
|
+
groups.some((group) => Array.isArray(group.tests) && matchesAnyGlob(file, group.tests, options)) ||
|
|
560
|
+
(inTestArea(file) && looksLikeTest(file))
|
|
561
|
+
|
|
562
|
+
for (const group of groups) {
|
|
563
|
+
if (Array.isArray(group.tests) && group.tests.length > 0) {
|
|
564
|
+
const matched = normalized.some((file) => matchesAnyGlob(file, group.tests, options))
|
|
565
|
+
if (!matched) add("map.empty-group", `group ${group.id} matches no tracked test file`, group.id)
|
|
566
|
+
}
|
|
567
|
+
if (typeof group.durationSec === "number" && group.durationSec > 180) {
|
|
568
|
+
add("map.group.slow", `group ${group.id} estimates ${group.durationSec}s, above the acceptable budget`, group.id)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const owners = new Map()
|
|
573
|
+
for (const file of normalized.filter(isTestFile)) {
|
|
574
|
+
const matching = groups.filter((group) =>
|
|
575
|
+
Array.isArray(group.tests) && matchesAnyGlob(file, group.tests, options),
|
|
576
|
+
)
|
|
577
|
+
for (const group of matching) {
|
|
578
|
+
if (!owners.has(file)) owners.set(file, new Set())
|
|
579
|
+
owners.get(file).add(group.id)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const [file, ids] of owners) {
|
|
583
|
+
if (ids.size > 1) add("map.overlap", `test ${file} is owned by ${[...ids].sort().join(", ")}`, file)
|
|
584
|
+
}
|
|
585
|
+
for (const file of normalized.filter(isTestFile)) {
|
|
586
|
+
if (!owners.has(file)) add("map.orphan-test", `test ${file} has no owning group`, file)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const fullOn = Array.isArray(policy.fullOnPaths) ? policy.fullOnPaths : []
|
|
590
|
+
const ignores = Array.isArray(policy.ignorePaths) ? policy.ignorePaths.map((entry) => entry.glob) : []
|
|
591
|
+
for (const file of normalized) {
|
|
592
|
+
if (inTestArea(file) || isTestFile(file)) continue
|
|
593
|
+
const owned = groups.some((group) => matchesAnyGlob(file, group.sources ?? [], options))
|
|
594
|
+
if (owned) continue
|
|
595
|
+
if (matchesAnyGlob(file, fullOn, options)) continue
|
|
596
|
+
if (matchesAnyGlob(file, ignores, options)) continue
|
|
597
|
+
add("map.unknown-source", `source ${file} is not owned, full-on, or ignored`, file)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Turn a validation report into the documented exit code.
|
|
603
|
+
*
|
|
604
|
+
* Structural errors always exit 3. Drift warnings exit 4 only when the caller
|
|
605
|
+
* asked for strict mode; otherwise they are advisory and exit 0.
|
|
606
|
+
*
|
|
607
|
+
* @param {{errors: unknown[], warnings: unknown[]}} report Validation report.
|
|
608
|
+
* @param {object} [options] Exit options.
|
|
609
|
+
* @param {boolean} [options.strict=false] Whether warnings are fatal.
|
|
610
|
+
* @returns {number} 3 for errors, 4 for strict-only failure, 0 otherwise.
|
|
611
|
+
* @example
|
|
612
|
+
* process.exitCode = exitCodeFor(report, { strict: true })
|
|
613
|
+
*/
|
|
614
|
+
export function exitCodeFor(report, { strict = false } = {}) {
|
|
615
|
+
if (report.errors.length > 0) return 3
|
|
616
|
+
if (strict && report.warnings.length > 0) return 4
|
|
617
|
+
return 0
|
|
618
|
+
}
|