@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.
- package/.claude-plugin/plugin.json +12 -1
- package/CHANGELOG.md +93 -0
- package/README.md +5 -6
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +20 -23
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- 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
|
+
}
|