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,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.
|
|
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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -20,7 +20,14 @@ description: Analyze the current repository and create or refresh concise CodeOp
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
5. Include project type, principal languages/frameworks, authoritative build/test/verify commands, high-level structure, generated-file warnings, and CodeOps artifact/config locations.
|
|
23
|
-
6. If the managed section exists, replace only its contents. Preserve all other text byte-for-byte.
|
|
23
|
+
6. If the managed section exists, replace only its generated contents outside the `CODEOPS-TESTING` block. Preserve all other text byte-for-byte.
|
|
24
|
+
6b. **Testing block.** Detect `codeops/test-map.json` or `codeops/test-map/` and the project's
|
|
25
|
+
`full` command. The `<!-- CODEOPS-TESTING:START -->` / `<!-- CODEOPS-TESTING:END -->` block
|
|
26
|
+
inside the managed section is owned by the `organize-tests` skill: preserve it byte-for-byte,
|
|
27
|
+
never render or overwrite it, and warn when it is missing or stale. With no map and no block,
|
|
28
|
+
report that no map is present and do not create a block; an existing no-map block is preserved
|
|
29
|
+
as-is. Warn (advisory only) when CI has no full gate on a release/acceptance branch. The block
|
|
30
|
+
contract is in [_shared/test-selection.md](../../_shared/test-selection.md).
|
|
24
31
|
7. On a non-integration branch, preview changes to repository-wide guidance and ask before writing unless repository policy explicitly permits branch-local updates.
|
|
25
32
|
8. In compact mode, remove duplication and stale generated detail from the managed section only. Flag suspected hand-authored bloat; never silently rewrite it.
|
|
26
33
|
9. Validate every recorded command or mark it explicitly unverified.
|
|
@@ -145,7 +145,9 @@ summary template. The essentials:
|
|
|
145
145
|
|
|
146
146
|
### Step 1 — Load the plan
|
|
147
147
|
|
|
148
|
-
1. Read `plans/$ARGUMENTS/99-execution-plan.md`.
|
|
148
|
+
1. Read `plans/$ARGUMENTS/99-execution-plan.md`. An old plan with a single `**Verify**` line
|
|
149
|
+
uses that line as the gate command; with a map, derive the task command, otherwise it serves
|
|
150
|
+
both.
|
|
149
151
|
2. Find incomplete tasks — both `[ ]` and implemented-but-unverified `[~]`; read supporting specs
|
|
150
152
|
in `plans/$ARGUMENTS/`.
|
|
151
153
|
3. Determine the starting point: a `[~]` task is resumed first (re-verify, then promote or keep
|
|
@@ -177,12 +179,17 @@ For each task, in order:
|
|
|
177
179
|
points in [execution-protocol.md](execution-protocol.md) §Progress Reporting; a failed or
|
|
178
180
|
missing report never blocks any step.
|
|
179
181
|
|
|
180
|
-
3. **Verify** — run
|
|
181
|
-
|
|
182
|
-
(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
3. **Verify** — run the task command: the plan's `**Task verify**` line, or the map's `task`
|
|
183
|
+
gate with `--baseline <phase-tree>` and `--include <spec-file>` for the plan's declared spec
|
|
184
|
+
file(s). With neither, run the plan's `**Verify (gate)**` command, or the project's full verify
|
|
185
|
+
(from AGENTS.md or detected conventions), output captured per the protocol's
|
|
186
|
+
**Verify-output capture rule** (PASS one-liner; on failure
|
|
187
|
+
the last 50 log lines + log path). A changed path outside the phase's expected modification
|
|
188
|
+
set, or a shared/config/test-infrastructure change, forces the full gate. Pass → promote `[~]`
|
|
189
|
+
→ `[x]`; fail → fix and re-verify (mark stays `[~]`). Immediately after every `[x]` promotion,
|
|
190
|
+
run the protocol's read-only progress-bar command for the selected plan and show its exact
|
|
191
|
+
output in the next commentary update. Only `[x]` contributes to the displayed completion
|
|
192
|
+
count.
|
|
186
193
|
4. **Commit** per the active commit mode (see [commit-modes.md](commit-modes.md)) — the commit
|
|
187
194
|
gate keys off `[x]`.
|
|
188
195
|
5. **Techdocs check (after each phase):** if the phase introduced architectural changes and
|
|
@@ -215,7 +222,8 @@ For each task, in order:
|
|
|
215
222
|
|
|
216
223
|
1. Finish the current task before stopping.
|
|
217
224
|
2. **🚨 First, update `99-execution-plan.md`** with all completed tasks (before anything else).
|
|
218
|
-
3. Run the
|
|
225
|
+
3. Run the task command when the plan is not complete and the session stops inside a phase;
|
|
226
|
+
run the full gate at plan completion or a phase boundary.
|
|
219
227
|
4. Handle the commit per the active commit mode.
|
|
220
228
|
5. Report a session summary (must state `Execution Plan Updated: ✅`). Template in
|
|
221
229
|
[execution-protocol.md](execution-protocol.md).
|