@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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. 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
+ }