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,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pure test-selection algorithm.
4
+ *
5
+ * Given a loaded map, a change set, and a gate, decide which groups must run.
6
+ * The function is pure: it never reads Git, executes a command, or scans the
7
+ * repository. That keeps the most failure-sensitive logic easy to test and
8
+ * makes the CLI's job a thin I/O wrapper.
9
+ *
10
+ * The safety rule is simple: any uncertainty returns `mode: "full"` with a
11
+ * visible reason. A partial selection is never produced from an incomplete or
12
+ * malformed map.
13
+ *
14
+ * @module lib/test-selection
15
+ */
16
+
17
+ import { isTargetBasedGroup, normalizeRepoPath } from "./test-map.mjs"
18
+ import { matchesAnyGlob } from "./test-selection-glob.mjs"
19
+
20
+ /** Gates that may narrow the run to less than every group. */
21
+ export const PARTIAL_GATES = Object.freeze(["task", "phase", "pr"])
22
+
23
+ /** Every gate the selector accepts. */
24
+ export const ALL_GATES = Object.freeze(["task", "phase", "pr", "release", "acceptance", "nightly"])
25
+
26
+ /** Tiers allowed at each gate; `null` means every tier. */
27
+ const GATE_TIERS = Object.freeze({
28
+ task: Object.freeze(["task"]),
29
+ phase: Object.freeze(["task", "phase"]),
30
+ pr: Object.freeze(["task", "phase"]),
31
+ release: null,
32
+ acceptance: null,
33
+ nightly: null,
34
+ })
35
+
36
+ /** Stable reason order used in output and explanations. */
37
+ const REASON_ORDER = Object.freeze([
38
+ "direct-source",
39
+ "direct-test",
40
+ "included",
41
+ "dependent",
42
+ "always-run",
43
+ "smoke",
44
+ "gate-all",
45
+ ])
46
+
47
+ /**
48
+ * Add one reason to a group's reason set.
49
+ *
50
+ * @param {Map<string, Set<string>>} reasons Reason sets by group ID.
51
+ * @param {string} id Group ID.
52
+ * @param {string} reason Stable reason token.
53
+ */
54
+ function addReason(reasons, id, reason) {
55
+ if (!reasons.has(id)) reasons.set(id, new Set())
56
+ reasons.get(id).add(reason)
57
+ }
58
+
59
+ /**
60
+ * Sort reasons into the documented order.
61
+ *
62
+ * @param {Set<string>} values Reason set.
63
+ * @returns {string[]} Ordered reasons.
64
+ */
65
+ function orderedReasons(values) {
66
+ return [...values].sort((left, right) => REASON_ORDER.indexOf(left) - REASON_ORDER.indexOf(right))
67
+ }
68
+
69
+ /**
70
+ * Build the full-suite result.
71
+ *
72
+ * @param {object} input Result input.
73
+ * @param {string} input.reason Stable fallback reason.
74
+ * @param {object|null} input.policy Policy, when a map exists.
75
+ * @param {string|null} input.mapHash Map hash.
76
+ * @param {string} input.gate Gate.
77
+ * @param {object|null} input.base Resolved base.
78
+ * @param {string[]} input.changed Changed paths.
79
+ * @param {string[]} input.skipped Group IDs that did not run.
80
+ * @param {object[]} input.warnings Accumulated warnings.
81
+ * @param {string[]} [input.unknownPaths] Paths with no owner.
82
+ * @returns {object} Full selection result.
83
+ */
84
+ function fullResult({ reason, policy, mapHash, gate, base, changed, skipped, warnings, unknownPaths = [] }) {
85
+ const commands = expandFullCommands(policy)
86
+ return {
87
+ schema: 1,
88
+ mode: "full",
89
+ gate,
90
+ base: base ?? null,
91
+ mapHash: mapHash ?? null,
92
+ changed,
93
+ selected: [],
94
+ deferred: [],
95
+ skipped,
96
+ commands,
97
+ budget: { estimatedSec: 0, status: "not-applicable" },
98
+ warnings,
99
+ reason,
100
+ fullCommand: policy?.full ?? null,
101
+ unknownPaths,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Expand a policy's full command into runner-shaped commands.
107
+ *
108
+ * @param {object|null} policy Map policy.
109
+ * @returns {Array<{id: string, argv: string[], cwd: string}>} Commands to run.
110
+ */
111
+ function expandFullCommands(policy) {
112
+ const full = policy?.full
113
+ if (!Array.isArray(full) || full.length === 0) return []
114
+ if (full.every((token) => typeof token === "string")) {
115
+ return [{ id: "full", argv: [...full], cwd: "." }]
116
+ }
117
+ return full.map((argv, index) => ({ id: `full.${index + 1}`, argv: [...argv], cwd: "." }))
118
+ }
119
+
120
+ /**
121
+ * Compute the task-budget status without ever dropping a group.
122
+ *
123
+ * @param {object[]} selected Selected groups.
124
+ * @param {object} policy Map policy.
125
+ * @returns {{estimatedSec: number, status: string, breakdown?: object[]}} Budget summary.
126
+ */
127
+ function budgetFor(selected, policy) {
128
+ const taskGroups = selected.filter((group) => group.tier === "task")
129
+ const estimatedSec = taskGroups.reduce((total, group) => total + (group.durationSec ?? 0), 0)
130
+ const target = policy.budgets?.taskTargetSeconds ?? 120
131
+ const acceptable = policy.budgets?.taskAcceptableSeconds ?? 180
132
+ const status = estimatedSec <= target ? "within-target" : estimatedSec <= acceptable ? "acceptable" : "over-budget"
133
+ if (status !== "over-budget") return { estimatedSec, status }
134
+ return {
135
+ estimatedSec,
136
+ status,
137
+ breakdown: taskGroups.map((group) => ({ id: group.id, durationSec: group.durationSec ?? 0 })),
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Resolve a group's full test set and expand its argv command.
143
+ *
144
+ * `{tests}` always expands to every file the group owns, not only the files in
145
+ * the current change set. A source-only change therefore still runs the whole
146
+ * group, which is what makes a green task result meaningful.
147
+ *
148
+ * @param {object} group Selected group.
149
+ * @param {string[]|null} files Tracked repository files.
150
+ * @param {boolean} caseSensitive Match case sensitivity.
151
+ * @returns {{argv: string[], cwd: string}|null} Expanded command, or null when the test set is empty.
152
+ */
153
+ function expandGroupCommand(group, files, caseSensitive) {
154
+ const tests = Array.isArray(group.tests) ? group.tests : []
155
+ const needsTests = group.run.command.some((token) => token === "{tests}")
156
+ const resolved = needsTests
157
+ ? (files ?? [])
158
+ .map(normalizeRepoPath)
159
+ .filter((file) => matchesAnyGlob(file, tests, { caseSensitive }))
160
+ .sort()
161
+ : []
162
+ if (needsTests && resolved.length === 0) return null
163
+
164
+ const argv = []
165
+ for (const token of group.run.command) {
166
+ if (token === "{tests}") argv.push(...resolved)
167
+ else argv.push(token)
168
+ }
169
+ return { argv, cwd: group.run.cwd ?? "." }
170
+ }
171
+
172
+ /**
173
+ * Select test groups for one gate.
174
+ *
175
+ * @param {object} input Selection input.
176
+ * @param {object|null} input.map Loaded map, or null for the no-map case.
177
+ * @param {string|null} [input.mapHash] Map hash for evidence.
178
+ * @param {string[]} [input.changed=[]] Changed repository paths.
179
+ * @param {string[]|null} [input.files=null] Tracked repository files for command expansion.
180
+ * @param {string} [input.gate="task"] Gate name.
181
+ * @param {string[]} [input.include=[]] Extra paths that must be considered changed.
182
+ * @param {boolean} [input.explicitFull=false] Force the full suite.
183
+ * @param {Record<string, string>} [input.env={}] Environment values.
184
+ * @param {object|null} [input.base=null] Resolved base evidence.
185
+ * @returns {object} Deterministic selection result.
186
+ * @throws {Error} When the gate is unknown.
187
+ * @example
188
+ * const result = selectTests({ map: loaded.map, changed: ["web/billing/Dialog.tsx"], gate: "task" })
189
+ */
190
+ export function selectTests({
191
+ map,
192
+ mapHash = null,
193
+ changed = [],
194
+ files = null,
195
+ gate = "task",
196
+ include = [],
197
+ explicitFull = false,
198
+ env = {},
199
+ base = null,
200
+ }) {
201
+ if (!ALL_GATES.includes(gate)) throw new Error(`unknown gate: ${gate}`)
202
+ const warnings = []
203
+ const policy = map?.policy ?? null
204
+ const groups = map?.groups ?? []
205
+ const allIds = groups.map((group) => group.id).sort()
206
+ const changedPaths = [...new Set([...changed, ...include].map(normalizeRepoPath))].sort()
207
+
208
+ if (!map) {
209
+ return fullResult({ reason: "no-map", policy: null, mapHash: null, gate, base, changed: changedPaths, skipped: [], warnings })
210
+ }
211
+
212
+ if (explicitFull || env.CODEOPS_FULL_TESTS === "1") {
213
+ return fullResult({ reason: "forced", policy, mapHash, gate, base, changed: changedPaths, skipped: allIds, warnings })
214
+ }
215
+
216
+ const caseSensitive = policy.caseSensitive !== false
217
+ if (!caseSensitive) {
218
+ warnings.push({ code: "case-insensitive", message: "path matching is case-insensitive by policy override" })
219
+ }
220
+ const matchOptions = { caseSensitive }
221
+
222
+ const fullOnPaths = (policy.fullOnPaths ?? []).filter((pattern) =>
223
+ changedPaths.some((path) => matchesAnyGlob(path, [pattern], matchOptions)),
224
+ )
225
+ if (fullOnPaths.length > 0) {
226
+ const matching = changedPaths.filter((path) => matchesAnyGlob(path, fullOnPaths, matchOptions))
227
+ return fullResult({
228
+ reason: "full-on-path",
229
+ policy,
230
+ mapHash,
231
+ gate,
232
+ base,
233
+ changed: matching,
234
+ skipped: allIds,
235
+ warnings,
236
+ })
237
+ }
238
+
239
+ const ignoreGlobs = (policy.ignorePaths ?? []).map((entry) => entry.glob)
240
+ const relevant = changedPaths.filter((path) => !matchesAnyGlob(path, ignoreGlobs, matchOptions))
241
+ if (changedPaths.length > 0 && relevant.length === 0) {
242
+ return {
243
+ schema: 1,
244
+ mode: "none",
245
+ gate,
246
+ base: base ?? null,
247
+ mapHash,
248
+ changed: changedPaths,
249
+ selected: [],
250
+ deferred: [],
251
+ skipped: allIds,
252
+ commands: [],
253
+ budget: { estimatedSec: 0, status: "not-applicable" },
254
+ warnings,
255
+ reason: "docs-only",
256
+ fullCommand: policy.full ?? null,
257
+ unknownPaths: [],
258
+ }
259
+ }
260
+
261
+ const includeSet = new Set(include.map(normalizeRepoPath))
262
+ const reasons = new Map()
263
+ const unknownPaths = []
264
+
265
+ for (const path of relevant) {
266
+ let matched = false
267
+ for (const group of groups) {
268
+ const sourceMatch = matchesAnyGlob(path, group.sources ?? [], matchOptions)
269
+ const testMatch = Array.isArray(group.tests) && matchesAnyGlob(path, group.tests, matchOptions)
270
+ if (sourceMatch) addReason(reasons, group.id, "direct-source")
271
+ if (testMatch) addReason(reasons, group.id, "direct-test")
272
+ if (sourceMatch || testMatch) {
273
+ matched = true
274
+ if (includeSet.has(path)) addReason(reasons, group.id, "included")
275
+ }
276
+ }
277
+ if (!matched) unknownPaths.push(path)
278
+ }
279
+
280
+ if (unknownPaths.length > 0) {
281
+ return fullResult({
282
+ reason: "unknown-path",
283
+ policy,
284
+ mapHash,
285
+ gate,
286
+ base,
287
+ changed: changedPaths,
288
+ skipped: allIds,
289
+ warnings,
290
+ unknownPaths,
291
+ })
292
+ }
293
+
294
+ const byId = new Map(groups.map((group) => [group.id, group]))
295
+ const dependents = new Map()
296
+ for (const group of groups) {
297
+ for (const dependency of group.dependsOn ?? []) {
298
+ if (!dependents.has(dependency)) dependents.set(dependency, [])
299
+ dependents.get(dependency).push(group.id)
300
+ }
301
+ }
302
+
303
+ const queue = [...reasons.keys()]
304
+ while (queue.length > 0) {
305
+ const current = queue.shift()
306
+ for (const dependent of dependents.get(current) ?? []) {
307
+ if (!reasons.has(dependent)) {
308
+ addReason(reasons, dependent, "dependent")
309
+ queue.push(dependent)
310
+ }
311
+ }
312
+ }
313
+
314
+ for (const group of groups) {
315
+ if (group.alwaysRun === true) addReason(reasons, group.id, "always-run")
316
+ }
317
+
318
+ const allowedTiers = GATE_TIERS[gate]
319
+ if (allowedTiers === null) {
320
+ for (const group of groups) {
321
+ if (!reasons.has(group.id)) addReason(reasons, group.id, "gate-all")
322
+ }
323
+ }
324
+
325
+ const deferred = []
326
+ for (const id of [...reasons.keys()]) {
327
+ const group = byId.get(id)
328
+ if (!group) continue
329
+ if (allowedTiers !== null && !allowedTiers.includes(group.tier)) {
330
+ reasons.delete(id)
331
+ deferred.push({ id: group.id, reason: "deferred-tier" })
332
+ }
333
+ }
334
+
335
+ if (gate === "pr") {
336
+ for (const id of policy.prSmoke ?? []) {
337
+ if (byId.has(id)) addReason(reasons, id, "smoke")
338
+ }
339
+ }
340
+
341
+ if (allowedTiers !== null) {
342
+ const selectedCount = reasons.size
343
+ const threshold = policy.fullThreshold ?? 0.6
344
+ if (groups.length > 0 && selectedCount / groups.length >= threshold) {
345
+ return fullResult({ reason: "threshold", policy, mapHash, gate, base, changed: changedPaths, skipped: allIds, warnings })
346
+ }
347
+ }
348
+
349
+ const selectedGroups = [...reasons.keys()]
350
+ .map((id) => byId.get(id))
351
+ .filter(Boolean)
352
+ .sort((left, right) => left.slice.localeCompare(right.slice) || left.id.localeCompare(right.id))
353
+
354
+ const commands = []
355
+ for (const group of selectedGroups) {
356
+ const command = expandGroupCommand(group, files, caseSensitive)
357
+ if (!command) {
358
+ return fullResult({
359
+ reason: "map.empty-group",
360
+ policy,
361
+ mapHash,
362
+ gate,
363
+ base,
364
+ changed: changedPaths,
365
+ skipped: allIds,
366
+ warnings,
367
+ })
368
+ }
369
+ commands.push({ id: group.id, argv: command.argv, cwd: command.cwd })
370
+ }
371
+
372
+ const budget = budgetFor(selectedGroups, policy)
373
+ if (budget.status === "over-budget") {
374
+ warnings.push({
375
+ code: "budget.over-budget",
376
+ message: `selected task groups estimate ${budget.estimatedSec}s; all groups are retained`,
377
+ breakdown: budget.breakdown,
378
+ })
379
+ }
380
+
381
+ const selectedIds = new Set(selectedGroups.map((group) => group.id))
382
+ const deferredIds = new Set(deferred.map((entry) => entry.id))
383
+ const skipped = allIds.filter((id) => !selectedIds.has(id) && !deferredIds.has(id))
384
+
385
+ return {
386
+ schema: 1,
387
+ mode: "groups",
388
+ gate,
389
+ base: base ?? null,
390
+ mapHash,
391
+ changed: changedPaths,
392
+ selected: selectedGroups.map((group) => ({
393
+ id: group.id,
394
+ slice: group.slice,
395
+ reasons: orderedReasons(reasons.get(group.id)),
396
+ })),
397
+ deferred: deferred.sort((left, right) => left.id.localeCompare(right.id)),
398
+ skipped,
399
+ commands,
400
+ budget: { estimatedSec: budget.estimatedSec, status: budget.status },
401
+ warnings,
402
+ reason: null,
403
+ fullCommand: policy.full ?? null,
404
+ unknownPaths: [],
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Check whether a group is target-based and therefore exempt from `{tests}`.
410
+ *
411
+ * Re-exported so the CLI and templates do not need a second import path.
412
+ *
413
+ * @param {object} group Map group.
414
+ * @returns {boolean} `true` when the command has no `{tests}` placeholder.
415
+ */
416
+ export function groupUsesTargetDiscovery(group) {
417
+ return isTargetBasedGroup(group)
418
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
@@ -14,7 +14,8 @@
14
14
  "./tui": "./plugin/tui.js"
15
15
  },
16
16
  "bin": {
17
- "opencode-codeops": "bin/index.mjs"
17
+ "opencode-codeops": "bin/index.mjs",
18
+ "codeops-test": "bin/codeops-test.mjs"
18
19
  },
19
20
  "scripts": {
20
21
  "test": "node --test",
@@ -39,6 +40,7 @@
39
40
  "!scripts/*.test.mjs",
40
41
  "!scripts/*.spec.test.mjs",
41
42
  "bin/",
43
+ "!bin/fixtures/",
42
44
  "!bin/*.test.mjs",
43
45
  "!bin/*.spec.test.mjs",
44
46
  "references/",
@@ -0,0 +1,164 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/blendsdk/opencode-codeops/schemas/test-map.schema.json",
4
+ "title": "CodeOps test-impact map",
5
+ "description": "A single-file map, a policy file, or one group fragment. The loader combines a policy file with fragments.",
6
+ "oneOf": [
7
+ { "$ref": "#/$defs/singleMap" },
8
+ { "$ref": "#/$defs/policyFile" },
9
+ { "$ref": "#/$defs/fragment" }
10
+ ],
11
+ "$defs": {
12
+ "path": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\u0000).*$"
16
+ },
17
+ "argv": {
18
+ "type": "array",
19
+ "minItems": 1,
20
+ "items": { "type": "string", "minLength": 1 }
21
+ },
22
+ "full": {
23
+ "oneOf": [
24
+ { "$ref": "#/$defs/argv" },
25
+ {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "items": { "$ref": "#/$defs/argv" }
29
+ }
30
+ ]
31
+ },
32
+ "ignoreEntry": {
33
+ "type": "object",
34
+ "required": ["glob", "reason"],
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "glob": { "$ref": "#/$defs/path" },
38
+ "reason": { "type": "string", "minLength": 1 }
39
+ }
40
+ },
41
+ "branches": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "integration": { "type": "array", "items": { "type": "string" } },
46
+ "feature": { "type": "array", "items": { "type": "string" } },
47
+ "release": { "type": "array", "items": { "type": "string" } },
48
+ "acceptance": { "type": "array", "items": { "type": "string" } },
49
+ "releaseTags": { "type": "array", "items": { "type": "string" } },
50
+ "unknown": { "enum": ["integration", "feature", "release", "acceptance"] }
51
+ }
52
+ },
53
+ "policy": {
54
+ "type": "object",
55
+ "required": ["schema", "full"],
56
+ "additionalProperties": true,
57
+ "properties": {
58
+ "schema": { "const": 1 },
59
+ "full": { "$ref": "#/$defs/full" },
60
+ "budgets": {
61
+ "type": "object",
62
+ "additionalProperties": false,
63
+ "properties": {
64
+ "taskTargetSeconds": { "type": "number", "minimum": 0 },
65
+ "taskAcceptableSeconds": { "type": "number", "minimum": 0 }
66
+ }
67
+ },
68
+ "branches": { "$ref": "#/$defs/branches" },
69
+ "ignorePaths": { "type": "array", "items": { "$ref": "#/$defs/ignoreEntry" } },
70
+ "fullOnPaths": { "type": "array", "items": { "$ref": "#/$defs/path" } },
71
+ "acceptance": {
72
+ "type": "object",
73
+ "required": ["mode"],
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "mode": { "enum": ["automated", "manual", "external"] },
77
+ "groups": { "type": "array", "items": { "type": "string" } },
78
+ "checklist": { "type": ["string", "null"] }
79
+ }
80
+ },
81
+ "testRoots": {
82
+ "oneOf": [
83
+ { "type": "null" },
84
+ { "type": "array", "items": { "$ref": "#/$defs/path" } }
85
+ ]
86
+ },
87
+ "fullThreshold": { "type": "number", "minimum": 0, "maximum": 1 },
88
+ "prSmoke": { "type": "array", "items": { "type": "string" } },
89
+ "requirements": {
90
+ "type": "object",
91
+ "additionalProperties": {
92
+ "type": "object",
93
+ "required": ["check"],
94
+ "additionalProperties": false,
95
+ "properties": { "check": { "$ref": "#/$defs/argv" } }
96
+ }
97
+ },
98
+ "caseSensitive": { "type": "boolean" }
99
+ }
100
+ },
101
+ "sliceAcceptance": {
102
+ "type": "object",
103
+ "required": ["mode", "reason"],
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "mode": { "enum": ["manual", "external", "na"] },
107
+ "reason": { "type": "string", "minLength": 1 }
108
+ }
109
+ },
110
+ "group": {
111
+ "type": "object",
112
+ "required": ["id", "slice", "tier", "sources", "run"],
113
+ "additionalProperties": false,
114
+ "properties": {
115
+ "id": { "type": "string", "minLength": 1 },
116
+ "slice": { "type": "string", "minLength": 1 },
117
+ "tier": { "enum": ["task", "phase", "release", "acceptance"] },
118
+ "sources": { "type": "array", "items": { "$ref": "#/$defs/path" } },
119
+ "tests": { "type": "array", "items": { "$ref": "#/$defs/path" } },
120
+ "dependsOn": { "type": "array", "items": { "type": "string" } },
121
+ "alwaysRun": { "type": "boolean" },
122
+ "durationSec": { "type": "number", "minimum": 0 },
123
+ "parallelSafe": { "type": "boolean" },
124
+ "requires": { "type": "array", "items": { "type": "string" } },
125
+ "owner": { "type": "string" },
126
+ "description": { "type": "string" },
127
+ "acceptance": { "$ref": "#/$defs/sliceAcceptance" },
128
+ "run": {
129
+ "type": "object",
130
+ "required": ["command"],
131
+ "additionalProperties": false,
132
+ "properties": {
133
+ "command": { "$ref": "#/$defs/argv" },
134
+ "cwd": { "$ref": "#/$defs/path" }
135
+ }
136
+ }
137
+ }
138
+ },
139
+ "singleMap": {
140
+ "type": "object",
141
+ "required": ["schema", "policy", "groups"],
142
+ "additionalProperties": false,
143
+ "properties": {
144
+ "schema": { "const": 1 },
145
+ "policy": { "$ref": "#/$defs/policy" },
146
+ "groups": { "type": "array", "items": { "$ref": "#/$defs/group" } }
147
+ }
148
+ },
149
+ "policyFile": {
150
+ "$ref": "#/$defs/policy"
151
+ },
152
+ "fragment": {
153
+ "type": "object",
154
+ "required": ["schema", "groups"],
155
+ "additionalProperties": false,
156
+ "properties": {
157
+ "schema": { "const": 1 },
158
+ "domain": { "type": "string" },
159
+ "owner": { "type": "string" },
160
+ "groups": { "type": "array", "items": { "$ref": "#/$defs/group" } }
161
+ }
162
+ }
163
+ }
164
+ }