@renzrollon/interlock 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +12 -1
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +5 -6
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +20 -23
  6. package/hooks/notify-option.mjs +69 -0
  7. package/hooks/preflight.mjs +6 -42
  8. package/lib/conformance.mjs +75 -0
  9. package/lib/coverage.mjs +387 -0
  10. package/lib/doctor.mjs +17 -13
  11. package/lib/drift.mjs +36 -12
  12. package/lib/findings.mjs +40 -3
  13. package/lib/graph/extract/openspec.mjs +6 -15
  14. package/lib/limits.mjs +78 -1
  15. package/lib/merge-confidence.mjs +1302 -0
  16. package/lib/merge-evidence.mjs +527 -0
  17. package/lib/notify.mjs +10 -20
  18. package/lib/outcomes.mjs +26 -5
  19. package/lib/pipeline.mjs +380 -0
  20. package/lib/prompts/dimensions.mjs +24 -2
  21. package/lib/prompts/domain-context.mjs +271 -0
  22. package/lib/prompts/handoff.mjs +32 -1
  23. package/lib/prompts/index.mjs +57 -0
  24. package/lib/prompts/remediate.mjs +29 -5
  25. package/lib/prompts/review.mjs +21 -7
  26. package/lib/prompts/schemas.mjs +2 -0
  27. package/lib/ready.mjs +13 -1
  28. package/lib/receipt.mjs +72 -2
  29. package/lib/review-core.mjs +224 -21
  30. package/lib/risk.mjs +8 -0
  31. package/lib/run-log.mjs +44 -0
  32. package/lib/run.mjs +859 -27
  33. package/lib/spec-audit.mjs +232 -0
  34. package/lib/spec-coverage.mjs +105 -0
  35. package/lib/spec-text.mjs +257 -0
  36. package/lib/verify.mjs +17 -4
  37. package/package.json +1 -1
  38. package/shared/SESSION-SHAPES.md +1 -1
  39. package/shared/TEST-PROFILE.md +39 -2
  40. package/skills/mr/SKILL.md +161 -11
  41. package/skills/review-code/REVIEW.template.md +24 -6
  42. package/skills/review-code/SKILL.md +16 -6
  43. package/skills/review-code/dimensions/domain.md +21 -0
