@renzrollon/interlock 1.0.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/verify.mjs
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
// Verification policy as code: what to run, and what a result means.
|
|
2
|
+
//
|
|
3
|
+
// This module decides; it never verifies. It spawns nothing, reads nothing,
|
|
4
|
+
// and executes nothing. The orchestration that consumes it is a dynamic
|
|
5
|
+
// workflow script that cannot `import()`, has no filesystem and no shell — only
|
|
6
|
+
// the agents it spawns can run a command. So the shape is inverted from the
|
|
7
|
+
// obvious one: `planVerification` says which commands *should* run, an agent
|
|
8
|
+
// runs them and reports back, and `judge*` says what the results mean.
|
|
9
|
+
//
|
|
10
|
+
// The policies below were prose in `skills/ship/SKILL.md`, and prose is exactly
|
|
11
|
+
// how they got broken:
|
|
12
|
+
//
|
|
13
|
+
// 1. "Use the test profile" became "run `npm test`" the moment a model could
|
|
14
|
+
// not find the profile quickly. A plan built from a profile uses that
|
|
15
|
+
// profile's commands verbatim; a plan built from nothing reports
|
|
16
|
+
// `hasProfile:false` and a skip reason. There is no third branch where a
|
|
17
|
+
// command gets guessed.
|
|
18
|
+
//
|
|
19
|
+
// 2. "A red unit suite halts" was a sentence a model could weigh against
|
|
20
|
+
// other sentences. It is now a boolean with a reason string attached.
|
|
21
|
+
//
|
|
22
|
+
// 3. "Coverage is advisory" drifted into coverage-shaped blocking. Coverage
|
|
23
|
+
// steps are never `blocking` and coverage judgements never `halt` — not
|
|
24
|
+
// as a default, but structurally: `judgeCoverageResult` has no code path
|
|
25
|
+
// that can return a halt.
|
|
26
|
+
//
|
|
27
|
+
// 4. E2E is run, never repaired. E2E failures are frequently environmental,
|
|
28
|
+
// and auto-fixing them papers over real regressions, so a red e2e produces
|
|
29
|
+
// a loud non-blocking signal instead of a gate.
|
|
30
|
+
//
|
|
31
|
+
// 5. Every skip carries a machine-readable reason, so the caller can always
|
|
32
|
+
// print `VERIFICATION SKIPPED: reason=<reason>` rather than silently
|
|
33
|
+
// verifying nothing.
|
|
34
|
+
//
|
|
35
|
+
// 6. Repair is bounded and clustered. Failures collapse by normalized error
|
|
36
|
+
// signature so a shared root cause is fixed once, and the number of repair
|
|
37
|
+
// iterations is capped by `LIMITS.rootCauseIterations`.
|
|
38
|
+
//
|
|
39
|
+
// Pure: no fs, no agent, no I/O, no clock, no imports outside `lib/`.
|
|
40
|
+
// Exposed to skills as `interlock verify …`.
|
|
41
|
+
|
|
42
|
+
import { LIMITS } from './limits.mjs'
|
|
43
|
+
import { isDocsPath } from './risk.mjs'
|
|
44
|
+
|
|
45
|
+
/** Step kinds, in the order they should be executed. */
|
|
46
|
+
export const VERIFY_KINDS = ['typecheck', 'unit', 'lint', 'coverage', 'e2e']
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Kinds whose failure is a gate rather than a report. Deliberately short: a
|
|
50
|
+
* type error is a compile error and a red unit suite is a regression, but lint,
|
|
51
|
+
* coverage and e2e are all reported rather than enforced.
|
|
52
|
+
*/
|
|
53
|
+
export const BLOCKING_KINDS = ['typecheck', 'unit']
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which of the `BLOCKING_KINDS` actually halt, per call site. Verification is
|
|
57
|
+
* called from two places with two different published contracts, and collapsing
|
|
58
|
+
* them breaks one of them:
|
|
59
|
+
*
|
|
60
|
+
* - `inter-wave` runs between waves, where the wave loop owns the response
|
|
61
|
+
* (`LIMITS.interWaveFixAttempts` targeted fixes, then halt or warn). A type
|
|
62
|
+
* error here must stop the next wave from building on top of it.
|
|
63
|
+
* - `final` runs before the commit, where `skills/ship/SKILL.md` and
|
|
64
|
+
* `docs/04-when-it-stops.md` both publish the exhaustive list of hard halts.
|
|
65
|
+
* A red typecheck is not on that list, so it cannot silently become a fourth
|
|
66
|
+
* — it is reported loudly and does not stop the run.
|
|
67
|
+
*
|
|
68
|
+
* `final` is the default: it is the stricter-documented path, and a caller that
|
|
69
|
+
* forgot to say which site it is should not accidentally acquire extra halts.
|
|
70
|
+
*/
|
|
71
|
+
export const VERIFY_CONTEXTS = {
|
|
72
|
+
'inter-wave': { haltingKinds: BLOCKING_KINDS },
|
|
73
|
+
final: { haltingKinds: BLOCKING_KINDS.filter(kind => kind === 'unit') }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const DEFAULT_VERIFY_CONTEXT = 'final'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Machine-readable skip reasons. The caller prints
|
|
80
|
+
* `VERIFICATION SKIPPED: reason=<reason>`, so these strings are part of the
|
|
81
|
+
* contract — add to them, never reword them in place.
|
|
82
|
+
*/
|
|
83
|
+
export const SKIP_REASONS = {
|
|
84
|
+
/** No `.claude/testing/profile.json` at all. Never guess a command from this. */
|
|
85
|
+
NO_PROFILE: 'no-test-profile',
|
|
86
|
+
/** A profile exists but holds no command for this kind. */
|
|
87
|
+
NO_COMMAND: 'no-detectable-command',
|
|
88
|
+
/** The failures were already red before this change touched anything. */
|
|
89
|
+
PRE_EXISTING: 'failures-are-pre-existing',
|
|
90
|
+
/** `LIMITS.interWaveVerifyBudgetMs` spent; only typecheck still runs. */
|
|
91
|
+
BUDGET_EXCEEDED: 'verify-budget-exceeded',
|
|
92
|
+
/** `e2e.enabled` is false in the profile. */
|
|
93
|
+
E2E_DISABLED: 'e2e-not-enabled',
|
|
94
|
+
/** The caller did not opt in with `--e2e`. */
|
|
95
|
+
E2E_NOT_REQUESTED: 'e2e-not-requested',
|
|
96
|
+
/** `coverage.enabled` is false / no coverage command. Never an error. */
|
|
97
|
+
COVERAGE_NOT_CONFIGURED: 'coverage-not-configured',
|
|
98
|
+
/** The caller explicitly turned this step off. */
|
|
99
|
+
CALLER_OPTED_OUT: 'disabled-by-caller',
|
|
100
|
+
/** `--changed` named only documentation paths. */
|
|
101
|
+
DOCS_ONLY: 'docs-only-changes'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Banner prefixes the caller prints verbatim (see the plan's §C3 table). */
|
|
105
|
+
export const BANNERS = {
|
|
106
|
+
noProfile: 'NO TEST PROFILE: run /interlock:fix-tests --reconfigure once',
|
|
107
|
+
skipped: reason => `VERIFICATION SKIPPED: reason=${reason}`,
|
|
108
|
+
e2eFailed: detail => `E2E FAILED (non-blocking by policy): ${detail}`,
|
|
109
|
+
typecheckFailed: detail => `TYPECHECK FAILED (non-blocking at the final gate): ${detail}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fail(message) {
|
|
113
|
+
const err = new Error(message)
|
|
114
|
+
err.userFacing = true
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isObject = v => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
119
|
+
|
|
120
|
+
function trimmedString(value) {
|
|
121
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function int(value, fallback = null) {
|
|
125
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- profile validation ---------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// Only the fields TEST-PROFILE.md actually defines are validated. An absent
|
|
131
|
+
// profile is not malformed — it is the documented "no profile" case and must
|
|
132
|
+
// produce `hasProfile:false`, not a throw.
|
|
133
|
+
function validateProfile(profile) {
|
|
134
|
+
if (profile === null || profile === undefined) return
|
|
135
|
+
if (!isObject(profile)) fail('test profile must be a JSON object (see shared/TEST-PROFILE.md)')
|
|
136
|
+
|
|
137
|
+
if (profile.version !== undefined && !Number.isInteger(profile.version)) {
|
|
138
|
+
fail('test profile "version" must be an integer')
|
|
139
|
+
}
|
|
140
|
+
if (profile.notes !== undefined && !Array.isArray(profile.notes)) {
|
|
141
|
+
fail('test profile "notes" must be an array')
|
|
142
|
+
}
|
|
143
|
+
if (profile.known_flaky !== undefined && !Array.isArray(profile.known_flaky)) {
|
|
144
|
+
fail('test profile "known_flaky" must be an array')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const section of ['unit', 'e2e', 'coverage', 'typecheck', 'lint']) {
|
|
148
|
+
const value = profile[section]
|
|
149
|
+
if (value === undefined || value === null) continue
|
|
150
|
+
if (!isObject(value)) fail(`test profile "${section}" must be an object`)
|
|
151
|
+
if (
|
|
152
|
+
value.command !== undefined &&
|
|
153
|
+
value.command !== null &&
|
|
154
|
+
typeof value.command !== 'string'
|
|
155
|
+
) {
|
|
156
|
+
fail(`test profile "${section}.command" must be a string or null`)
|
|
157
|
+
}
|
|
158
|
+
if (value.timeout_ms !== undefined && value.timeout_ms !== null && !Number.isInteger(value.timeout_ms)) {
|
|
159
|
+
fail(`test profile "${section}.timeout_ms" must be an integer`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [section, field] of [['e2e', 'enabled'], ['coverage', 'enabled']]) {
|
|
164
|
+
const value = profile[section]
|
|
165
|
+
if (isObject(value) && value[field] !== undefined && typeof value[field] !== 'boolean') {
|
|
166
|
+
fail(`test profile "${section}.${field}" must be a boolean`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- planning -------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function step(kind, command, extra = {}) {
|
|
174
|
+
return {
|
|
175
|
+
id: kind,
|
|
176
|
+
kind,
|
|
177
|
+
command,
|
|
178
|
+
blocking: BLOCKING_KINDS.includes(kind),
|
|
179
|
+
...extra
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decide which verification commands should run for this change.
|
|
185
|
+
*
|
|
186
|
+
* Every command comes from the profile or the caller **verbatim** — this
|
|
187
|
+
* function never synthesizes one. `typecheck` and `lint` have their own
|
|
188
|
+
* TEST-PROFILE.md sections, discovered once by `/interlock:fix-tests` the same
|
|
189
|
+
* way `unit` is, and a caller may override either through `opts`. When neither
|
|
190
|
+
* supplies one the step is skipped with a reason rather than guessed at: there
|
|
191
|
+
* is no third branch where a command gets invented.
|
|
192
|
+
*
|
|
193
|
+
* @param {object|null} profile parsed `.claude/testing/profile.json`, or null
|
|
194
|
+
* @param {{
|
|
195
|
+
* e2e?: boolean, // caller passed --e2e (default false)
|
|
196
|
+
* coverage?: boolean, // default true; advisory either way
|
|
197
|
+
* lint?: boolean, // default true
|
|
198
|
+
* typecheckCommand?: string|null,
|
|
199
|
+
* lintCommand?: string|null,
|
|
200
|
+
* elapsedMs?: number, // time already spent verifying this run
|
|
201
|
+
* budgetMs?: number, // default LIMITS.interWaveVerifyBudgetMs
|
|
202
|
+
* preExistingFailures?: boolean // suite was already red before this change
|
|
203
|
+
* context?: string, // inter-wave | final — inter-wave omits e2e/coverage by default
|
|
204
|
+
* changed?: string[] // repo-relative paths the last wave touched
|
|
205
|
+
* }} [opts]
|
|
206
|
+
* @returns {{steps: Array, skipped: Array<{kind: string, reason: string, detail?: string}>,
|
|
207
|
+
* hasProfile: boolean, budgetMs: number, elapsedMs: number, budgetExceeded: boolean,
|
|
208
|
+
* banners: string[]}}
|
|
209
|
+
*/
|
|
210
|
+
export function planVerification(profile, opts = {}) {
|
|
211
|
+
validateProfile(profile)
|
|
212
|
+
if (!isObject(opts)) fail('verify options must be an object')
|
|
213
|
+
|
|
214
|
+
const hasProfile = isObject(profile)
|
|
215
|
+
const unit = hasProfile && isObject(profile.unit) ? profile.unit : {}
|
|
216
|
+
const e2e = hasProfile && isObject(profile.e2e) ? profile.e2e : {}
|
|
217
|
+
const coverage = hasProfile && isObject(profile.coverage) ? profile.coverage : {}
|
|
218
|
+
const typecheckSection = hasProfile && isObject(profile.typecheck) ? profile.typecheck : {}
|
|
219
|
+
const lintSection = hasProfile && isObject(profile.lint) ? profile.lint : {}
|
|
220
|
+
|
|
221
|
+
const budgetMs = int(opts.budgetMs, null) ?? LIMITS.interWaveVerifyBudgetMs
|
|
222
|
+
const elapsedMs = int(opts.elapsedMs, 0) ?? 0
|
|
223
|
+
const budgetExceeded = elapsedMs >= budgetMs
|
|
224
|
+
|
|
225
|
+
if (opts.context !== undefined && opts.context !== null) {
|
|
226
|
+
if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, opts.context)) {
|
|
227
|
+
fail(
|
|
228
|
+
`verify context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${opts.context}")`
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const interWave = opts.context === 'inter-wave'
|
|
234
|
+
const wantE2e = opts.e2e === true
|
|
235
|
+
const wantCoverage = interWave ? opts.coverage === true : opts.coverage !== false
|
|
236
|
+
const wantLint = opts.lint !== false
|
|
237
|
+
|
|
238
|
+
const steps = []
|
|
239
|
+
const skipped = []
|
|
240
|
+
const skip = (kind, reason, detail) => {
|
|
241
|
+
const entry = { kind, reason }
|
|
242
|
+
if (detail) entry.detail = detail
|
|
243
|
+
skipped.push(entry)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const changed = Array.isArray(opts.changed)
|
|
247
|
+
? opts.changed.filter(p => typeof p === 'string' && p.trim())
|
|
248
|
+
: null
|
|
249
|
+
if (changed && changed.length && changed.every(p => isDocsPath(p))) {
|
|
250
|
+
for (const kind of VERIFY_KINDS) {
|
|
251
|
+
skip(kind, SKIP_REASONS.DOCS_ONLY, `${changed.length} changed path(s) are documentation`)
|
|
252
|
+
}
|
|
253
|
+
const banners = skipped.map(s => BANNERS.skipped(s.reason))
|
|
254
|
+
if (!hasProfile) banners.unshift(BANNERS.noProfile)
|
|
255
|
+
return { steps, skipped, hasProfile, budgetMs, elapsedMs, budgetExceeded, banners }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The reason a profile-derived step is unavailable: never having a profile is
|
|
259
|
+
// a different failure from having one with nothing in it, and the caller
|
|
260
|
+
// prints them differently.
|
|
261
|
+
const missingReason = hasProfile ? SKIP_REASONS.NO_COMMAND : SKIP_REASONS.NO_PROFILE
|
|
262
|
+
|
|
263
|
+
// --- typecheck: profile- or caller-supplied, and the one thing that survives
|
|
264
|
+
// the budget.
|
|
265
|
+
const typecheckCommand =
|
|
266
|
+
trimmedString(opts.typecheckCommand) || trimmedString(typecheckSection.command)
|
|
267
|
+
if (typecheckCommand) {
|
|
268
|
+
steps.push(step('typecheck', typecheckCommand, { cwd: trimmedString(typecheckSection.cwd) || '.' }))
|
|
269
|
+
} else {
|
|
270
|
+
skip('typecheck', SKIP_REASONS.NO_COMMAND, 'no typecheck command in the profile or from the caller')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- unit
|
|
274
|
+
const unitCommand = trimmedString(unit.command)
|
|
275
|
+
if (budgetExceeded) {
|
|
276
|
+
skip('unit', SKIP_REASONS.BUDGET_EXCEEDED, `${elapsedMs}ms of ${budgetMs}ms budget spent`)
|
|
277
|
+
} else if (opts.preExistingFailures === true) {
|
|
278
|
+
skip('unit', SKIP_REASONS.PRE_EXISTING, 'the suite was already red before this change')
|
|
279
|
+
} else if (!unitCommand) {
|
|
280
|
+
skip('unit', missingReason, hasProfile ? 'profile has no unit.command' : undefined)
|
|
281
|
+
} else {
|
|
282
|
+
steps.push(
|
|
283
|
+
step('unit', unitCommand, {
|
|
284
|
+
cwd: trimmedString(unit.cwd) || '.',
|
|
285
|
+
timeoutMs: int(unit.timeout_ms, null),
|
|
286
|
+
filterSyntax: trimmedString(unit.filter_syntax),
|
|
287
|
+
singleFile: trimmedString(unit.single_file),
|
|
288
|
+
prerequisites: Array.isArray(unit.prerequisites) ? [...unit.prerequisites] : [],
|
|
289
|
+
env: isObject(unit.env) ? { ...unit.env } : {}
|
|
290
|
+
})
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- lint
|
|
295
|
+
const lintCommand = trimmedString(opts.lintCommand) || trimmedString(lintSection.command)
|
|
296
|
+
if (!wantLint) skip('lint', SKIP_REASONS.CALLER_OPTED_OUT)
|
|
297
|
+
else if (budgetExceeded) {
|
|
298
|
+
skip('lint', SKIP_REASONS.BUDGET_EXCEEDED, `${elapsedMs}ms of ${budgetMs}ms budget spent`)
|
|
299
|
+
} else if (!lintCommand) {
|
|
300
|
+
skip('lint', SKIP_REASONS.NO_COMMAND, 'no lint command in the profile or from the caller')
|
|
301
|
+
} else {
|
|
302
|
+
steps.push(step('lint', lintCommand, { cwd: trimmedString(lintSection.cwd) || '.' }))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- coverage: advisory, always. Never blocking, whatever the number says.
|
|
306
|
+
const coverageCommand = trimmedString(coverage.command)
|
|
307
|
+
if (!wantCoverage) skip('coverage', SKIP_REASONS.CALLER_OPTED_OUT)
|
|
308
|
+
else if (budgetExceeded) {
|
|
309
|
+
skip('coverage', SKIP_REASONS.BUDGET_EXCEEDED, `${elapsedMs}ms of ${budgetMs}ms budget spent`)
|
|
310
|
+
} else if (!hasProfile) skip('coverage', SKIP_REASONS.NO_PROFILE)
|
|
311
|
+
else if (coverage.enabled === false || !coverageCommand) {
|
|
312
|
+
skip('coverage', SKIP_REASONS.COVERAGE_NOT_CONFIGURED)
|
|
313
|
+
} else {
|
|
314
|
+
steps.push(
|
|
315
|
+
step('coverage', coverageCommand, {
|
|
316
|
+
reportPath: trimmedString(coverage.report_path),
|
|
317
|
+
format: trimmedString(coverage.format) || 'lcov',
|
|
318
|
+
advisory: true
|
|
319
|
+
})
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- e2e: opt-in, run, never repaired.
|
|
324
|
+
const e2eCommand = trimmedString(e2e.command)
|
|
325
|
+
if (!wantE2e) skip('e2e', SKIP_REASONS.E2E_NOT_REQUESTED)
|
|
326
|
+
else if (budgetExceeded) {
|
|
327
|
+
skip('e2e', SKIP_REASONS.BUDGET_EXCEEDED, `${elapsedMs}ms of ${budgetMs}ms budget spent`)
|
|
328
|
+
} else if (!hasProfile) skip('e2e', SKIP_REASONS.NO_PROFILE)
|
|
329
|
+
else if (e2e.enabled !== true) skip('e2e', SKIP_REASONS.E2E_DISABLED)
|
|
330
|
+
else if (!e2eCommand) skip('e2e', SKIP_REASONS.NO_COMMAND, 'profile has no e2e.command')
|
|
331
|
+
else {
|
|
332
|
+
steps.push(
|
|
333
|
+
step('e2e', e2eCommand, {
|
|
334
|
+
cwd: trimmedString(e2e.cwd) || '.',
|
|
335
|
+
timeoutMs: int(e2e.timeout_ms, null),
|
|
336
|
+
prerequisites: Array.isArray(e2e.prerequisites) ? [...e2e.prerequisites] : [],
|
|
337
|
+
env: isObject(e2e.env) ? { ...e2e.env } : {},
|
|
338
|
+
repairable: false
|
|
339
|
+
})
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
steps.sort((a, b) => VERIFY_KINDS.indexOf(a.kind) - VERIFY_KINDS.indexOf(b.kind))
|
|
344
|
+
|
|
345
|
+
const banners = []
|
|
346
|
+
if (!hasProfile) banners.push(BANNERS.noProfile)
|
|
347
|
+
for (const s of skipped) banners.push(BANNERS.skipped(s.reason))
|
|
348
|
+
|
|
349
|
+
return { steps, skipped, hasProfile, budgetMs, elapsedMs, budgetExceeded, banners }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- failure signatures ---------------------------------------------------
|
|
353
|
+
|
|
354
|
+
const ANSI = /\u001b\[[0-9;]*m/g
|
|
355
|
+
const SOURCE_EXT =
|
|
356
|
+
'js|jsx|mjs|cjs|ts|tsx|mts|cts|py|go|rs|rb|java|kt|kts|swift|php|cs|scala|vue|svelte|json|ya?ml|html|css|snap|txt|log'
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Reduce one failure line to a root-cause signature by erasing the parts that
|
|
360
|
+
* differ between two occurrences of the *same* cause: file paths, line/column
|
|
361
|
+
* numbers, memory addresses, hashes, and timings. Everything else — the error
|
|
362
|
+
* class, the message, the expected/actual values — is signal and survives.
|
|
363
|
+
* @param {string} text
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
export function normalizeFailureSignature(text) {
|
|
367
|
+
let s = String(text == null ? '' : text)
|
|
368
|
+
s = s.replace(ANSI, '')
|
|
369
|
+
s = s.replace(/\\/g, '/')
|
|
370
|
+
s = s.replace(/[\r\n]+/g, ' ')
|
|
371
|
+
// Common runner markers so "✕ foo" and "FAIL foo" do not fork a cluster.
|
|
372
|
+
s = s.replace(/(^|\s)(?:[✕✗×✘●x])\s+/g, '$1')
|
|
373
|
+
// Runner prefixes only — never the error class, which is signal.
|
|
374
|
+
s = s.replace(/(^|\s)(?:FAIL(?:ED)?|not ok\s*\d*)[:\s]+/g, '$1')
|
|
375
|
+
// Addresses and hashes before anything else — they can look like numbers.
|
|
376
|
+
s = s.replace(/\b0x[0-9a-fA-F]+\b/g, '<addr>')
|
|
377
|
+
s = s.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>')
|
|
378
|
+
s = s.replace(/\b[0-9a-f]{7,}\b/gi, '<hash>')
|
|
379
|
+
// Timings.
|
|
380
|
+
s = s.replace(/\b\d+(?:[.,]\d+)?\s?(?:ms|µs|us|ns|sec|secs|seconds?|s)\b/gi, '<time>')
|
|
381
|
+
// Paths that contain a separator, with optional :line:col.
|
|
382
|
+
s = s.replace(
|
|
383
|
+
/(?:[A-Za-z]:)?(?:\.{0,2}\/)?(?:[\w.@+~-]+\/)+[\w.@+~-]+(?::\d+(?::\d+)?)?/g,
|
|
384
|
+
'<path>'
|
|
385
|
+
)
|
|
386
|
+
// Bare filenames with a known extension, with optional :line:col.
|
|
387
|
+
s = s.replace(new RegExp(`\\b[\\w.@+~-]+\\.(?:${SOURCE_EXT})\\b(?::\\d+(?::\\d+)?)?`, 'gi'), '<path>')
|
|
388
|
+
// Whatever line/column references survived on their own.
|
|
389
|
+
s = s.replace(/:\d+:\d+\b/g, ':<line>')
|
|
390
|
+
s = s.replace(/\b(line|row|col|column)\s+\d+/gi, '$1 <n>')
|
|
391
|
+
s = s.replace(/\((\d+):(\d+)\)/g, '(<line>)')
|
|
392
|
+
return s.replace(/\s+/g, ' ').trim()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function failureText(failure) {
|
|
396
|
+
if (typeof failure === 'string') return failure
|
|
397
|
+
if (!isObject(failure)) return ''
|
|
398
|
+
return (
|
|
399
|
+
trimmedString(failure.message) ||
|
|
400
|
+
trimmedString(failure.error) ||
|
|
401
|
+
trimmedString(failure.text) ||
|
|
402
|
+
trimmedString(failure.title) ||
|
|
403
|
+
trimmedString(failure.test) ||
|
|
404
|
+
trimmedString(failure.name) ||
|
|
405
|
+
''
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function failureTest(failure) {
|
|
410
|
+
if (typeof failure === 'string') return failure
|
|
411
|
+
if (!isObject(failure)) return ''
|
|
412
|
+
return trimmedString(failure.test) || trimmedString(failure.name) || trimmedString(failure.title) || failureText(failure)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Group failures by normalized error signature so a shared root cause is fixed
|
|
417
|
+
* once instead of N times. Ordered by cluster size descending, so the caller
|
|
418
|
+
* can spend its repair budget on the biggest cause first.
|
|
419
|
+
*
|
|
420
|
+
* Accepts raw failure lines (strings) or `{test, message, file, line}` objects
|
|
421
|
+
* — both are things an agent can honestly transcribe from runner output.
|
|
422
|
+
*
|
|
423
|
+
* @param {Array<string|object>} failures
|
|
424
|
+
* @returns {Array<{signature: string, count: number, tests: string[], files: string[],
|
|
425
|
+
* representative: string}>}
|
|
426
|
+
*/
|
|
427
|
+
export function clusterFailures(failures) {
|
|
428
|
+
if (failures === undefined || failures === null) return []
|
|
429
|
+
if (!Array.isArray(failures)) fail('failures must be an array of strings or objects')
|
|
430
|
+
|
|
431
|
+
const map = new Map()
|
|
432
|
+
for (const failure of failures) {
|
|
433
|
+
const text = failureText(failure)
|
|
434
|
+
const signature = normalizeFailureSignature(text)
|
|
435
|
+
if (!signature) continue
|
|
436
|
+
if (!map.has(signature)) {
|
|
437
|
+
map.set(signature, { signature, count: 0, tests: [], files: [], representative: text })
|
|
438
|
+
}
|
|
439
|
+
const cluster = map.get(signature)
|
|
440
|
+
cluster.count += 1
|
|
441
|
+
const test = failureTest(failure)
|
|
442
|
+
if (test && !cluster.tests.includes(test)) cluster.tests.push(test)
|
|
443
|
+
const file = isObject(failure) ? trimmedString(failure.file) : null
|
|
444
|
+
if (file && !cluster.files.includes(file)) cluster.files.push(file)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return [...map.values()].sort(
|
|
448
|
+
(a, b) => b.count - a.count || (a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0)
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- judging --------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Shape every `judge*` function consumes. An agent produces all of it by
|
|
456
|
+
* running the planned command and reading stdout:
|
|
457
|
+
*
|
|
458
|
+
* { kind, command, exitCode, ran?, total?, passed?, failed?, skipped?,
|
|
459
|
+
* failures?: Array<string|{test,message,file,line}>, durationMs? }
|
|
460
|
+
*
|
|
461
|
+
* `exitCode` is always available; the counts come from the runner's own summary
|
|
462
|
+
* line; `failures` are the failure lines copied verbatim. Nothing here requires
|
|
463
|
+
* structured output that no test runner emits.
|
|
464
|
+
*/
|
|
465
|
+
function validateResult(result, kind) {
|
|
466
|
+
if (!isObject(result)) fail(`${kind} result must be an object`)
|
|
467
|
+
if (result.ran === false) return
|
|
468
|
+
if (result.exitCode === undefined || result.exitCode === null) {
|
|
469
|
+
fail(`${kind} result must include a numeric exitCode (or ran:false)`)
|
|
470
|
+
}
|
|
471
|
+
if (!Number.isFinite(Number(result.exitCode))) {
|
|
472
|
+
fail(`${kind} result "exitCode" must be a number`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function countsOf(result) {
|
|
477
|
+
return {
|
|
478
|
+
total: int(result.total, null),
|
|
479
|
+
passed: int(result.passed, null),
|
|
480
|
+
failed: int(result.failed, null),
|
|
481
|
+
skipped: int(result.skipped, null)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// A suite that went green by shrinking is not green. Only honestly detectable
|
|
486
|
+
// when the caller supplies a baseline run: without one, we say so rather than
|
|
487
|
+
// pretend.
|
|
488
|
+
function detectWeakening(counts, baseline) {
|
|
489
|
+
if (!isObject(baseline)) return { weakened: null, check: 'unavailable' }
|
|
490
|
+
const base = countsOf(baseline)
|
|
491
|
+
if (base.total === null && base.skipped === null) return { weakened: null, check: 'unavailable' }
|
|
492
|
+
if (counts.total === null && counts.skipped === null) return { weakened: null, check: 'unavailable' }
|
|
493
|
+
|
|
494
|
+
if (base.total !== null && counts.total !== null && counts.total < base.total) {
|
|
495
|
+
return {
|
|
496
|
+
weakened: {
|
|
497
|
+
signal: 'total-decreased',
|
|
498
|
+
detail: `test count fell from ${base.total} to ${counts.total} — tests were removed, not fixed`
|
|
499
|
+
},
|
|
500
|
+
check: 'ok'
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (base.skipped !== null && counts.skipped !== null && counts.skipped > base.skipped) {
|
|
504
|
+
return {
|
|
505
|
+
weakened: {
|
|
506
|
+
signal: 'skipped-increased',
|
|
507
|
+
detail: `skipped tests rose from ${base.skipped} to ${counts.skipped} — failures were silenced, not fixed`
|
|
508
|
+
},
|
|
509
|
+
check: 'ok'
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { weakened: null, check: 'ok' }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Decide whether a unit result halts the run. A red unit suite is a hard halt,
|
|
517
|
+
* expressed as a boolean the caller cannot reinterpret.
|
|
518
|
+
*
|
|
519
|
+
* @param {object} result see the shape note above
|
|
520
|
+
* @param {{baseline?: object, preExistingFailures?: boolean|string[]}} [opts]
|
|
521
|
+
* `baseline` is the same shape, captured before the change — required for the
|
|
522
|
+
* weakened-suite check. `preExistingFailures` is `true` (everything was
|
|
523
|
+
* already red) or an array of signatures known to be red beforehand.
|
|
524
|
+
* @returns {{halt: boolean, reason: string, status: string, ran: boolean,
|
|
525
|
+
* exitCode: number|null, counts: object, clusters: Array, weakened: object|null,
|
|
526
|
+
* weakenedCheck: string, preExisting: boolean, signals: Array}}
|
|
527
|
+
*/
|
|
528
|
+
export function judgeUnitResult(result, opts = {}) {
|
|
529
|
+
validateResult(result, 'unit')
|
|
530
|
+
if (!isObject(opts)) fail('verify options must be an object')
|
|
531
|
+
|
|
532
|
+
const ran = result.ran !== false
|
|
533
|
+
const exitCode = ran ? Number(result.exitCode) : null
|
|
534
|
+
const counts = countsOf(result)
|
|
535
|
+
const clusters = clusterFailures(result.failures)
|
|
536
|
+
const signals = []
|
|
537
|
+
|
|
538
|
+
if (!ran) {
|
|
539
|
+
const reason = trimmedString(result.reason) || 'the unit command could not be executed'
|
|
540
|
+
return {
|
|
541
|
+
halt: true,
|
|
542
|
+
reason: `unit suite did not run — ${reason}`,
|
|
543
|
+
status: 'error',
|
|
544
|
+
ran: false,
|
|
545
|
+
exitCode: null,
|
|
546
|
+
counts,
|
|
547
|
+
clusters,
|
|
548
|
+
weakened: null,
|
|
549
|
+
weakenedCheck: 'unavailable',
|
|
550
|
+
preExisting: false,
|
|
551
|
+
signals: [{ level: 'halt', kind: 'unit', message: `unit suite did not run — ${reason}` }]
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const failedCount = counts.failed === null ? null : counts.failed
|
|
556
|
+
const red = exitCode !== 0 || (failedCount !== null && failedCount > 0)
|
|
557
|
+
|
|
558
|
+
const { weakened, check } = detectWeakening(counts, opts.baseline)
|
|
559
|
+
if (weakened) {
|
|
560
|
+
signals.push({ level: 'halt', kind: 'unit', message: `suite was weakened: ${weakened.detail}` })
|
|
561
|
+
}
|
|
562
|
+
if (check === 'unavailable') {
|
|
563
|
+
signals.push({
|
|
564
|
+
level: 'info',
|
|
565
|
+
kind: 'unit',
|
|
566
|
+
message: 'weakened-suite check unavailable — no baseline counts were supplied'
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Excusing pre-existing failures: `true` excuses everything, an array of
|
|
571
|
+
// signatures excuses only the clusters that match one of them.
|
|
572
|
+
let preExisting = false
|
|
573
|
+
if (red) {
|
|
574
|
+
if (opts.preExistingFailures === true) preExisting = true
|
|
575
|
+
else if (Array.isArray(opts.preExistingFailures) && opts.preExistingFailures.length) {
|
|
576
|
+
const known = new Set(opts.preExistingFailures.map(normalizeFailureSignature))
|
|
577
|
+
preExisting = clusters.length > 0 && clusters.every(c => known.has(c.signature))
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// A weakened suite outranks everything: it is the one way a "green" result is
|
|
582
|
+
// still a halt.
|
|
583
|
+
if (weakened) {
|
|
584
|
+
return {
|
|
585
|
+
halt: true,
|
|
586
|
+
reason: `unit suite was weakened (${weakened.signal}) — ${weakened.detail}`,
|
|
587
|
+
status: 'weakened',
|
|
588
|
+
ran: true,
|
|
589
|
+
exitCode,
|
|
590
|
+
counts,
|
|
591
|
+
clusters,
|
|
592
|
+
weakened,
|
|
593
|
+
weakenedCheck: check,
|
|
594
|
+
preExisting,
|
|
595
|
+
signals
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!red) {
|
|
600
|
+
return {
|
|
601
|
+
halt: false,
|
|
602
|
+
reason: 'unit suite is green',
|
|
603
|
+
status: 'green',
|
|
604
|
+
ran: true,
|
|
605
|
+
exitCode,
|
|
606
|
+
counts,
|
|
607
|
+
clusters,
|
|
608
|
+
weakened: null,
|
|
609
|
+
weakenedCheck: check,
|
|
610
|
+
preExisting: false,
|
|
611
|
+
signals
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const failedLabel = failedCount === null ? `exit ${exitCode}` : `${failedCount} failing`
|
|
616
|
+
if (preExisting) {
|
|
617
|
+
const message = `unit failures are pre-existing (${failedLabel}) — not caused by this change`
|
|
618
|
+
signals.push({ level: 'warn', kind: 'unit', reason: SKIP_REASONS.PRE_EXISTING, message })
|
|
619
|
+
return {
|
|
620
|
+
halt: false,
|
|
621
|
+
reason: SKIP_REASONS.PRE_EXISTING,
|
|
622
|
+
status: 'red-pre-existing',
|
|
623
|
+
ran: true,
|
|
624
|
+
exitCode,
|
|
625
|
+
counts,
|
|
626
|
+
clusters,
|
|
627
|
+
weakened: null,
|
|
628
|
+
weakenedCheck: check,
|
|
629
|
+
preExisting: true,
|
|
630
|
+
signals
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const reason = `unit suite is red (${failedLabel}, ${clusters.length} root-cause cluster(s))`
|
|
635
|
+
signals.push({ level: 'halt', kind: 'unit', message: reason })
|
|
636
|
+
return {
|
|
637
|
+
halt: true,
|
|
638
|
+
reason,
|
|
639
|
+
status: 'red',
|
|
640
|
+
ran: true,
|
|
641
|
+
exitCode,
|
|
642
|
+
counts,
|
|
643
|
+
clusters,
|
|
644
|
+
weakened: null,
|
|
645
|
+
weakenedCheck: check,
|
|
646
|
+
preExisting: false,
|
|
647
|
+
signals
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Judge an e2e result. Structurally incapable of halting: e2e is run, never
|
|
653
|
+
* repaired, and a red e2e is a banner, not a gate.
|
|
654
|
+
* @param {object} result see the shape note above
|
|
655
|
+
* @returns {{halt: false, blocking: false, passed: boolean, status: string,
|
|
656
|
+
* reason: string, repairable: false, clusters: Array, signals: Array}}
|
|
657
|
+
*/
|
|
658
|
+
export function judgeE2eResult(result, opts = {}) {
|
|
659
|
+
validateResult(result, 'e2e')
|
|
660
|
+
if (!isObject(opts)) fail('verify options must be an object')
|
|
661
|
+
|
|
662
|
+
const ran = result.ran !== false
|
|
663
|
+
const counts = countsOf(result)
|
|
664
|
+
const exitCode = ran ? Number(result.exitCode) : null
|
|
665
|
+
const failedCount = counts.failed
|
|
666
|
+
const passed = ran && exitCode === 0 && (failedCount === null || failedCount === 0)
|
|
667
|
+
const clusters = clusterFailures(result.failures)
|
|
668
|
+
const signals = []
|
|
669
|
+
|
|
670
|
+
let status
|
|
671
|
+
let reason
|
|
672
|
+
if (!ran) {
|
|
673
|
+
status = 'error'
|
|
674
|
+
reason = trimmedString(result.reason) || 'the e2e command could not be executed'
|
|
675
|
+
} else if (passed) {
|
|
676
|
+
status = 'green'
|
|
677
|
+
reason = 'e2e suite is green'
|
|
678
|
+
} else {
|
|
679
|
+
status = 'red'
|
|
680
|
+
reason = failedCount === null ? `exit ${exitCode}` : `${failedCount} failing`
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (!passed) {
|
|
684
|
+
signals.push({
|
|
685
|
+
level: 'warn',
|
|
686
|
+
kind: 'e2e',
|
|
687
|
+
message: BANNERS.e2eFailed(reason),
|
|
688
|
+
banner: BANNERS.e2eFailed(reason)
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
halt: false,
|
|
694
|
+
blocking: false,
|
|
695
|
+
passed,
|
|
696
|
+
status,
|
|
697
|
+
reason,
|
|
698
|
+
repairable: false,
|
|
699
|
+
counts,
|
|
700
|
+
clusters,
|
|
701
|
+
signals
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Judge a coverage result. Advisory by construction — there is no return path
|
|
707
|
+
* that sets `halt` or `blocking`, however bad the number is.
|
|
708
|
+
* @param {object} result `{ exitCode, ran?, pct?, changedLines?, coveredLines?, perFile? }`
|
|
709
|
+
* @returns {{halt: false, blocking: false, advisory: true, ran: boolean,
|
|
710
|
+
* pct: number|null, warnings: string[], signals: Array}}
|
|
711
|
+
*/
|
|
712
|
+
export function judgeCoverageResult(result, opts = {}) {
|
|
713
|
+
if (!isObject(result)) fail('coverage result must be an object')
|
|
714
|
+
if (!isObject(opts)) fail('verify options must be an object')
|
|
715
|
+
|
|
716
|
+
const ran = result.ran !== false && result.exitCode !== undefined && Number(result.exitCode) === 0
|
|
717
|
+
const perFile = Array.isArray(result.perFile) ? result.perFile.filter(isObject) : []
|
|
718
|
+
|
|
719
|
+
let pct = typeof result.pct === 'number' && Number.isFinite(result.pct) ? result.pct : null
|
|
720
|
+
const changedLines = int(result.changedLines, null)
|
|
721
|
+
const coveredLines = int(result.coveredLines, null)
|
|
722
|
+
if (pct === null && changedLines !== null && coveredLines !== null && changedLines > 0) {
|
|
723
|
+
pct = Math.round((coveredLines / changedLines) * 1000) / 10
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const warnings = perFile
|
|
727
|
+
.filter(row => int(row.coveredLines, 0) === 0 && int(row.changedLines, 0) > 0)
|
|
728
|
+
.map(row => `${row.path}: ${int(row.changedLines, 0)} changed line(s), 0 covered`)
|
|
729
|
+
|
|
730
|
+
const signals = warnings.map(message => ({ level: 'info', kind: 'coverage', message }))
|
|
731
|
+
if (!ran) {
|
|
732
|
+
signals.push({
|
|
733
|
+
level: 'info',
|
|
734
|
+
kind: 'coverage',
|
|
735
|
+
message: `coverage not measured — ${trimmedString(result.reason) || 'coverage command did not succeed'} (advisory)`
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
halt: false,
|
|
741
|
+
blocking: false,
|
|
742
|
+
advisory: true,
|
|
743
|
+
ran,
|
|
744
|
+
pct,
|
|
745
|
+
changedLines,
|
|
746
|
+
coveredLines,
|
|
747
|
+
perFile,
|
|
748
|
+
warnings,
|
|
749
|
+
signals
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Roll a plan plus the results an agent reported back into one verdict.
|
|
755
|
+
* @param {object} plan output of `planVerification`
|
|
756
|
+
* @param {Array<object>} results one entry per executed step, each carrying `kind`
|
|
757
|
+
* @param {{context?: 'inter-wave'|'final', baseline?: object,
|
|
758
|
+
* preExistingFailures?: boolean|string[]}} [opts]
|
|
759
|
+
* `context` selects the halting set — see `VERIFY_CONTEXTS`. Defaults to
|
|
760
|
+
* `final`. An unrecognised value is an error rather than a default, because
|
|
761
|
+
* quietly taking the permissive branch on a typo is how a hard halt goes
|
|
762
|
+
* missing.
|
|
763
|
+
* @returns {{halt: boolean, reason: string|null, context: string, haltingKinds: string[],
|
|
764
|
+
* unit: object|null, e2e: object|null, coverage: object|null, signals: Array,
|
|
765
|
+
* banners: string[], skipped: Array}}
|
|
766
|
+
*/
|
|
767
|
+
export function judgeVerification(plan, results = [], opts = {}) {
|
|
768
|
+
// Both halves matter: `skipped` is iterated below, so a hand-written plan
|
|
769
|
+
// missing it would throw a raw TypeError instead of an actionable error.
|
|
770
|
+
if (!isObject(plan) || !Array.isArray(plan.steps) || !Array.isArray(plan.skipped)) {
|
|
771
|
+
fail('verification plan must come from planVerification (needs both "steps" and "skipped" arrays)')
|
|
772
|
+
}
|
|
773
|
+
if (!Array.isArray(results)) fail('verification results must be an array')
|
|
774
|
+
if (!isObject(opts)) fail('verify options must be an object')
|
|
775
|
+
|
|
776
|
+
const context = opts.context === undefined ? DEFAULT_VERIFY_CONTEXT : opts.context
|
|
777
|
+
if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
|
|
778
|
+
fail(`verify context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${context}")`)
|
|
779
|
+
}
|
|
780
|
+
const haltingKinds = VERIFY_CONTEXTS[context].haltingKinds
|
|
781
|
+
|
|
782
|
+
const byKind = new Map()
|
|
783
|
+
for (const r of results) {
|
|
784
|
+
if (!isObject(r)) fail('each verification result must be an object')
|
|
785
|
+
const kind = trimmedString(r.kind)
|
|
786
|
+
if (!kind || !VERIFY_KINDS.includes(kind)) {
|
|
787
|
+
fail(`verification result "kind" must be one of ${VERIFY_KINDS.join('|')}`)
|
|
788
|
+
}
|
|
789
|
+
byKind.set(kind, r)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const signals = []
|
|
793
|
+
const banners = []
|
|
794
|
+
if (!plan.hasProfile) banners.push(BANNERS.noProfile)
|
|
795
|
+
for (const s of plan.skipped) {
|
|
796
|
+
banners.push(BANNERS.skipped(s.reason))
|
|
797
|
+
signals.push({
|
|
798
|
+
level: 'info',
|
|
799
|
+
kind: s.kind,
|
|
800
|
+
reason: s.reason,
|
|
801
|
+
message: `${s.kind}: ${BANNERS.skipped(s.reason)}${s.detail ? ` (${s.detail})` : ''}`
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
let halt = false
|
|
806
|
+
let reason = null
|
|
807
|
+
|
|
808
|
+
// One expression covers both contexts: a blocking kind that failed halts if
|
|
809
|
+
// and only if this call site's halting set contains it, and is otherwise
|
|
810
|
+
// reported loudly enough that nobody mistakes the banner for silence.
|
|
811
|
+
const record = (kind, failed, detail, how = {}) => {
|
|
812
|
+
if (!failed) return
|
|
813
|
+
if (haltingKinds.includes(kind)) {
|
|
814
|
+
if (!how.alreadySignalled) signals.push({ level: 'halt', kind, message: detail })
|
|
815
|
+
if (!halt) {
|
|
816
|
+
halt = true
|
|
817
|
+
reason = detail
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
const text = how.banner || detail
|
|
822
|
+
signals.push({ level: 'warn', kind, message: text, banner: text })
|
|
823
|
+
banners.push(text)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const typecheck = byKind.get('typecheck')
|
|
827
|
+
if (typecheck) {
|
|
828
|
+
validateResult(typecheck, 'typecheck')
|
|
829
|
+
const ok = typecheck.ran !== false && Number(typecheck.exitCode) === 0
|
|
830
|
+
const detail = typecheck.ran === false
|
|
831
|
+
? trimmedString(typecheck.reason) || 'the typecheck command could not be executed'
|
|
832
|
+
: `exit ${Number(typecheck.exitCode)}`
|
|
833
|
+
record('typecheck', !ok, `typecheck failed (${detail})`, {
|
|
834
|
+
banner: BANNERS.typecheckFailed(detail)
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let unit = null
|
|
839
|
+
if (byKind.has('unit')) {
|
|
840
|
+
unit = judgeUnitResult(byKind.get('unit'), opts)
|
|
841
|
+
signals.push(...unit.signals)
|
|
842
|
+
// judgeUnitResult already emitted its own halt signal — do not double it.
|
|
843
|
+
record('unit', unit.halt, unit.reason, { alreadySignalled: true })
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const lint = byKind.get('lint')
|
|
847
|
+
if (lint && lint.ran !== false && Number(lint.exitCode) !== 0) {
|
|
848
|
+
signals.push({ level: 'warn', kind: 'lint', message: 'lint failed (non-blocking)' })
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
let coverage = null
|
|
852
|
+
if (byKind.has('coverage')) {
|
|
853
|
+
coverage = judgeCoverageResult(byKind.get('coverage'), opts)
|
|
854
|
+
signals.push(...coverage.signals)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let e2e = null
|
|
858
|
+
if (byKind.has('e2e')) {
|
|
859
|
+
e2e = judgeE2eResult(byKind.get('e2e'), opts)
|
|
860
|
+
signals.push(...e2e.signals)
|
|
861
|
+
for (const s of e2e.signals) if (s.banner) banners.push(s.banner)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
halt,
|
|
866
|
+
reason,
|
|
867
|
+
context,
|
|
868
|
+
haltingKinds: [...haltingKinds],
|
|
869
|
+
unit,
|
|
870
|
+
e2e,
|
|
871
|
+
coverage,
|
|
872
|
+
signals,
|
|
873
|
+
banners,
|
|
874
|
+
skipped: plan.skipped
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// --- oversized-result anti-swallow (add-ship-run-inspectability §3.3) ------
|
|
879
|
+
//
|
|
880
|
+
// This is deliberately NOT inside `judgeVerification` (design.md decision 3):
|
|
881
|
+
// the pure judge decides what a result *means*, this decides whether the
|
|
882
|
+
// result is even shaped the way spill was supposed to guarantee. A verify
|
|
883
|
+
// agent that pastes a spilled suite log into `cliStdout`, `detail`, or a
|
|
884
|
+
// `failures[]` entry instead of using the locator+preview `lib/spill.mjs`
|
|
885
|
+
// handed it has defeated the whole point of spilling; this is the check that
|
|
886
|
+
// catches it before the bytes reach a judgement (or a trajectory line).
|
|
887
|
+
//
|
|
888
|
+
// Fields checked are exactly the ones an agent could plausibly stuff a full
|
|
889
|
+
// suite log into: the free-text result fields plus every failure's text.
|
|
890
|
+
// `preview` itself is exempt — it is *allowed* to be long, that is what it is.
|
|
891
|
+
// Every field of a result that can carry free text. An incomplete list is an
|
|
892
|
+
// anti-swallow check with unguarded doors: `summary`, `message`, `error` and
|
|
893
|
+
// `notes` were all absent, so a whole red suite pasted into any of them passed
|
|
894
|
+
// straight through to judgement.
|
|
895
|
+
const RESULT_TEXT_FIELDS = [
|
|
896
|
+
'detail',
|
|
897
|
+
'cliStdout',
|
|
898
|
+
'reason',
|
|
899
|
+
'stdout',
|
|
900
|
+
'stderr',
|
|
901
|
+
'output',
|
|
902
|
+
'summary',
|
|
903
|
+
'message',
|
|
904
|
+
'error',
|
|
905
|
+
'notes'
|
|
906
|
+
]
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Check that no verify result field exceeds the preview budget. Call this
|
|
910
|
+
* before `judgeVerification` / `judgeUnitResult`, never after — an oversized
|
|
911
|
+
* field is the leak this check exists to catch, not a data point to judge.
|
|
912
|
+
*
|
|
913
|
+
* @param {Array<object>} results the same array `judgeVerification` takes
|
|
914
|
+
* @param {{maxChars?: number}} [opts] defaults to `LIMITS.verifyPreviewChars`
|
|
915
|
+
* @returns {{ok: boolean, violations: Array<{kind: string, field: string, length: number, limit: number}>}}
|
|
916
|
+
*/
|
|
917
|
+
export function checkResultFieldSizes(results, opts = {}) {
|
|
918
|
+
if (!Array.isArray(results)) fail('verification results must be an array')
|
|
919
|
+
const limit = Number.isInteger(opts.maxChars) && opts.maxChars > 0 ? opts.maxChars : LIMITS.verifyPreviewChars
|
|
920
|
+
|
|
921
|
+
const violations = []
|
|
922
|
+
for (const r of results) {
|
|
923
|
+
if (!isObject(r)) continue
|
|
924
|
+
const kind = trimmedString(r.kind) || '(unknown kind)'
|
|
925
|
+
|
|
926
|
+
for (const field of RESULT_TEXT_FIELDS) {
|
|
927
|
+
const value = r[field]
|
|
928
|
+
if (typeof value === 'string' && value.length > limit) {
|
|
929
|
+
violations.push({ kind, field, length: value.length, limit })
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (Array.isArray(r.failures)) {
|
|
934
|
+
r.failures.forEach((failure, i) => {
|
|
935
|
+
const text = failureText(failure)
|
|
936
|
+
if (text.length > limit) {
|
|
937
|
+
violations.push({ kind, field: `failures[${i}]`, length: text.length, limit })
|
|
938
|
+
}
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return { ok: violations.length === 0, violations }
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// --- repair budget --------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Decide what to do next against a red suite. Repair is bounded by
|
|
950
|
+
* `LIMITS.rootCauseIterations` across the whole run, because repairing by root
|
|
951
|
+
* cause is slow by design and an unbounded loop turns a ship into an open-ended
|
|
952
|
+
* debugging session.
|
|
953
|
+
*
|
|
954
|
+
* @param {{iterationsUsed?: number, clusters?: Array, halt?: boolean,
|
|
955
|
+
* weakened?: object|null, preExisting?: boolean, maxIterations?: number}} state
|
|
956
|
+
* @returns {{action: 'repair'|'halt'|'accept', reason: string,
|
|
957
|
+
* iterationsLeft: number, target: object|null}}
|
|
958
|
+
*/
|
|
959
|
+
export function nextRepairAction(state = {}) {
|
|
960
|
+
if (!isObject(state)) fail('repair state must be an object')
|
|
961
|
+
|
|
962
|
+
const max = int(state.maxIterations, null) ?? LIMITS.rootCauseIterations
|
|
963
|
+
const used = Math.max(0, int(state.iterationsUsed, 0) ?? 0)
|
|
964
|
+
const iterationsLeft = Math.max(0, max - used)
|
|
965
|
+
const clusters = Array.isArray(state.clusters) ? state.clusters : []
|
|
966
|
+
const target = clusters.length ? clusters[0] : null
|
|
967
|
+
|
|
968
|
+
// Weakening is never repairable by another iteration — the previous iteration
|
|
969
|
+
// is what caused it.
|
|
970
|
+
if (state.weakened) {
|
|
971
|
+
return {
|
|
972
|
+
action: 'halt',
|
|
973
|
+
reason: 'suite was weakened rather than fixed — revert the shrinking change',
|
|
974
|
+
iterationsLeft,
|
|
975
|
+
target: null
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (state.preExisting === true) {
|
|
980
|
+
return {
|
|
981
|
+
action: 'accept',
|
|
982
|
+
reason: SKIP_REASONS.PRE_EXISTING,
|
|
983
|
+
iterationsLeft,
|
|
984
|
+
target: null
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const stillRed = state.halt === true || clusters.length > 0
|
|
989
|
+
if (!stillRed) {
|
|
990
|
+
return { action: 'accept', reason: 'nothing left to repair', iterationsLeft, target: null }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (iterationsLeft <= 0) {
|
|
994
|
+
return {
|
|
995
|
+
action: 'halt',
|
|
996
|
+
reason: `root-cause repair budget exhausted (${max} iteration(s)) with ${clusters.length} cluster(s) unresolved`,
|
|
997
|
+
iterationsLeft: 0,
|
|
998
|
+
target: null
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
action: 'repair',
|
|
1004
|
+
reason: target
|
|
1005
|
+
? `repair the largest root cause first (${target.count} failure(s)): ${target.signature}`
|
|
1006
|
+
: 'repair the remaining failures by root cause',
|
|
1007
|
+
iterationsLeft,
|
|
1008
|
+
target
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// --- formatting -----------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
function formatPlanOutput(plan) {
|
|
1015
|
+
const lines = []
|
|
1016
|
+
lines.push(
|
|
1017
|
+
`verification plan: ${plan.steps.length} step(s), ${plan.skipped.length} skipped` +
|
|
1018
|
+
(plan.hasProfile ? '' : ' (no test profile)')
|
|
1019
|
+
)
|
|
1020
|
+
if (plan.budgetExceeded) {
|
|
1021
|
+
lines.push(` budget exceeded: ${plan.elapsedMs}ms of ${plan.budgetMs}ms — typecheck only`)
|
|
1022
|
+
}
|
|
1023
|
+
for (const s of plan.steps) {
|
|
1024
|
+
lines.push(` [${s.blocking ? 'blocking' : 'advisory'}] ${s.kind}: ${s.command}`)
|
|
1025
|
+
}
|
|
1026
|
+
for (const s of plan.skipped) {
|
|
1027
|
+
lines.push(` ${BANNERS.skipped(s.reason)} kind=${s.kind}${s.detail ? ` (${s.detail})` : ''}`)
|
|
1028
|
+
}
|
|
1029
|
+
if (!plan.hasProfile) lines.push(` ${BANNERS.noProfile}`)
|
|
1030
|
+
return lines.join('\n') + '\n'
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function formatVerdictOutput(verdict) {
|
|
1034
|
+
const lines = []
|
|
1035
|
+
const where = verdict.context ? ` [${verdict.context}]` : ''
|
|
1036
|
+
lines.push(
|
|
1037
|
+
verdict.halt
|
|
1038
|
+
? `VERIFY HALT${where} — ${verdict.reason}`
|
|
1039
|
+
: `VERIFY OK${where} — nothing blocking`
|
|
1040
|
+
)
|
|
1041
|
+
for (const s of verdict.signals) lines.push(` [${s.level}] ${s.kind}: ${s.message}`)
|
|
1042
|
+
return lines.join('\n') + '\n'
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function formatUnitOutput(judgement) {
|
|
1046
|
+
const lines = []
|
|
1047
|
+
lines.push(judgement.halt ? `UNIT HALT — ${judgement.reason}` : `UNIT OK — ${judgement.reason}`)
|
|
1048
|
+
const { total, passed, failed, skipped } = judgement.counts
|
|
1049
|
+
if (total !== null || failed !== null) {
|
|
1050
|
+
lines.push(` total=${total ?? '?'} passed=${passed ?? '?'} failed=${failed ?? '?'} skipped=${skipped ?? '?'}`)
|
|
1051
|
+
}
|
|
1052
|
+
for (const c of judgement.clusters) {
|
|
1053
|
+
lines.push(` cluster x${c.count}: ${c.signature}`)
|
|
1054
|
+
}
|
|
1055
|
+
if (judgement.weakenedCheck === 'unavailable') {
|
|
1056
|
+
lines.push(' weakened-suite check unavailable (no baseline counts supplied)')
|
|
1057
|
+
}
|
|
1058
|
+
return lines.join('\n') + '\n'
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Human-readable rendering of a plan, a unit judgement, or a full verdict.
|
|
1063
|
+
* @param {object} value output of `planVerification`, `judgeUnitResult`, or `judgeVerification`
|
|
1064
|
+
*/
|
|
1065
|
+
export function formatVerification(value) {
|
|
1066
|
+
if (!isObject(value)) fail('nothing to format')
|
|
1067
|
+
if (Array.isArray(value.steps)) return formatPlanOutput(value)
|
|
1068
|
+
if (Array.isArray(value.banners)) return formatVerdictOutput(value)
|
|
1069
|
+
if (Array.isArray(value.clusters)) return formatUnitOutput(value)
|
|
1070
|
+
if (typeof value.action === 'string') {
|
|
1071
|
+
return `${value.action.toUpperCase()} — ${value.reason} (${value.iterationsLeft} iteration(s) left)\n`
|
|
1072
|
+
}
|
|
1073
|
+
fail('formatVerification expects a plan, a judgement, or a repair action')
|
|
1074
|
+
}
|