@@ -0,0 +1,1302 @@
1
+ // Merge confidence: one answer to "can this be merged?".
2
+ //
3
+ // After a ship run the only machine facts about mergeability were "no blocker
4
+ // survived" and "the unit suite is green". Everything else a merge decision
5
+ // needs was computed somewhere and never combined — surviving warnings, the
6
+ // risk class over the paths actually touched, skipped verifications,
7
+ // unconfirmed conformance scenarios, open ledger rows, the diff's size, the
8
+ // platform's own checks — and the one surface that pronounced on it was prose
9
+ // a model re-argued on every run. This module combines them, in code.
10
+ //
11
+ // The output is a three-way verdict a platform rule can branch on (MERGE,
12
+ // HUMAN, BLOCK) and a bounded score a person can sort by:
13
+ //
14
+ // - THE WORST BAND WINS. Each gating signal reads green, amber, red or
15
+ // unobserved; the verdict is BLOCK on any red, MERGE only when every signal
16
+ // is green, HUMAN otherwise. Unobserved ranks as amber. No count of greens
17
+ // lifts a single amber or red — averaging is how a critical signal gets
18
+ // diluted by volume, which `lib/risk.mjs` already refuses.
19
+ // - A FLAG LOWERS THE SCORE AND NEVER MOVES A BAND. "No e2e suite exists
20
+ // here" is worth knowing and worth a point, and is not a reason to stop.
21
+ // - THE SCORE IS BOUNDED BY THE WORST BAND. Its range comes from
22
+ // `MERGE_BANDS.ceiling`; the share of gating signals that are green with no
23
+ // flag positions it inside the range, so a red can never outscore an amber
24
+ // and two changes in one band still sort.
25
+ //
26
+ // Signals live in one frozen registry. The formula (`combineSignals`) never
27
+ // names a signal, so adding one is one registry entry and one fixture. Every
28
+ // registered gating signal has a producer, so a clean, reviewed, verified change
29
+ // can read MERGE; a signal whose fact the caller did not gather reads
30
+ // unobserved and says so, never green by default.
31
+ //
32
+ // Every threshold is read from `MERGE_BANDS` (lib/limits.mjs) and cited by its
33
+ // published name in the reason that applies it. A threshold restated here as a
34
+ // literal is a test failure.
35
+ //
36
+ // Pure: no fs, no git, no network, no agent, no clock. The CLI gathers every
37
+ // fact (`interlock merge-confidence`) and hands this module one plain object.
38
+
39
+ import { MERGE_BANDS } from './limits.mjs'
40
+ import { classifyRisk, canonicalizePath, MERGE_ALLOWED_CLASSES } from './risk.mjs'
41
+ import { BLOCKING_KINDS, SKIP_REASONS } from './verify.mjs'
42
+ import { normalizeSeverity, SEVERITIES } from './findings.mjs'
43
+ import { pathExcludedBy } from './review-core.mjs'
44
+ import { PIPELINE_STATES } from './pipeline.mjs'
45
+
46
+ /** The four bands a gating signal may read. */
47
+ const BANDS = Object.freeze(['green', 'amber', 'red', 'unobserved'])
48
+
49
+ // Worse is later. Used both for the worst band over signals and, inside one
50
+ // signal, to take the worse of two facts: there amber outranks unobserved,
51
+ // because a stated fact is worse news than a missing one.
52
+ const SEVERITY_ORDER = Object.freeze(['green', 'unobserved', 'amber', 'red'])
53
+ const rank = band => SEVERITY_ORDER.indexOf(band)
54
+
55
+ /** What would lift a non-green signal: a person, more evidence, or a fix. */
56
+ const KINDS = Object.freeze(['person', 'evidence', 'finding'])
57
+ // When two facts at one band disagree on what would lift them, the one that
58
+ // no evidence can lift is the one to report.
59
+ const KIND_PRIORITY = Object.freeze(['finding', 'person', 'evidence'])
60
+ const KIND_LABELS = Object.freeze({
61
+ finding: 'needs a fix',
62
+ person: 'needs a person',
63
+ evidence: 'needs more evidence'
64
+ })
65
+
66
+ /**
67
+ * The published flag vocabulary (design D2). A flag outside it is a defect and
68
+ * is rejected, never carried: this list is the only vocabulary a reader has to
69
+ * match against. `domain-skipped` is raised by the review.ran reader from a
70
+ * review record that names the domain dimension as skipped; `legacy-unspecced`
71
+ * is raised by the spec-coverage reader for an edited file nothing names;
72
+ * `no-graph` by the drift and living-conformance readers; `requirements-omitted`
73
+ * by the living-conformance reader when the size bound cut the list;
74
+ * `unmeasured-source` by the coverage reader when the input names changed source
75
+ * the report does not list.
76
+ */
77
+ export const FLAGS = Object.freeze([
78
+ 'no-e2e',
79
+ 'no-typecheck',
80
+ 'no-coverage',
81
+ 'no-baseline',
82
+ 'no-pipeline',
83
+ 'docs-only-changes',
84
+ 'dimensions-unrecorded',
85
+ 'domain-skipped',
86
+ 'legacy-unspecced',
87
+ 'no-graph',
88
+ 'requirements-omitted',
89
+ 'unmeasured-source'
90
+ ])
91
+
92
+ /**
93
+ * The labels a merge request carries for a verdict and a score, defined here and
94
+ * nowhere else (design D14 of label-and-harden-the-merge-verdict). The command
95
+ * prints them under `labels`; the `mr` skill applies what it is handed and holds
96
+ * no label name of its own. `color` is six hexadecimal digits with no `#`, the
97
+ * form a platform's label command takes. The score label's name is `scorePrefix`
98
+ * followed by the integer score, unpadded.
99
+ */
100
+ export const MERGE_LABELS = Object.freeze({
101
+ verdicts: Object.freeze({
102
+ MERGE: Object.freeze({
103
+ name: 'interlock/merge',
104
+ color: '0e8a16',
105
+ description: 'Interlock merge confidence: every gating signal is green for the assessed commit'
106
+ }),
107
+ HUMAN: Object.freeze({
108
+ name: 'interlock/human-review',
109
+ color: 'fbca04',
110
+ description: 'Interlock merge confidence: the assessed commit needs a person or more evidence'
111
+ }),
112
+ BLOCK: Object.freeze({
113
+ name: 'interlock/blocked',
114
+ color: 'b60205',
115
+ description: 'Interlock merge confidence: a gating signal is red for the assessed commit'
116
+ })
117
+ }),
118
+ scorePrefix: 'interlock/score-',
119
+ score: Object.freeze({
120
+ color: 'c5def5',
121
+ description: 'Interlock merge-confidence score for the assessed commit, bounded by its worst band'
122
+ })
123
+ })
124
+
125
+ /**
126
+ * How to recognise a label this module names, with no verdict behind it: the
127
+ * three verdict label names and the prefix every score label starts with. The
128
+ * command prints it for a caller whose assessment stopped, which has a label
129
+ * from an earlier assessment to take off and no verdict of its own to read the
130
+ * names from.
131
+ *
132
+ * @returns {{ verdictNames: string[], scorePrefix: string }}
133
+ */
134
+ export function mergeLabelNames() {
135
+ return {
136
+ verdictNames: Object.values(MERGE_LABELS.verdicts).map(label => label.name),
137
+ scorePrefix: MERGE_LABELS.scorePrefix
138
+ }
139
+ }
140
+
141
+ /** The labels for one verdict and score: the two to apply, and how to recognise their predecessors. */
142
+ function labelsFor(verdict, score) {
143
+ return {
144
+ verdict: { ...MERGE_LABELS.verdicts[verdict] },
145
+ score: {
146
+ name: `${MERGE_LABELS.scorePrefix}${score}`,
147
+ color: MERGE_LABELS.score.color,
148
+ description: MERGE_LABELS.score.description
149
+ },
150
+ ...mergeLabelNames()
151
+ }
152
+ }
153
+
154
+ // Coverage figures are percentages a tool printed; this absorbs the binary
155
+ // representation error of a subtraction, so a decline of exactly the tolerance
156
+ // is judged as exactly the tolerance.
157
+ const ROUNDING_SLACK = 1e-9
158
+
159
+ function fail(message) {
160
+ const err = new Error(message)
161
+ err.userFacing = true
162
+ throw err
163
+ }
164
+
165
+ const isObject = v => !!v && typeof v === 'object' && !Array.isArray(v)
166
+ const list = v => (Array.isArray(v) ? v : [])
167
+ const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null)
168
+ const tally = v => (Array.isArray(v) ? v.length : num(v) ?? 0)
169
+ const notGiven = flag => `the ${flag} input was not given`
170
+
171
+ // --- result builders -----------------------------------------------------------
172
+
173
+ const evidenceOr = (evidence, reason) => (evidence && evidence.length ? evidence : [reason])
174
+ const green = (reason, evidence, flags = []) => ({ band: 'green', flags, kind: null, reason, evidence: evidenceOr(evidence, reason) })
175
+ const amber = (kind, reason, evidence) => ({ band: 'amber', flags: [], kind, reason, evidence: evidenceOr(evidence, reason) })
176
+ const red = (reason, evidence) => ({ band: 'red', flags: [], kind: 'finding', reason, evidence: evidenceOr(evidence, reason) })
177
+ const unobserved = (reason, evidence) => ({ band: 'unobserved', flags: [], kind: 'evidence', reason, evidence: evidenceOr(evidence, reason) })
178
+
179
+ // --- review (design D5) ----------------------------------------------------------
180
+
181
+ const reviewOf = input => (isObject(input.review) ? input.review : null)
182
+ const findingLabel = f => `${f.file || '(unscoped)'} — ${f.title || '(untitled)'}`
183
+ const verdictsOf = f => (isObject(f.votes) ? num(f.votes.total) ?? 0 : 0)
184
+
185
+ /** The surviving findings, or a result saying why there are none to read. */
186
+ function survivingOf(input, what) {
187
+ const review = reviewOf(input)
188
+ if (!review) return { stop: unobserved(`no review to ${what}: ${notGiven('--review')}`) }
189
+ if (!Array.isArray(review.surviving)) {
190
+ return { stop: unobserved(`the --review input carries no "surviving" list, so no finding could be ${what === 'read' ? 'read' : 'adjudicated'}`) }
191
+ }
192
+ return { review, found: review.surviving.filter(isObject) }
193
+ }
194
+
195
+ function readReviewBlockers(input) {
196
+ const { stop, found } = survivingOf(input, 'read')
197
+ if (stop) return stop
198
+ const blockers = found.filter(f => normalizeSeverity(f.severity) === 'blocker')
199
+ const malformed = found.filter(f => normalizeSeverity(f.severity) === null)
200
+ if (blockers.length || malformed.length) {
201
+ const parts = []
202
+ if (blockers.length) parts.push(`${blockers.length} surviving blocker(s)`)
203
+ if (malformed.length) parts.push(`${malformed.length} surviving finding(s) with a severity outside ${SEVERITIES.join('|')}`)
204
+ return red(parts.join(' and '), [
205
+ ...blockers.map(f => `blocker: ${findingLabel(f)}`),
206
+ ...malformed.map(
207
+ f => `severity ${JSON.stringify(f.severity)} is not one of ${SEVERITIES.join('|')}, rejected: ${findingLabel(f)}`
208
+ )
209
+ ])
210
+ }
211
+ return green(`no surviving blocker among ${found.length} surviving finding(s)`, [
212
+ `${found.length} surviving finding(s): no blocker, no unrecognised severity`
213
+ ])
214
+ }
215
+
216
+ function readReviewWarnings(input, bands) {
217
+ const { stop, found } = survivingOf(input, 'read')
218
+ if (stop) return stop
219
+ const cap = bands.maxWarningsForMerge
220
+ const cite = `MERGE_BANDS.maxWarningsForMerge = ${cap}`
221
+ const warnings = found.filter(f => normalizeSeverity(f.severity) === 'warning')
222
+ const evidence = warnings.map(f => `warning: ${findingLabel(f)}`)
223
+ if (warnings.length > cap) {
224
+ return amber('finding', `${warnings.length} surviving warning(s) exceed ${cite}`, evidence)
225
+ }
226
+ return green(`${warnings.length} surviving warning(s), at or under ${cite}`, evidence.length ? evidence : ['no surviving warning'])
227
+ }
228
+
229
+ function readReviewRan(input) {
230
+ const review = reviewOf(input)
231
+ if (!review) return amber('evidence', `no review ran: ${notGiven('--review')}`)
232
+ const dims = review.dimensions
233
+ if (!isObject(dims)) {
234
+ return green(
235
+ 'the review carries its counts but no dimension record, so which dimensions ran is unrecorded',
236
+ [`counts: ${JSON.stringify(isObject(review.counts) ? review.counts : null)}`, 'no dimension record in the review input'],
237
+ ['dimensions-unrecorded']
238
+ )
239
+ }
240
+ const named = v => list(v).filter(s => typeof s === 'string')
241
+ const evidence = [
242
+ `selected: ${named(dims.selected).join(', ') || '(none)'}`,
243
+ `ran: ${named(dims.ran).join(', ') || '(none)'}`
244
+ ]
245
+ const problems = []
246
+ if (dims.completed === false) problems.push('the review did not complete')
247
+ const unavailable = named(dims.unavailable)
248
+ if (unavailable.length) problems.push(`selected dimension(s) could not run with their rubric: ${unavailable.join(', ')}`)
249
+ // A selected dimension the record says neither ran nor was unavailable has
250
+ // no record of running, so "every selected dimension ran" would be a guess.
251
+ const accounted = new Set([...named(dims.ran), ...unavailable])
252
+ const unaccounted = named(dims.selected).filter(id => !accounted.has(id))
253
+ if (unaccounted.length) problems.push(`selected dimension(s) with no record that they ran: ${unaccounted.join(', ')}`)
254
+ // A skipped domain review lowers the score and moves no band: the other
255
+ // dimensions still ran with their rubrics. Only the name `domain` raises it.
256
+ const domainSkipped = list(dims.skipped).find(s => isObject(s) && s.name === 'domain')
257
+ const flags = []
258
+ if (domainSkipped) {
259
+ flags.push('domain-skipped')
260
+ const why = typeof domainSkipped.reason === 'string' && domainSkipped.reason ? `: ${domainSkipped.reason}` : ''
261
+ evidence.push(`domain review skipped${why}`)
262
+ }
263
+ if (problems.length) return { ...amber('evidence', problems.join('; '), evidence), flags }
264
+ return green('every selected dimension ran with its rubric', evidence, flags)
265
+ }
266
+
267
+ function readReviewAdversarial(input, bands) {
268
+ const { stop, review, found } = survivingOf(input, 'adjudicate')
269
+ if (stop) return stop
270
+ const floor = bands.minVerdictsPerFinding
271
+ const cite = `MERGE_BANDS.minVerdictsPerFinding = ${floor}`
272
+ const rejected = isObject(review.counts) ? num(review.counts.dismissalsRejected) ?? 0 : 0
273
+ const thin = found
274
+ .filter(f => ['blocker', 'warning'].includes(normalizeSeverity(f.severity)))
275
+ .filter(f => verdictsOf(f) < floor)
276
+ // A finding the quality band removed is in neither `surviving` nor
277
+ // `dismissed`: the skeptics' vote kept it, and their scores said it was badly
278
+ // written. For a warning that is the band doing its job. For a blocker, or a
279
+ // severity the gate would reject, it is a blocker no signal above can see, and
280
+ // a verdict that lets a change merge with nobody looking must not rest on it.
281
+ const dropped = list(review.droppedByQuality)
282
+ .filter(isObject)
283
+ .filter(f => !['warning', 'suggestion'].includes(normalizeSeverity(f.severity)))
284
+ const problems = []
285
+ if (dropped.length) problems.push(`${dropped.length} blocker(s) the skeptics' vote kept and the quality band then removed`)
286
+ if (rejected > 0) problems.push(`${rejected} refutation(s) refused for citing nothing`)
287
+ if (thin.length) problems.push(`${thin.length} surviving blocker or warning finding(s) with fewer verdicts than ${cite}`)
288
+ const evidence = [
289
+ `refused refutations: ${rejected}`,
290
+ ...thin.map(f => `${verdictsOf(f)} verdict(s): ${findingLabel(f)}`),
291
+ ...dropped.map(f => `dropped by the quality band, severity ${JSON.stringify(f.severity ?? null)}: ${findingLabel(f)}`)
292
+ ]
293
+ // More verdicts or a citation lift the other two; a dropped blocker is lifted
294
+ // by someone reading it.
295
+ if (problems.length) return amber(dropped.length ? 'person' : 'evidence', problems.join('; '), evidence)
296
+ return green(`every surviving blocker and warning has at least ${cite}, no refutation was refused, and the quality band removed no blocker`, evidence)
297
+ }
298
+
299
+ // --- risk (design D8) --------------------------------------------------------------
300
+
301
+ function readRisk(input, bands, policy) {
302
+ const result = classifyRisk({
303
+ changedPaths: list(input.changedPaths),
304
+ artifacts: isObject(input.artifacts) ? input.artifacts : {}
305
+ })
306
+ const allowed = list(policy.allowedClasses)
307
+ const cite = `MERGE_ALLOWED_CLASSES (${allowed.join(', ')})`
308
+ const driving = result.signals.filter(s => s.class === result.riskClass)
309
+ const evidence = [`class ${result.riskClass}`, ...driving.map(s => `${s.signal}: ${s.evidence}`)]
310
+ if (allowed.includes(result.riskClass)) return green(`class ${result.riskClass} is within ${cite}`, evidence)
311
+ // Never red: touching a sensitive path is a reason for a person to decide,
312
+ // not a defect the machine found. The classifier's own fail-closed answer for
313
+ // empty input is amber too, not unobserved — the classifier did answer.
314
+ const why = result.basis === 'no-input' ? ` — ${result.reasons[0]}` : ''
315
+ return amber('person', `class ${result.riskClass} is outside ${cite}, so a person decides${why}`, evidence)
316
+ }
317
+
318
+ // --- verification (design D3, D6) -----------------------------------------------------
319
+
320
+ const verdictOfInput = input => (isObject(input.verify) ? input.verify : null)
321
+ const skippedEntry = (verdict, kind) => list(verdict.skipped).find(s => isObject(s) && s.kind === kind) || null
322
+
323
+ /** One fact inside a signal: a band, what would lift it, and a line saying why. */
324
+ const fact = (band, kind, text, flags = []) => ({ band, kind: band === 'green' ? null : kind, text, flags })
325
+
326
+ /**
327
+ * The worse of several facts, as one signal result. The band, kind and reason
328
+ * come from the worst fact; the flags come from every fact, because a flag is
329
+ * reported with its signal whatever the band.
330
+ */
331
+ function worseOf(facts) {
332
+ const worst = facts.reduce((w, f) => (rank(f.band) > rank(w) ? f.band : w), 'green')
333
+ const atWorst = facts.filter(f => f.band === worst)
334
+ const kind = worst === 'green' ? null : KIND_PRIORITY.find(k => atWorst.some(f => f.kind === k)) || 'evidence'
335
+ const flags = [...new Set(facts.flatMap(f => f.flags))]
336
+ return {
337
+ band: worst,
338
+ flags,
339
+ kind,
340
+ reason: atWorst.map(f => f.text).join('; '),
341
+ evidence: facts.map(f => f.text)
342
+ }
343
+ }
344
+
345
+ /** How one skipped verification kind resolves, one band per skip reason. */
346
+ function skipFact(verdict, kind) {
347
+ const entry = skippedEntry(verdict, kind)
348
+ if (!entry) return fact('unobserved', 'evidence', `${kind} was planned and no result was reported`)
349
+ const reason = entry.reason
350
+ const detail = entry.detail ? ` (${entry.detail})` : ''
351
+ if (reason === SKIP_REASONS.DOCS_ONLY) {
352
+ return fact('green', null, `${kind} skipped: every changed path is documentation (${reason})`, ['docs-only-changes'])
353
+ }
354
+ if (reason === SKIP_REASONS.BUDGET_EXCEEDED) {
355
+ return fact('unobserved', 'evidence', `${kind} was skipped: ${reason} — the verification budget was spent before it ran${detail}`)
356
+ }
357
+ if (reason === SKIP_REASONS.PRE_EXISTING) {
358
+ return fact('amber', 'person', `${kind} was skipped: ${reason} — the suite was red before this change, so merging onto it is a person's call`)
359
+ }
360
+ if (kind === 'typecheck' && reason === SKIP_REASONS.NO_COMMAND) {
361
+ return fact('green', null, `typecheck skipped: the profile names no type-check command (${reason})`, ['no-typecheck'])
362
+ }
363
+ if (kind === 'unit' && (reason === SKIP_REASONS.NO_PROFILE || reason === SKIP_REASONS.NO_COMMAND)) {
364
+ // Never a flag: the ship loop refuses to run unattended without a test
365
+ // profile, so a repository with no unit evidence has not merely skipped a tool.
366
+ return fact('amber', 'evidence', `unit was skipped: ${reason} — this change has no unit evidence${detail}`)
367
+ }
368
+ return fact('amber', 'evidence', `${kind} was skipped for a reason this signal does not name (${reason}), so it fails closed`)
369
+ }
370
+
371
+ function unitFact(verdict) {
372
+ const unit = isObject(verdict.unit) ? verdict.unit : null
373
+ if (!unit) return skipFact(verdict, 'unit')
374
+ if (unit.halt === true || ['red', 'weakened', 'error'].includes(unit.status)) {
375
+ return fact('red', 'finding', `unit failed: ${unit.reason || unit.status}`)
376
+ }
377
+ if (unit.status === 'red-pre-existing') {
378
+ return fact(
379
+ 'amber',
380
+ 'person',
381
+ `unit is red and its failures are pre-existing (${SKIP_REASONS.PRE_EXISTING}): this change did not make it red, and merging onto a red suite is a person's call`
382
+ )
383
+ }
384
+ if (unit.status === 'green') return fact('green', null, `unit: ${unit.reason || 'green'}`)
385
+ return fact('amber', 'evidence', `unit reported a status this signal does not name (${unit.status}), so it fails closed`)
386
+ }
387
+
388
+ function typecheckFact(verdict) {
389
+ const typecheck = isObject(verdict.typecheck) ? verdict.typecheck : null
390
+ if (!typecheck) return skipFact(verdict, 'typecheck')
391
+ if (typecheck.passed === true) return fact('green', null, 'typecheck passed')
392
+ if (typecheck.passed === false) {
393
+ const how = typecheck.ran === false ? 'it could not run' : `exit ${typecheck.exitCode}`
394
+ return fact('red', 'finding', `typecheck failed (${how})`)
395
+ }
396
+ return fact('unobserved', 'evidence', 'typecheck reported a result with no "passed" field')
397
+ }
398
+
399
+ const KIND_FACTS = Object.freeze({ unit: unitFact, typecheck: typecheckFact })
400
+
401
+ /** The final verdict, or the result saying why it cannot be read. */
402
+ function finalVerdictOf(input) {
403
+ const verdict = verdictOfInput(input)
404
+ if (!verdict) return { stop: unobserved(`no verification verdict to read: ${notGiven('--verify')}`) }
405
+ if (verdict.context !== 'final') {
406
+ return {
407
+ stop: unobserved(
408
+ `the verification verdict is from the ${JSON.stringify(verdict.context ?? null)} gate, not the final one`
409
+ )
410
+ }
411
+ }
412
+ return { verdict }
413
+ }
414
+
415
+ function readSuite(input) {
416
+ const { stop, verdict } = finalVerdictOf(input)
417
+ if (stop) return stop
418
+ // Each blocking kind on its own, never `verdict.halt`: the final gate does
419
+ // not halt on a failed type check, and that is a statement about whether the
420
+ // run stops, not about whether the result can be merged.
421
+ return worseOf(BLOCKING_KINDS.map(kind => KIND_FACTS[kind](verdict)))
422
+ }
423
+
424
+ function readE2e(input) {
425
+ const { stop, verdict } = finalVerdictOf(input)
426
+ if (stop) return stop
427
+ const e2e = isObject(verdict.e2e) ? verdict.e2e : null
428
+ if (e2e) {
429
+ if (e2e.passed === true) return green('e2e passed', [`e2e: ${e2e.reason || 'green'}`])
430
+ return amber('finding', `e2e ran and failed (${e2e.reason || e2e.status || 'no reason given'})`)
431
+ }
432
+ const entry = skippedEntry(verdict, 'e2e')
433
+ if (!entry) return unobserved('e2e was planned and no result was reported')
434
+ const reason = entry.reason
435
+ if (reason === SKIP_REASONS.DOCS_ONLY) return green(`e2e skipped: every changed path is documentation (${reason})`)
436
+ if (
437
+ [SKIP_REASONS.E2E_DISABLED, SKIP_REASONS.E2E_NOT_REQUESTED, SKIP_REASONS.NO_PROFILE, SKIP_REASONS.NO_COMMAND].includes(
438
+ reason
439
+ )
440
+ ) {
441
+ return green(`no end-to-end suite is enabled or requested here (${reason})`, [`e2e skipped: ${reason}`], ['no-e2e'])
442
+ }
443
+ if (reason === SKIP_REASONS.BUDGET_EXCEEDED) {
444
+ return unobserved(`e2e was skipped: ${reason} — the verification budget was spent before it ran`)
445
+ }
446
+ return amber('evidence', `e2e was skipped for a reason this signal does not name (${reason}), so it fails closed`)
447
+ }
448
+
449
+ // --- coverage (design D7) -------------------------------------------------------------
450
+
451
+ function readCoverage(input, bands) {
452
+ const coverage = isObject(input.coverage) ? input.coverage : null
453
+ if (coverage && coverage.configured === false) {
454
+ return green('coverage is not configured in this repository (the --coverage input says so)', [], ['no-coverage'])
455
+ }
456
+ if (!coverage) {
457
+ const verdict = verdictOfInput(input)
458
+ const entry = verdict && verdict.context === 'final' ? skippedEntry(verdict, 'coverage') : null
459
+ if (entry && entry.reason === SKIP_REASONS.COVERAGE_NOT_CONFIGURED) {
460
+ return green(
461
+ `coverage is not configured in this repository (the final verification verdict skipped it as ${entry.reason})`,
462
+ [],
463
+ ['no-coverage']
464
+ )
465
+ }
466
+ return unobserved(`no coverage figures to read: ${notGiven('--coverage')}`)
467
+ }
468
+
469
+ const sizeFloor = bands.minChangedLinesForCoverage
470
+ const tolerance = bands.maxCoverageDeclinePts
471
+ const patchFloor = bands.patchCoverageFloorPct
472
+ const lines = num(coverage.changedLines)
473
+ const head = num(coverage.headTotalPct)
474
+ const base = num(coverage.baseTotalPct)
475
+ const diffPct = num(coverage.diffPct)
476
+ const evidence = [
477
+ `changed lines: ${lines ?? 'unknown'}`,
478
+ `head total: ${head ?? 'unknown'}%`,
479
+ `base total: ${base ?? 'none'}%${coverage.baseSha ? ` at ${coverage.baseSha}` : ''}`,
480
+ `changed-line coverage: ${diffPct ?? 'unknown'}%`
481
+ ]
482
+
483
+ // Changed source the report never saw (design D13). A report lists the files
484
+ // the tests loaded, so a new file no test loads counts neither for nor against
485
+ // any figure above. It is named in the evidence of every reading and flags a
486
+ // green one; it never moves a band, because a report cannot tell a file no
487
+ // test loaded from one the project left out of coverage on purpose.
488
+ const stated = Array.isArray(coverage.unreportedFiles)
489
+ const unreported = stated ? coverage.unreportedFiles.filter(f => typeof f === 'string' && f) : []
490
+ if (!stated) {
491
+ evidence.push('the coverage input does not state unreported files, so changed source the report never saw cannot be named')
492
+ } else if (unreported.length) {
493
+ const added = num(coverage.unreportedAddedLines)
494
+ evidence.push(
495
+ `${unreported.length} changed source file(s) the coverage report does not list, ` +
496
+ `${added ?? 'an unstated number of'} added line(s) in them: ${unreported.join(', ')}`
497
+ )
498
+ } else {
499
+ evidence.push('no changed source file is missing from the coverage report')
500
+ }
501
+ const greenWith = (reason, flags = []) => green(reason, evidence, unreported.length ? [...flags, 'unmeasured-source'] : flags)
502
+
503
+ // A change of exactly the floor is judged; only a smaller one is exempt.
504
+ if (lines !== null && lines < sizeFloor) {
505
+ return greenWith(`${lines} changed line(s) is under MERGE_BANDS.minChangedLinesForCoverage = ${sizeFloor}, so coverage is not judged`)
506
+ }
507
+ if (base !== null) {
508
+ if (coverage.baseIsAncestor !== true) {
509
+ return unobserved(
510
+ `the baseline commit ${coverage.baseSha || '(unnamed)'} is not an ancestor of the head, so its total says nothing certain about this change`,
511
+ evidence
512
+ )
513
+ }
514
+ if (head === null) return unobserved('the coverage input carries a base total but no head total', evidence)
515
+ const compared = `head ${head}% against base ${base}% (MERGE_BANDS.maxCoverageDeclinePts = ${tolerance})`
516
+ if (base - head <= tolerance + ROUNDING_SLACK) return greenWith(`coverage held within the tolerance: ${compared}`)
517
+ return amber('finding', `coverage fell by more than the tolerance: ${compared}`, evidence)
518
+ }
519
+ if (diffPct === null) {
520
+ return unobserved('the coverage input carries neither a base total nor a changed-line coverage figure', evidence)
521
+ }
522
+ const patch = `${diffPct}% of changed lines covered (MERGE_BANDS.patchCoverageFloorPct = ${patchFloor}), with no baseline to compare against`
523
+ if (diffPct >= patchFloor) return greenWith(patch, ['no-baseline'])
524
+ return amber('finding', `changed-line coverage is below the patch floor: ${patch}`, evidence)
525
+ }
526
+
527
+ // --- the platform's pipeline (design D7) -----------------------------------------------
528
+
529
+ function readPipeline(input) {
530
+ const pipeline = isObject(input.pipeline) ? input.pipeline : null
531
+ if (!pipeline) return unobserved(`no pipeline checks to read: ${notGiven('--pipeline')}`)
532
+
533
+ // A check the caller left out stays visible wherever the verdict is shown,
534
+ // whatever band the signal reads (design D6).
535
+ const excluded = list(pipeline.excluded).filter(n => typeof n === 'string' && n)
536
+ const named = result =>
537
+ excluded.length ? { ...result, evidence: [...result.evidence, ...excluded.map(n => `excluded by the caller: ${n}`)] } : result
538
+
539
+ // An input that is not well-formed is never an input with no checks (design
540
+ // D7). The command refuses such a file before assessing; this is the second
541
+ // line of defence, for a caller that builds the input itself. An absent or
542
+ // misspelt list and an empty list are different statements: read alike, a
543
+ // misspelt key hides a failed required check behind a green signal.
544
+ if (!Array.isArray(pipeline.checks)) {
545
+ return named(unobserved('the pipeline input is not well-formed: it carries no "checks" list, and an absent list is not an empty one'))
546
+ }
547
+ const stray = pipeline.checks.findIndex(c => !isObject(c))
548
+ if (stray !== -1) {
549
+ return named(unobserved(`the pipeline input is not well-formed: checks[${stray}] is not an object`))
550
+ }
551
+ const checks = pipeline.checks
552
+ const nameOf = c => c.name || '(unnamed check)'
553
+ const failedRequired = checks.filter(c => c.state === 'fail' && c.required === true)
554
+ const head = typeof input.headSha === 'string' && input.headSha ? input.headSha : null
555
+
556
+ // The commit check runs first on purpose: a failure on another commit says
557
+ // nothing certain about the head, which may have fixed it. It is named in
558
+ // the evidence so it is not hidden, and it does not read red.
559
+ if (!head || pipeline.sha !== head) {
560
+ const reason = head
561
+ ? `the pipeline input describes another commit (${pipeline.sha || '(none)'}), not the head (${head})`
562
+ : 'the head commit is unknown, so the pipeline input cannot be matched to it'
563
+ return named(
564
+ unobserved(reason, [
565
+ `pipeline sha: ${pipeline.sha || '(none)'}; head: ${head || 'unknown'}`,
566
+ ...failedRequired.map(c => `failed required check on another commit: ${nameOf(c)}`)
567
+ ])
568
+ )
569
+ }
570
+ const evidence = checks.map(c => `${nameOf(c)}: ${c.state}${c.required === true ? ' (required)' : ''}`)
571
+ if (failedRequired.length) {
572
+ return named(
573
+ red(
574
+ `${failedRequired.length} required check(s) failed on the head commit: ${failedRequired.map(nameOf).join(', ')}`,
575
+ failedRequired.map(c => `failed required check: ${nameOf(c)}`)
576
+ )
577
+ )
578
+ }
579
+ const pending = checks.filter(c => c.state === 'pending')
580
+ if (pending.length) {
581
+ return named(unobserved(`${pending.length} check(s) still pending on the head commit: ${pending.map(nameOf).join(', ')}`, evidence))
582
+ }
583
+ const failedOptional = checks.filter(c => c.state === 'fail')
584
+ if (failedOptional.length) {
585
+ return named(
586
+ amber('finding', `${failedOptional.length} non-required check(s) failed on the head commit: ${failedOptional.map(nameOf).join(', ')}`, evidence)
587
+ )
588
+ }
589
+ if (!checks.length) {
590
+ return named(green('the pipeline input records no checks for the head commit', [`pipeline sha ${pipeline.sha}: no checks`], ['no-pipeline']))
591
+ }
592
+ const unknown = checks.filter(c => !PIPELINE_STATES.includes(c.state))
593
+ if (unknown.length) {
594
+ return named(
595
+ amber(
596
+ 'evidence',
597
+ `check(s) in a state this signal does not name, so it fails closed: ${unknown.map(c => `${nameOf(c)}=${c.state}`).join(', ')}`,
598
+ evidence
599
+ )
600
+ )
601
+ }
602
+ return named(green(`${checks.length} check(s) passed or were skipped on the head commit`, evidence))
603
+ }
604
+
605
+ // --- delta conformance (design D6) ---------------------------------------------------
606
+
607
+ function readDeltaConformance(input) {
608
+ const checklist = isObject(input.checklist) ? input.checklist : null
609
+ if (!checklist || checklist.skipped === true) {
610
+ const why = checklist && checklist.reason ? checklist.reason : 'no checklist was gathered'
611
+ return amber('evidence', `the conformance checklist could not be built: ${why}`)
612
+ }
613
+ // The denominator is always the command's own count. A count carried in the
614
+ // handoff is never read, so a caller cannot shrink it.
615
+ const counted = Number.isInteger(checklist.verdictsRequired) ? checklist.verdictsRequired : null
616
+ if (counted === null) return amber('evidence', 'the conformance checklist carries no scenario count')
617
+ const source = list(checklist.changedFiles).filter(f => typeof f === 'string')
618
+ if (counted === 0) {
619
+ if (source.length) {
620
+ return amber(
621
+ 'finding',
622
+ 'the delta specs carry no scenario, so the change promised no behaviour for the source it changed',
623
+ source.map(f => `changed source: ${f}`)
624
+ )
625
+ }
626
+ return green('the delta specs carry no scenario and no changed path is coverable source, so there is nothing to confirm', [
627
+ 'no scenario counted; no changed coverable source'
628
+ ])
629
+ }
630
+ const handoff = isObject(input.handoff) ? input.handoff : null
631
+ if (!handoff) return unobserved(`the delta specs carry ${counted} scenario(s) and ${notGiven('--handoff')}`)
632
+ const checked = Number.isInteger(handoff.scenariosChecked) ? handoff.scenariosChecked : null
633
+ const unconfirmed = Number.isInteger(handoff.scenariosUnconfirmed) ? handoff.scenariosUnconfirmed : null
634
+ if (checked === null || unconfirmed === null) {
635
+ return amber('evidence', 'the handoff input carries no scenariosChecked or scenariosUnconfirmed count')
636
+ }
637
+ const evidence = [`${checked} of ${counted} scenario(s) checked (the command counted ${counted}); ${unconfirmed} unconfirmed`]
638
+ if (handoff.verdictsRequired !== undefined) {
639
+ evidence.push(`the handoff's own count (${JSON.stringify(handoff.verdictsRequired)}) is ignored`)
640
+ }
641
+ if (unconfirmed > 0) return amber('evidence', `${unconfirmed} of ${counted} scenario(s) unconfirmed`, evidence)
642
+ if (checked !== counted) {
643
+ return amber('evidence', `${checked} of ${counted} scenario(s) were checked; the denominator is the command's own count`, evidence)
644
+ }
645
+ return green(`${checked} of ${counted} scenario(s) checked, none unconfirmed`, evidence)
646
+ }
647
+
648
+ // --- living conformance (design D6) -----------------------------------------------------
649
+
650
+ const withFlags = (result, flags) => ({ ...result, flags })
651
+
652
+ function readLivingConformance(input) {
653
+ const living = isObject(input.living) ? input.living : null
654
+ if (!living) return unobserved('the living-requirement list was not gathered')
655
+ // A list built without a graph and with nothing declared selects nothing: the
656
+ // change touches no requirement anyone can name, which the flag says.
657
+ if (typeof living.skipped === 'string' && living.skipped) {
658
+ return green(`no living requirement could be selected: ${living.skipped}`, [`living list skipped: ${living.skipped}`], ['no-graph'])
659
+ }
660
+ // The denominator is always the command's own count. A count carried in the
661
+ // handoff is never read, so a caller cannot shrink it.
662
+ const counted = Number.isInteger(living.requirementsRequired) ? living.requirementsRequired : null
663
+ if (counted === null) return amber('evidence', 'the living-requirement list carries no requirement count')
664
+ const partial = typeof living.partial === 'string' && living.partial ? living.partial : null
665
+ const omitted = list(living.omitted).filter(isObject)
666
+ const partialLine = partial ? [`partial list: ${partial}`] : []
667
+ // What the size bound left out: a flag, and how many were asked beside how
668
+ // many were not. It is said whatever the count is, a count of none included.
669
+ const omittedLines = omitted.length
670
+ ? [
671
+ `${counted} requirement(s) asked and ${omitted.length} left out by the size bound`,
672
+ ...omitted.map(o => `left out: ${o.capability}: ${o.heading}`)
673
+ ]
674
+ : []
675
+ // Both flags describe the list, not the answers, so every reading of the
676
+ // answers carries them: a cut list whose answers came back unconfirmed was
677
+ // still cut, and the reader of an amber signal is the one who needs to know.
678
+ const flags = [...(partial ? ['no-graph'] : []), ...(omitted.length ? ['requirements-omitted'] : [])]
679
+ const listLines = [...partialLine, ...omittedLines]
680
+ if (counted === 0) {
681
+ return green(
682
+ omitted.length
683
+ ? 'no living requirement was asked: the size bound left every one of them out, so there is nothing to confirm'
684
+ : 'no living requirement is in force for the capabilities this change touches, so there is nothing to confirm',
685
+ ['0 living requirement(s) listed', ...listLines],
686
+ flags
687
+ )
688
+ }
689
+ const handoff = isObject(input.handoff) ? input.handoff : null
690
+ if (!handoff) {
691
+ return unobserved(`${counted} living requirement(s) listed and no answers: ${notGiven('--handoff')}`)
692
+ }
693
+ const checked = Number.isInteger(handoff.requirementsChecked) ? handoff.requirementsChecked : null
694
+ const unconfirmed = Number.isInteger(handoff.requirementsUnconfirmed) ? handoff.requirementsUnconfirmed : null
695
+ if (checked === null || unconfirmed === null) {
696
+ return withFlags(
697
+ amber('evidence', `${counted} living requirement(s) listed and the handoff input carries no requirementsChecked or requirementsUnconfirmed count`, listLines),
698
+ flags
699
+ )
700
+ }
701
+ const evidence = [
702
+ `${checked} of ${counted} living requirement(s) checked (the command counted ${counted}); ${unconfirmed} unconfirmed`,
703
+ ...partialLine
704
+ ]
705
+ if (handoff.requirementsRequired !== undefined) {
706
+ evidence.push(`the handoff's own count (${JSON.stringify(handoff.requirementsRequired)}) is ignored`)
707
+ }
708
+ evidence.push(...omittedLines)
709
+ if (unconfirmed > 0) {
710
+ return withFlags(amber('evidence', `${unconfirmed} of ${counted} living requirement(s) unconfirmed`, evidence), flags)
711
+ }
712
+ if (checked !== counted) {
713
+ return withFlags(
714
+ amber('evidence', `${checked} of ${counted} living requirement(s) were checked; the denominator is the command's own count`, evidence),
715
+ flags
716
+ )
717
+ }
718
+ return green(`${checked} of ${counted} living requirement(s) checked, none unconfirmed`, evidence, flags)
719
+ }
720
+
721
+ // --- spec coverage (design D8) -------------------------------------------------------------
722
+
723
+ function readSpecCoverage(input) {
724
+ const diff = isObject(input.diff) ? input.diff : null
725
+ if (!diff) return unobserved(`no base to tell an added file from an edited one: ${notGiven('--base')}`)
726
+ if (typeof diff.nothingToDiff === 'string') return unobserved(diff.nothingToDiff)
727
+ const facts = isObject(input.specCoverage) ? input.specCoverage : null
728
+ if (!facts) return unobserved('the spec-coverage facts were not gathered')
729
+
730
+ const added = list(facts.added).filter(isObject)
731
+ const unnamed = added.filter(a => !a.namedBy)
732
+ const lost = list(facts.lost).filter(isObject)
733
+ const legacy = list(facts.legacy).filter(f => typeof f === 'string')
734
+ const repo = isObject(facts.repo) ? facts.repo : null
735
+ const repoLine = repo && num(repo.named) !== null && num(repo.total) !== null
736
+ ? `${repo.named} of ${repo.total} tracked coverable source file(s) are named by a spec`
737
+ : null
738
+
739
+ if (unnamed.length || lost.length) {
740
+ const parts = []
741
+ if (unnamed.length) parts.push(`${unnamed.length} added source file(s) no spec names: ${unnamed.map(a => a.path).join(', ')}`)
742
+ if (lost.length) {
743
+ parts.push(`${lost.length} kept file(s) lost the spec that named them: ${lost.map(l => `${l.path} (${l.spec})`).join(', ')}`)
744
+ }
745
+ return amber('finding', parts.join('; '), [
746
+ ...unnamed.map(a => `added, named by no spec: ${a.path}`),
747
+ ...lost.map(l => `named by ${l.spec} at the base, by nothing now: ${l.path}`),
748
+ ...(repoLine ? [repoLine] : [])
749
+ ])
750
+ }
751
+
752
+ const evidence = [
753
+ ...added.map(a => `${a.path} is named by a ${a.namedBy === 'living' ? 'living spec' : 'delta spec of this change'} (${a.namedBy})`)
754
+ ]
755
+ if (legacy.length) evidence.push(`${legacy.length} edited file(s) no spec names: ${legacy.join(', ')}`)
756
+ if (repoLine) evidence.push(repoLine)
757
+ const reason = added.length
758
+ ? `every one of the ${added.length} added source file(s) is named by a spec`
759
+ : 'the diff adds no coverable source file, so there is nothing to name'
760
+ return green(reason, evidence, legacy.length ? ['legacy-unspecced'] : [])
761
+ }
762
+
763
+ // --- the review policy, read from the base (design D3) -----------------------------------
764
+
765
+ function readHumanPaths(input) {
766
+ const policy = isObject(input.reviewPolicy) ? input.reviewPolicy : null
767
+ if (!policy) return unobserved('no --base was given: there is no base to read the review policy from')
768
+ const ref = typeof policy.ref === 'string' && policy.ref ? policy.ref : 'the base'
769
+ const prefixes = list(policy.humanPaths).filter(p => typeof p === 'string')
770
+ const listed = prefixes.map(p => ({ raw: p, canonical: canonicalizePath(p) })).filter(p => p.canonical)
771
+ const facts = []
772
+
773
+ const problems = list(policy.problems).filter(p => typeof p === 'string')
774
+ for (const problem of problems) facts.push(fact('amber', 'person', `${ref} REVIEW.md: ${problem}`))
775
+
776
+ if (policy.differsFromBase === true) {
777
+ facts.push(
778
+ fact(
779
+ 'amber',
780
+ 'person',
781
+ `REVIEW.md differs from ${ref}; its always-human-review list was read from ${ref}${listed.length ? ` (${listed.map(p => p.canonical).join(', ')})` : ''}`
782
+ )
783
+ )
784
+ }
785
+
786
+ const touched = list(input.changedPaths).filter(p => typeof p === 'string')
787
+ for (const path of touched) {
788
+ const under = listed.find(p => pathExcludedBy(path, p.canonical))
789
+ if (under) facts.push(fact('amber', 'person', `${path} falls under ${under.canonical}/, listed under Always Human Review in ${ref} REVIEW.md`))
790
+ }
791
+
792
+ if (facts.length) return worseOf(facts)
793
+ return green(`no changed path falls under the ${listed.length} always-human-review path(s) listed at ${ref}`, [
794
+ `always-human-review paths read from ${ref}: ${listed.map(p => p.canonical).join(', ') || '(none)'}`,
795
+ `${touched.length} changed path(s), none under a listed path`
796
+ ])
797
+ }
798
+
799
+ // --- diff shape, ledger, run, drift ------------------------------------------------------
800
+
801
+ function readDiffShape(input, bands) {
802
+ const diff = isObject(input.diff) ? input.diff : null
803
+ if (!diff) return unobserved(`no base to diff against: ${notGiven('--base')}`)
804
+ // A base that is HEAD or ahead of it diffs empty by construction: an empty
805
+ // diff there proves nothing about the change, so it is never read as small.
806
+ if (typeof diff.nothingToDiff === 'string') return unobserved(diff.nothingToDiff)
807
+ const lines = num(diff.changedLines)
808
+ const files = num(diff.changedFiles)
809
+ if (lines === null || files === null) return unobserved('the diff carries no changed-line or changed-file count')
810
+ const maxLines = bands.maxChangedLinesForMerge
811
+ const maxFiles = bands.maxChangedFilesForMerge
812
+ const evidence = [`${lines} changed line(s) (added plus deleted), ${files} changed file(s)`]
813
+ if (num(diff.binaryFiles)) evidence.push(`${diff.binaryFiles} binary file(s), each counted as one file and no lines`)
814
+ const over = []
815
+ if (lines > maxLines) over.push(`${lines} changed line(s) exceed MERGE_BANDS.maxChangedLinesForMerge = ${maxLines}`)
816
+ if (files > maxFiles) over.push(`${files} changed file(s) exceed MERGE_BANDS.maxChangedFilesForMerge = ${maxFiles}`)
817
+ if (over.length) return amber('person', over.join('; '), evidence)
818
+ return green(
819
+ `${lines} changed line(s) and ${files} changed file(s), at or under MERGE_BANDS.maxChangedLinesForMerge = ${maxLines} and MERGE_BANDS.maxChangedFilesForMerge = ${maxFiles}`,
820
+ evidence
821
+ )
822
+ }
823
+
824
+ function readDecisions(input) {
825
+ const ledger = isObject(input.ledger) ? input.ledger : null
826
+ if (!ledger) return amber('evidence', 'no decision ledger was read: no change is under assessment, so name one')
827
+ if (ledger.changeExists === false) return amber('person', 'the change directory does not exist, so no ledger can clear it')
828
+ // Missing is a failure of the audit, never "nothing to decide".
829
+ if (ledger.missing === true || ledger.exists === false) {
830
+ return amber('person', 'the decision ledger is missing (decisions.md): a failure of the audit, not an empty ledger')
831
+ }
832
+ if (ledger.unparseable === true) return amber('person', 'the decision ledger is present and nobody can parse it')
833
+ const rows = list(ledger.rows).filter(isObject)
834
+ const needsHuman = num(ledger.needsHuman) ?? 0
835
+ const invalid = num(ledger.invalidCount) ?? num(ledger.invalid) ?? 0
836
+ const problems = []
837
+ const evidence = []
838
+ if (needsHuman > 0) {
839
+ problems.push(`${needsHuman} needs-human row(s)`)
840
+ evidence.push(...rows.filter(r => r.class === 'needs_human').map(r => `needs_human ${r.id}: ${r.question}`))
841
+ }
842
+ if (invalid > 0) {
843
+ problems.push(`${invalid} invalid row(s)`)
844
+ evidence.push(...rows.filter(r => r.valid === false).map(r => `invalid ${r.id}: ${r.question}`))
845
+ }
846
+ if (problems.length) return amber('person', `the decision ledger holds ${problems.join(' and ')}`, evidence)
847
+ return green(`${num(ledger.total) ?? rows.length} ledger row(s), none needing a person and none invalid`, rows.map(r => `${r.class} ${r.id}`))
848
+ }
849
+
850
+ /**
851
+ * The observations a run's close records, by the keys the record uses for them
852
+ * (design D9), and what counts as each having been made.
853
+ */
854
+ const isFlag = v => typeof v === 'boolean'
855
+ const isCount = v => Array.isArray(v) || num(v) !== null
856
+ const RUN_OBSERVATIONS = Object.freeze([
857
+ ['halted', isFlag],
858
+ ['trajectoryComplete', isFlag],
859
+ ['stageMarkerGaps', isCount],
860
+ ['rejectedReports', isCount],
861
+ ['permissionPrompts', isCount],
862
+ ['autoModeDenials', isCount],
863
+ ['leftoverTasks', isCount]
864
+ ])
865
+
866
+ function readRunIntegrity(input) {
867
+ const run = isObject(input.run) ? input.run : null
868
+ if (!run) return unobserved('no run record: this assessment was not made by a ship run, which writes one at close')
869
+ const problems = []
870
+ if (run.halted === true) problems.push('the run halted')
871
+ if (run.trajectoryComplete === false) problems.push('its trajectory is incomplete')
872
+ if (tally(run.stageMarkerGaps) > 0) problems.push(`${tally(run.stageMarkerGaps)} stage marker(s) were not published`)
873
+ if (tally(run.rejectedReports) > 0) problems.push(`${tally(run.rejectedReports)} handoff report(s) were rejected`)
874
+ if (tally(run.permissionPrompts) > 0) problems.push(`${tally(run.permissionPrompts)} permission prompt(s) occurred`)
875
+ if (tally(run.autoModeDenials) > 0) problems.push(`${tally(run.autoModeDenials)} auto-mode denial(s) occurred`)
876
+ if (tally(run.leftoverTasks) > 0) problems.push(`${tally(run.leftoverTasks)} task(s) of the change left unticked`)
877
+ // A REVIEW.md that differs from the base ref is policy.humanPaths' question,
878
+ // not the run's; reading it here too would move two signals on one fact.
879
+ // A recorded degradation wins: a halt beside missing counts still needs a person.
880
+ if (problems.length) return amber('person', `the run degraded: ${problems.join('; ')}`)
881
+ // An observation the record does not carry is unobserved, never a zero: a host
882
+ // that observed no permission prompt and a host that could not observe one are
883
+ // different facts.
884
+ const missing = RUN_OBSERVATIONS.filter(([key, made]) => !made(run[key])).map(([key]) => key)
885
+ if (missing.length) {
886
+ return unobserved(
887
+ `the run record does not carry every observation, and one nobody made is not a zero: ${missing.join(', ')}`,
888
+ missing.map(key => `not observed: ${key}`)
889
+ )
890
+ }
891
+ return green('the run completed with no degradation')
892
+ }
893
+
894
+ function readDrift(input) {
895
+ const drift = isObject(input.drift) ? input.drift : null
896
+ if (!drift) return unobserved('drift was not gathered for this assessment')
897
+ const stale = isObject(drift.stale) ? drift.stale : {}
898
+ const canonical = p => canonicalizePath(p) || p
899
+ const touched = new Set(list(input.changedPaths).filter(p => typeof p === 'string').map(canonical))
900
+
901
+ // Only what this change did: a citation of a file this change touched and
902
+ // that no longer exists. A broken citation elsewhere is drift's backlog.
903
+ const broken = list(stale.broken)
904
+ .filter(isObject)
905
+ .filter(b => typeof b.file === 'string' && touched.has(canonical(b.file)))
906
+
907
+ const change = isObject(input.change) ? input.change : null
908
+ const name = change && typeof change.name === 'string' ? change.name : null
909
+ const mine = new Set(change ? list(change.capabilities) : [])
910
+ const competing = []
911
+ for (const other of list(drift.unarchived).filter(isObject)) {
912
+ if (name && other.change === name) continue
913
+ if (!name) {
914
+ competing.push(`${other.change} is complete and unarchived, and with no change under assessment every one counts`)
915
+ continue
916
+ }
917
+ if (!Array.isArray(other.capabilities)) {
918
+ competing.push(`${other.change} is complete and unarchived, and its capabilities were not gathered, so the overlap fails closed`)
919
+ continue
920
+ }
921
+ const shared = other.capabilities.filter(c => mine.has(c))
922
+ if (shared.length) {
923
+ competing.push(`${other.change} is complete and unarchived and also has delta specs for ${shared.join(', ')}`)
924
+ }
925
+ }
926
+
927
+ const noGraph = stale.skipped === true
928
+ const flags = noGraph ? ['no-graph'] : []
929
+ const evidence = [
930
+ ...(noGraph ? [`the stale-citation check did not run: ${stale.reason || 'no usable graph'}`] : []),
931
+ ...broken.map(b => `${b.spec} cites ${b.file}, which this change removed`),
932
+ ...competing
933
+ ]
934
+ if (broken.length || competing.length) {
935
+ const parts = []
936
+ if (broken.length) parts.push(`${broken.length} living-spec citation(s) of a file this change removed`)
937
+ if (competing.length) parts.push(`${competing.length} other complete, unarchived change(s) on a capability this change also touches`)
938
+ return { band: 'amber', flags, kind: broken.length ? 'finding' : 'person', reason: parts.join('; '), evidence }
939
+ }
940
+ if (noGraph) {
941
+ return green(`the stale-citation check did not run, so its empty result is not read as clean: ${stale.reason || 'no usable graph'}`, evidence, flags)
942
+ }
943
+ return green('no living spec cites a file this change removed, and no other unarchived change shares its capabilities', [
944
+ `${list(stale.broken).length} broken citation(s) repo-wide, none of a file this change touched`,
945
+ `${list(drift.unarchived).length} unarchived change(s), none on this change's capabilities`
946
+ ])
947
+ }
948
+
949
+ // --- informational -------------------------------------------------------------------------
950
+
951
+ function readPlanFidelity(input) {
952
+ const reason = isObject(input.run)
953
+ ? 'the run record carries no planned and touched path sets in this version'
954
+ : 'no run record'
955
+ return {
956
+ band: null,
957
+ flags: [],
958
+ kind: null,
959
+ reason,
960
+ evidence: [isObject(input.run) ? reason : 'no run record: plan fidelity is reported from a ship run, and this assessment has none']
961
+ }
962
+ }
963
+
964
+ // --- the registry ----------------------------------------------------------------------------
965
+
966
+ const signal = (id, read, gating = true) => Object.freeze({ id, family: id.slice(0, id.indexOf('.')), gating, read })
967
+
968
+ /**
969
+ * Every registered signal, in the order the spec lists them: seventeen gating
970
+ * signals, then the one informational signal. `read(input, bands, policy)`
971
+ * returns `{ band, flags, evidence, reason, kind }`. The formula never names a
972
+ * signal; adding one is an entry here and a fixture.
973
+ */
974
+ export const SIGNALS = Object.freeze([
975
+ signal('review.blockers', readReviewBlockers),
976
+ signal('review.warnings', readReviewWarnings),
977
+ signal('review.ran', readReviewRan),
978
+ signal('review.adversarial', readReviewAdversarial),
979
+ signal('risk.class', readRisk),
980
+ signal('verify.suite', readSuite),
981
+ signal('verify.e2e', readE2e),
982
+ signal('verify.coverage', readCoverage),
983
+ signal('ci.pipeline', readPipeline),
984
+ signal('conformance.delta', readDeltaConformance),
985
+ signal('conformance.living', readLivingConformance),
986
+ signal('spec.coverage', readSpecCoverage),
987
+ signal('diff.shape', readDiffShape),
988
+ signal('decisions.open', readDecisions),
989
+ signal('run.integrity', readRunIntegrity),
990
+ signal('drift.stale', readDrift),
991
+ signal('policy.humanPaths', readHumanPaths),
992
+ signal('plan.fidelity', readPlanFidelity, false)
993
+ ])
994
+
995
+ // --- the formula ----------------------------------------------------------------------------
996
+
997
+ /** The score range of a worst band. Unobserved takes amber's, as it takes amber's rank. */
998
+ function rangeOf(worst, bands) {
999
+ const ceiling = isObject(bands) && isObject(bands.ceiling) ? bands.ceiling : fail('merge bands carry no ceiling table')
1000
+ if (worst === 'green') return { band: 'green', floor: ceiling.amber + 1, ceiling: ceiling.green }
1001
+ if (worst === 'red') return { band: 'red', floor: 0, ceiling: ceiling.red }
1002
+ return { band: 'amber', floor: ceiling.red + 1, ceiling: ceiling.amber }
1003
+ }
1004
+
1005
+ /** Check one result against the published vocabulary and copy it. Never mutates. */
1006
+ function checked(entry) {
1007
+ if (!isObject(entry) || typeof entry.id !== 'string' || !entry.id) fail('every signal result needs a string "id"')
1008
+ const gating = entry.gating !== false
1009
+ const flags = list(entry.flags)
1010
+ for (const flag of flags) {
1011
+ if (!FLAGS.includes(flag)) {
1012
+ fail(`${entry.id}: flag ${JSON.stringify(flag)} is not in the published FLAGS list (${FLAGS.join(', ')})`)
1013
+ }
1014
+ }
1015
+ if (gating) {
1016
+ if (!BANDS.includes(entry.band)) fail(`${entry.id}: band ${JSON.stringify(entry.band)} is not one of ${BANDS.join('|')}`)
1017
+ if (entry.band !== 'green' && !KINDS.includes(entry.kind)) {
1018
+ fail(`${entry.id}: a ${entry.band} result must say what would lift it, one of ${KINDS.join('|')}`)
1019
+ }
1020
+ } else if (entry.band !== null && entry.band !== undefined) {
1021
+ fail(`${entry.id}: an informational signal carries no band`)
1022
+ }
1023
+ const reason = typeof entry.reason === 'string' ? entry.reason : ''
1024
+ return {
1025
+ id: entry.id,
1026
+ family: typeof entry.family === 'string' ? entry.family : entry.id.slice(0, entry.id.indexOf('.')),
1027
+ gating,
1028
+ band: gating ? entry.band : null,
1029
+ flags: [...flags],
1030
+ kind: gating && entry.band !== 'green' ? entry.kind : null,
1031
+ reason,
1032
+ evidence: list(entry.evidence).map(String)
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Combine signal results into the verdict and the score (design D1). The same
1038
+ * list always yields the same answer, whatever input produced it.
1039
+ *
1040
+ * @param {Array<{id: string, family?: string, gating?: boolean, band: string|null,
1041
+ * flags: string[], kind: string|null, reason: string, evidence: string[]}>} results
1042
+ * @param {{bands?: object}} [opts] defaults to the published `MERGE_BANDS`
1043
+ * @returns {{verdict: 'MERGE'|'HUMAN'|'BLOCK', score: number, worstBand: string,
1044
+ * range: {band: string, floor: number, ceiling: number},
1045
+ * counts: {gating: number, clean: number, green: number, amber: number, red: number,
1046
+ * unobserved: number, flagged: number},
1047
+ * driving: Array<{id: string, band: string, kind: string, reason: string, evidence: string[]}>,
1048
+ * flags: Array<{id: string, flag: string}>, signals: Array, reasons: string[],
1049
+ * labels: {verdict: {name: string, color: string, description: string},
1050
+ * score: {name: string, color: string, description: string}, verdictNames: string[], scorePrefix: string}}}
1051
+ */
1052
+ export function combineSignals(results, { bands = MERGE_BANDS } = {}) {
1053
+ if (!Array.isArray(results)) fail('combineSignals needs an array of signal results')
1054
+ const signals = results.map(checked)
1055
+ const gating = signals.filter(s => s.gating)
1056
+ if (!gating.length) fail('combineSignals needs at least one gating signal result')
1057
+
1058
+ // The worst band wins; nothing is averaged.
1059
+ const worstBand = gating.reduce((worst, s) => (rank(s.band) > rank(worst) ? s.band : worst), 'green')
1060
+ const verdict = worstBand === 'red' ? 'BLOCK' : worstBand === 'green' ? 'MERGE' : 'HUMAN'
1061
+
1062
+ // The score: the band's floor plus its width scaled by the clean share.
1063
+ const clean = gating.filter(s => s.band === 'green' && !s.flags.length).length
1064
+ const range = rangeOf(worstBand, bands)
1065
+ const score = range.floor + Math.round(((range.ceiling - range.floor) * clean) / gating.length)
1066
+
1067
+ const howMany = band => gating.filter(s => s.band === band).length
1068
+ const counts = {
1069
+ gating: gating.length,
1070
+ clean,
1071
+ green: howMany('green'),
1072
+ amber: howMany('amber'),
1073
+ red: howMany('red'),
1074
+ unobserved: howMany('unobserved'),
1075
+ flagged: gating.filter(s => s.flags.length).length
1076
+ }
1077
+
1078
+ // Everything that stands between the change and MERGE, worst first, each
1079
+ // saying whether a person, more evidence or a fix would lift it.
1080
+ const driving = gating
1081
+ .map((s, order) => ({ s, order }))
1082
+ .filter(({ s }) => s.band !== 'green')
1083
+ .sort((a, b) => rank(b.s.band) - rank(a.s.band) || a.order - b.order)
1084
+ .map(({ s }) => ({ id: s.id, band: s.band, kind: s.kind, reason: s.reason, evidence: [...s.evidence] }))
1085
+
1086
+ const flags = signals.flatMap(s => s.flags.map(flag => ({ id: s.id, flag })))
1087
+
1088
+ const ids = band => gating.filter(s => s.band === band).map(s => s.id).join(', ')
1089
+ const reasons = []
1090
+ if (verdict === 'MERGE') reasons.push(`MERGE: all ${gating.length} gating signal(s) are green`)
1091
+ else if (verdict === 'BLOCK') reasons.push(`BLOCK: red on ${ids('red')}`)
1092
+ else {
1093
+ const held = []
1094
+ if (counts.amber) held.push(`amber on ${ids('amber')}`)
1095
+ if (counts.unobserved) held.push(`unobserved on ${ids('unobserved')}`)
1096
+ reasons.push(`HUMAN: ${held.join('; ')}`)
1097
+ }
1098
+ reasons.push(
1099
+ `score ${score}: the ${range.band} range ${range.floor}–${range.ceiling} (MERGE_BANDS.ceiling), ` +
1100
+ `positioned by ${clean} of ${gating.length} gating signal(s) green with no flag`
1101
+ )
1102
+
1103
+ return { verdict, score, worstBand, range, counts, driving, flags, signals, reasons, labels: labelsFor(verdict, score) }
1104
+ }
1105
+
1106
+ /**
1107
+ * Assess one change: run every registered signal over the input, then combine.
1108
+ *
1109
+ * The input is a plain object the CLI assembles (design D4): `change`,
1110
+ * `changedPaths`, `headSha`, `artifacts`, `review`, `verify`, `checklist`,
1111
+ * `handoff`, `pipeline`, `coverage`, `diff`, `ledger`, `drift`, `run`, `living`
1112
+ * (the living-requirement list's counts and flags: `skipped`, `partial`,
1113
+ * `requirementsRequired`, `omitted`, `source`) and `specCoverage` (`added`,
1114
+ * `lost`, `legacy`, `repo`, from `lib/spec-coverage.mjs`). The handoff may carry
1115
+ * `requirementsChecked` and `requirementsUnconfirmed`; the run record may carry
1116
+ * `leftoverTasks`. Any of them may be null; a signal whose input is absent says so.
1117
+ *
1118
+ * @param {object} input
1119
+ * @param {{bands?: object, allowedClasses?: string[]}} [opts]
1120
+ * default to the published `MERGE_BANDS` and `MERGE_ALLOWED_CLASSES`
1121
+ */
1122
+ export function assessMergeConfidence(input, { bands = MERGE_BANDS, allowedClasses = MERGE_ALLOWED_CLASSES } = {}) {
1123
+ const source = isObject(input) ? input : {}
1124
+ const policy = { allowedClasses }
1125
+ const results = SIGNALS.map(entry => {
1126
+ const read = entry.read(source, bands, policy)
1127
+ return {
1128
+ id: entry.id,
1129
+ family: entry.family,
1130
+ gating: entry.gating,
1131
+ band: read.band,
1132
+ flags: read.flags,
1133
+ kind: read.kind,
1134
+ reason: read.reason,
1135
+ evidence: evidenceOr(read.evidence, read.reason)
1136
+ }
1137
+ })
1138
+ return combineSignals(results, { bands })
1139
+ }
1140
+
1141
+ /**
1142
+ * The one-line statement of an assessment. `formatMergeConfidence` prints it
1143
+ * first and a ship run's close summary prints the same string, so a reader
1144
+ * greps one form. design D10 of complete-and-record-the-merge-verdict.
1145
+ */
1146
+ export function mergeConfidenceHeadline(result) {
1147
+ if (!isObject(result) || !isObject(result.range) || !isObject(result.counts)) {
1148
+ fail('mergeConfidenceHeadline needs an assessment result')
1149
+ }
1150
+ const { verdict, score, range, counts } = result
1151
+ return (
1152
+ `MERGE CONFIDENCE ${verdict} — score ${score} (${range.band} range ${range.floor}–${range.ceiling}; ` +
1153
+ `${counts.clean} of ${counts.gating} gating signals clean)`
1154
+ )
1155
+ }
1156
+
1157
+ /** Human-readable assessment: verdict and score first, then what drives it, then every signal. */
1158
+ export function formatMergeConfidence(result) {
1159
+ if (!isObject(result) || !Array.isArray(result.signals)) fail('formatMergeConfidence needs an assessment result')
1160
+ const lines = []
1161
+ lines.push(mergeConfidenceHeadline(result))
1162
+ lines.push('')
1163
+ if (result.driving.length) {
1164
+ lines.push('driving:')
1165
+ for (const kind of KIND_PRIORITY) {
1166
+ const group = result.driving.filter(d => d.kind === kind)
1167
+ if (!group.length) continue
1168
+ lines.push(` ${KIND_LABELS[kind]}:`)
1169
+ for (const d of group) lines.push(` ${d.id} [${d.band}] ${d.reason}`)
1170
+ }
1171
+ } else {
1172
+ lines.push('driving: none')
1173
+ }
1174
+ lines.push(result.flags.length ? `flags: ${result.flags.map(f => `${f.id} ${f.flag}`).join(', ')}` : 'flags: none')
1175
+ if (isObject(result.labels)) lines.push(`labels: ${result.labels.verdict.name}, ${result.labels.score.name}`)
1176
+ lines.push('')
1177
+ lines.push('signals:')
1178
+ const width = Math.max(...result.signals.map(s => s.id.length))
1179
+ for (const s of result.signals) {
1180
+ const band = s.gating ? s.band : 'info'
1181
+ const flagged = s.flags.length ? ` (${s.flags.join(', ')})` : ''
1182
+ lines.push(` ${s.id.padEnd(width)} ${band}${flagged} ${s.reason}`)
1183
+ for (const line of s.evidence) if (line !== s.reason) lines.push(` ${''.padEnd(width)} ${line}`)
1184
+ }
1185
+ lines.push('')
1186
+ for (const reason of result.reasons || []) lines.push(`reason: ${reason}`)
1187
+ return lines.join('\n') + '\n'
1188
+ }
1189
+
1190
+ // --- the description block (design D15) ------------------------------------------------------
1191
+
1192
+ // The longest a quoted string runs before it is cut. A display bound for the
1193
+ // block, never a limit the verdict reads.
1194
+ const SPAN_MAX_CHARS = 240
1195
+
1196
+ const MARKDOWN_GROUPS = Object.freeze({
1197
+ finding: 'Needs a fix',
1198
+ person: 'Needs a person',
1199
+ evidence: 'Needs more evidence'
1200
+ })
1201
+
1202
+ /**
1203
+ * One string as inert text: an inline code span. Reasons and evidence quote
1204
+ * names this repository does not control (a check is named by the workflow that
1205
+ * defines it, a finding by the reviewer, a ledger question by its author), and
1206
+ * the block is posted under the account of whoever ran the skill. A code span
1207
+ * renders no mention, no link and no markup, so the only things that can end it
1208
+ * are removed: a backtick is replaced and a line break folded. The text is cut
1209
+ * with an ellipsis at `SPAN_MAX_CHARS`.
1210
+ */
1211
+ function inertSpan(text, { inTable = false } = {}) {
1212
+ let body = String(text ?? '')
1213
+ .replace(/`/g, "'")
1214
+ .replace(/[\r\n\f\v\u2028\u2029]+/g, ' ')
1215
+ .trim()
1216
+ if (!body) body = '(empty)'
1217
+ if (body.length > SPAN_MAX_CHARS) body = `${body.slice(0, SPAN_MAX_CHARS - 1)}…`
1218
+ // A pipe ends a table cell even inside a code span, so there it is escaped.
1219
+ if (inTable) body = body.replace(/\|/g, '\\|')
1220
+ return `\`${body}\``
1221
+ }
1222
+
1223
+ /**
1224
+ * The description block for a merge request: one Markdown block, rendered from
1225
+ * the assessment and from the head, the base and the excluded check names the
1226
+ * command holds. `head` is `{ sha }` or null, `base` is `{ ref, sha }` (`sha`
1227
+ * null when the merge base could not be read) or null, `excluded` a list of
1228
+ * names. Every string a platform, a review or a ledger supplied is an inline
1229
+ * code span; the full signal table sits in one collapsed section, last.
1230
+ *
1231
+ * @param {object} result an assessment, as `assessMergeConfidence` returns it
1232
+ * @param {{ head?: {sha: string}|null, base?: {ref: string, sha: string|null}|null, excluded?: string[] }} [given]
1233
+ * @returns {string}
1234
+ */
1235
+ export function formatMergeConfidenceMarkdown(result, { head, base, excluded } = {}) {
1236
+ if (!isObject(result) || !Array.isArray(result.signals) || !isObject(result.range) || !isObject(result.counts)) {
1237
+ fail('formatMergeConfidenceMarkdown needs an assessment result')
1238
+ }
1239
+ const { verdict, score, range, counts } = result
1240
+ const labels = isObject(result.labels) ? result.labels : labelsFor(verdict, score)
1241
+ const lines = ['## Merge confidence', '']
1242
+ lines.push(
1243
+ `**${verdict}** · score ${score} (${range.band} range ${range.floor}–${range.ceiling}; ` +
1244
+ `${counts.clean} of ${counts.gating} gating signals clean)`
1245
+ )
1246
+ lines.push('')
1247
+
1248
+ const headSha = isObject(head) && typeof head.sha === 'string' && head.sha ? head.sha : null
1249
+ const baseRef = isObject(base) && typeof base.ref === 'string' && base.ref ? base.ref : null
1250
+ const baseSha = isObject(base) && typeof base.sha === 'string' && base.sha ? base.sha : null
1251
+ const commit = headSha ? `Assessed commit ${inertSpan(headSha)}` : 'The assessed commit could not be read'
1252
+ const against = !baseRef
1253
+ ? '; no base was given.'
1254
+ : baseSha
1255
+ ? ` against ${inertSpan(baseRef)} (merge base ${inertSpan(baseSha)}).`
1256
+ : ` against ${inertSpan(baseRef)}, whose merge base could not be read.`
1257
+ lines.push(`${commit}${against}`)
1258
+ lines.push('')
1259
+
1260
+ // Everything that stands between the change and MERGE, by what would lift it.
1261
+ const driving = list(result.driving)
1262
+ if (!driving.length) {
1263
+ lines.push('Nothing is driving: every gating signal is green.')
1264
+ lines.push('')
1265
+ }
1266
+ for (const kind of KIND_PRIORITY) {
1267
+ const group = driving.filter(d => d.kind === kind)
1268
+ if (!group.length) continue
1269
+ lines.push(`**${MARKDOWN_GROUPS[kind]}**`)
1270
+ lines.push('')
1271
+ for (const d of group) lines.push(`- ${inertSpan(d.id)} (${d.band}): ${inertSpan(d.reason)}`)
1272
+ lines.push('')
1273
+ }
1274
+
1275
+ // Each its own paragraph: a list here would join the group's list above it.
1276
+ const flags = list(result.flags)
1277
+ lines.push(`**Flags:** ${flags.length ? flags.map(f => `${inertSpan(f.id)} ${f.flag}`).join(', ') : 'none'}`)
1278
+ lines.push('')
1279
+ const left = list(excluded).filter(n => typeof n === 'string' && n)
1280
+ if (left.length) {
1281
+ lines.push(`**Excluded from the pipeline input:** ${left.map(n => inertSpan(n)).join(', ')}`)
1282
+ lines.push('')
1283
+ }
1284
+ lines.push(`**Labels:** ${inertSpan(labels.verdict.name)}, ${inertSpan(labels.score.name)}`)
1285
+ lines.push('')
1286
+
1287
+ lines.push('<details>')
1288
+ lines.push(`<summary>All ${result.signals.length} signals</summary>`)
1289
+ lines.push('')
1290
+ lines.push('| Signal | Band | Flags | Reason and evidence |')
1291
+ lines.push('| --- | --- | --- | --- |')
1292
+ for (const s of result.signals) {
1293
+ const said = [s.reason, ...list(s.evidence).filter(line => line !== s.reason)]
1294
+ lines.push(
1295
+ `| ${inertSpan(s.id, { inTable: true })} | ${s.gating ? s.band : 'info'} | ${list(s.flags).join(', ')} | ` +
1296
+ `${said.map(line => inertSpan(line, { inTable: true })).join('<br>')} |`
1297
+ )
1298
+ }
1299
+ lines.push('')
1300
+ lines.push('</details>')
1301
+ return lines.join('\n') + '\n'
1302
+ }