@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
package/lib/pipeline.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// The pipeline input: a platform's own check output, turned into what the merge
|
|
2
|
+
// verdict reads.
|
|
3
|
+
//
|
|
4
|
+
// `interlock merge-confidence --pipeline` reads `{ sha, checks: [{ name, state,
|
|
5
|
+
// required }], excluded }`, and the state of a check decides whether the verdict
|
|
6
|
+
// can read MERGE. That file used to have no producer: the `mr` skill was to
|
|
7
|
+
// write it, and a skill is prose a model follows, so the mapping from a
|
|
8
|
+
// platform's `SUCCESS`, `NEUTRAL`, `TIMED_OUT` and the rest to four states
|
|
9
|
+
// would have been re-derived on every run. This module is the mapping, as
|
|
10
|
+
// frozen data and three readers, so a test can hold it (design D2 to D7 of
|
|
11
|
+
// label-and-harden-the-merge-verdict).
|
|
12
|
+
//
|
|
13
|
+
// Three rules shape every reader:
|
|
14
|
+
//
|
|
15
|
+
// - A RESULT THE PLATFORM DID NOT GIVE IS `pending`. Cancelled, stale and
|
|
16
|
+
// unknown values read `pending`, which the verdict reads as unobserved.
|
|
17
|
+
// `fail` would report a defect nobody found; `skipped` would grant green on
|
|
18
|
+
// a check that never finished (design D5).
|
|
19
|
+
// - THE COMMIT IS THE PLATFORM'S. The `sha` is the head commit the document
|
|
20
|
+
// itself states, never one the caller holds. A GitLab merged-result pipeline
|
|
21
|
+
// runs on a commit GitLab made, so the head travels from the merge request
|
|
22
|
+
// document to the pipeline document in a pending record, typed by nobody
|
|
23
|
+
// (design D4).
|
|
24
|
+
// - A DOCUMENT THE READER DOES NOT RECOGNISE IS REFUSED. An absent check list
|
|
25
|
+
// is not the platform saying there are no checks.
|
|
26
|
+
//
|
|
27
|
+
// `validatePipelineInput` is the one statement of what a well-formed input is,
|
|
28
|
+
// as `validateBaseline` is for the coverage baseline. The command applies it to
|
|
29
|
+
// the `--pipeline` file before assessing; `lib/merge-confidence.mjs` imports
|
|
30
|
+
// the four states.
|
|
31
|
+
//
|
|
32
|
+
// The GitLab readers are written from GitLab's and glab's documentation and
|
|
33
|
+
// source, with fixtures built to match them. Nobody ran them against a live
|
|
34
|
+
// project, and docs/15-merge-confidence.md says so.
|
|
35
|
+
//
|
|
36
|
+
// Pure: no fs, no git, no network, no clock. `interlock pipeline` owns the I/O.
|
|
37
|
+
|
|
38
|
+
/** The four states an entry of the pipeline input may carry. */
|
|
39
|
+
export const PIPELINE_STATES = Object.freeze(['pass', 'fail', 'pending', 'skipped'])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GitHub (design D3). A check run that is not completed is `pending` whatever
|
|
43
|
+
* its conclusion; a completed one reads its conclusion here, and a commit status
|
|
44
|
+
* reads its state. Anything a table does not name reads `otherwise`.
|
|
45
|
+
*/
|
|
46
|
+
export const GITHUB_STATE_TABLE = Object.freeze({
|
|
47
|
+
checkRun: Object.freeze({
|
|
48
|
+
notCompleted: 'pending',
|
|
49
|
+
conclusions: Object.freeze({
|
|
50
|
+
SUCCESS: 'pass',
|
|
51
|
+
NEUTRAL: 'skipped',
|
|
52
|
+
SKIPPED: 'skipped',
|
|
53
|
+
FAILURE: 'fail',
|
|
54
|
+
TIMED_OUT: 'fail',
|
|
55
|
+
ACTION_REQUIRED: 'fail',
|
|
56
|
+
STARTUP_FAILURE: 'fail',
|
|
57
|
+
CANCELLED: 'pending',
|
|
58
|
+
STALE: 'pending'
|
|
59
|
+
}),
|
|
60
|
+
otherwise: 'pending'
|
|
61
|
+
}),
|
|
62
|
+
commitStatus: Object.freeze({
|
|
63
|
+
states: Object.freeze({
|
|
64
|
+
SUCCESS: 'pass',
|
|
65
|
+
FAILURE: 'fail',
|
|
66
|
+
ERROR: 'fail',
|
|
67
|
+
PENDING: 'pending',
|
|
68
|
+
EXPECTED: 'pending'
|
|
69
|
+
}),
|
|
70
|
+
otherwise: 'pending'
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* GitLab (design D4). A job reads its status here; a manual job reads by whether
|
|
76
|
+
* it is allowed to fail. Anything the table does not name reads `otherwise`.
|
|
77
|
+
*/
|
|
78
|
+
export const GITLAB_STATE_TABLE = Object.freeze({
|
|
79
|
+
statuses: Object.freeze({
|
|
80
|
+
success: 'pass',
|
|
81
|
+
failed: 'fail',
|
|
82
|
+
skipped: 'skipped',
|
|
83
|
+
created: 'pending',
|
|
84
|
+
pending: 'pending',
|
|
85
|
+
preparing: 'pending',
|
|
86
|
+
running: 'pending',
|
|
87
|
+
scheduled: 'pending',
|
|
88
|
+
waiting_for_resource: 'pending',
|
|
89
|
+
waiting_for_callback: 'pending',
|
|
90
|
+
canceled: 'pending',
|
|
91
|
+
canceling: 'pending'
|
|
92
|
+
}),
|
|
93
|
+
manual: Object.freeze({ allowedToFail: 'skipped', notAllowedToFail: 'pending' }),
|
|
94
|
+
otherwise: 'pending'
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/** The one check-run status whose conclusion is a result; any other is still running or queued. */
|
|
98
|
+
const GITHUB_COMPLETED = 'COMPLETED'
|
|
99
|
+
|
|
100
|
+
/** A merged-result or merge-train pipeline's reference: its own commit is one GitLab made. */
|
|
101
|
+
const MERGED_RESULT_REF = /^refs\/merge-requests\/[^/]+\/(?:merge|train)$/
|
|
102
|
+
|
|
103
|
+
const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
104
|
+
// A table is read by its own keys only: a value such as "constructor" names no
|
|
105
|
+
// row, and must not read whatever the object happens to inherit.
|
|
106
|
+
const lookup = (table, key, otherwise) => (typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : otherwise)
|
|
107
|
+
const isName = value => typeof value === 'string' && value.length > 0
|
|
108
|
+
const names = value => (Array.isArray(value) ? value.filter(isName) : [])
|
|
109
|
+
const refuse = reason => ({ ok: false, reason })
|
|
110
|
+
const quoted = value => JSON.stringify(value ?? null)
|
|
111
|
+
const typeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'a list' : `a ${typeof value}`)
|
|
112
|
+
|
|
113
|
+
// --- GitHub ----------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** The name and state of one `statusCheckRollup` entry, or null when it has no name. */
|
|
116
|
+
function githubEntry(entry) {
|
|
117
|
+
// A commit status is named by its context and carries a state; a check run is
|
|
118
|
+
// named by its name and carries a status and a conclusion.
|
|
119
|
+
const isStatus = entry.__typename === 'StatusContext' || (!isName(entry.name) && isName(entry.context))
|
|
120
|
+
if (isStatus) {
|
|
121
|
+
if (!isName(entry.context)) return null
|
|
122
|
+
const { states, otherwise } = GITHUB_STATE_TABLE.commitStatus
|
|
123
|
+
return { kind: 'status', name: entry.context, group: '', state: lookup(states, entry.state, otherwise), startedAt: entry.startedAt }
|
|
124
|
+
}
|
|
125
|
+
if (!isName(entry.name)) return null
|
|
126
|
+
const { notCompleted, conclusions, otherwise } = GITHUB_STATE_TABLE.checkRun
|
|
127
|
+
const state = entry.status !== GITHUB_COMPLETED ? notCompleted : lookup(conclusions, entry.conclusion, otherwise)
|
|
128
|
+
return {
|
|
129
|
+
kind: 'run',
|
|
130
|
+
name: entry.name,
|
|
131
|
+
group: isName(entry.workflowName) ? entry.workflowName : '',
|
|
132
|
+
state,
|
|
133
|
+
startedAt: entry.startedAt
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* When a run started, or null when the entry does not say. A platform's zero
|
|
139
|
+
* time (`0001-01-01T00:00:00Z`, what a run that has not started carries) parses
|
|
140
|
+
* as a date and is no start time.
|
|
141
|
+
*/
|
|
142
|
+
function startOf(entry) {
|
|
143
|
+
const at = Date.parse(entry.startedAt)
|
|
144
|
+
return Number.isNaN(at) || at <= 0 ? null : at
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Worse is earlier. Read only when several reports of one check cannot be put
|
|
148
|
+
// in order, where the less favourable one is the one that stands.
|
|
149
|
+
const LEAST_FAVOURABLE_FIRST = Object.freeze(['fail', 'pending', 'skipped', 'pass'])
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The one entry read for a check the platform reported more than once: the run
|
|
153
|
+
* that started last, the later entry on a tie. A run with no start time cannot
|
|
154
|
+
* be placed before or after any other, so with one among them there is no last
|
|
155
|
+
* run to read: the least favourable state stands, among the last timed run and
|
|
156
|
+
* every untimed one. Listing order is not evidence of which ran last, and a
|
|
157
|
+
* pass listed after a failure must not hide it for want of a time. `unordered`
|
|
158
|
+
* is set when that rule chose between different states, so the caller says so.
|
|
159
|
+
*/
|
|
160
|
+
function latestRun(runs) {
|
|
161
|
+
let last = null
|
|
162
|
+
const untimed = []
|
|
163
|
+
for (const run of runs) {
|
|
164
|
+
if (startOf(run) === null) untimed.push(run)
|
|
165
|
+
else if (!last || startOf(run) >= startOf(last)) last = run
|
|
166
|
+
}
|
|
167
|
+
if (!untimed.length) return { entry: last, unordered: false }
|
|
168
|
+
const candidates = last ? [last, ...untimed] : untimed
|
|
169
|
+
const entry = candidates.reduce((worst, run) =>
|
|
170
|
+
LEAST_FAVOURABLE_FIRST.indexOf(run.state) < LEAST_FAVOURABLE_FIRST.indexOf(worst.state) ? run : worst
|
|
171
|
+
)
|
|
172
|
+
return { entry, unordered: new Set(candidates.map(run => run.state)).size > 1 }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Apply the caller's required names and exclusions to the reported checks.
|
|
177
|
+
* Exclusion is exact, by name; it also removes a required name that matched no
|
|
178
|
+
* reported check, so a gate job that has not reported does not come back as a
|
|
179
|
+
* pending required entry (design D6).
|
|
180
|
+
*/
|
|
181
|
+
function applyCallerNames(reported, { required, exclude, requiredFromCaller }) {
|
|
182
|
+
const requiredNames = [...new Set(names(required))]
|
|
183
|
+
const excludeNames = [...new Set(names(exclude))]
|
|
184
|
+
const reportedNames = new Set(reported.map(c => c.name))
|
|
185
|
+
const notes = []
|
|
186
|
+
|
|
187
|
+
const excluded = []
|
|
188
|
+
for (const name of excludeNames) {
|
|
189
|
+
if (reportedNames.has(name) || requiredNames.includes(name)) excluded.push(name)
|
|
190
|
+
else {
|
|
191
|
+
notes.push(
|
|
192
|
+
`the excluded name ${quoted(name)} matched no check${requiredFromCaller ? ' and no required name' : ''}, ` +
|
|
193
|
+
`so nothing was left out for it (an exclusion is an exact name, never a pattern)`
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const checks = reported
|
|
199
|
+
.filter(c => !excluded.includes(c.name))
|
|
200
|
+
.map(c => ({ name: c.name, state: c.state, required: requiredFromCaller ? requiredNames.includes(c.name) : c.required === true }))
|
|
201
|
+
if (requiredFromCaller) {
|
|
202
|
+
// A required check that has not reported at all is in no document, so it is
|
|
203
|
+
// visible only when it is named: it becomes a pending required entry.
|
|
204
|
+
for (const name of requiredNames) {
|
|
205
|
+
if (!reportedNames.has(name) && !excluded.includes(name)) checks.push({ name, state: 'pending', required: true })
|
|
206
|
+
}
|
|
207
|
+
if (!requiredNames.length) {
|
|
208
|
+
notes.push('the required set was not given, so every check reads as not required: a failed check reads amber, never red')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { checks, excluded, notes }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The pipeline input from the document `gh pr view <n> --json
|
|
216
|
+
* headRefOid,statusCheckRollup` prints (design D3). Never throws.
|
|
217
|
+
*
|
|
218
|
+
* @param {unknown} doc
|
|
219
|
+
* @param {{ required?: string[], exclude?: string[] }} [opts]
|
|
220
|
+
* @returns {{ ok: true, input: { sha: string, checks: Array, excluded: string[] }, notes: string[] } | { ok: false, reason: string }}
|
|
221
|
+
*/
|
|
222
|
+
export function pipelineFromGithub(doc, { required, exclude } = {}) {
|
|
223
|
+
const hasSha = isObject(doc) && isName(doc.headRefOid)
|
|
224
|
+
const hasList = isObject(doc) && Array.isArray(doc.statusCheckRollup)
|
|
225
|
+
if (!hasSha || !hasList) {
|
|
226
|
+
const lacking = [
|
|
227
|
+
...(hasSha ? [] : ['no head commit (headRefOid)']),
|
|
228
|
+
...(hasList ? [] : ['no check list (statusCheckRollup)'])
|
|
229
|
+
]
|
|
230
|
+
return refuse(
|
|
231
|
+
`the document carries ${lacking.join(' and ')}: it is not what "gh pr view <n> --json headRefOid,statusCheckRollup" prints`
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// One entry per check; a re-run (the same name under the same workflow) is
|
|
236
|
+
// read from the run that started last.
|
|
237
|
+
const byKey = new Map()
|
|
238
|
+
for (let i = 0; i < doc.statusCheckRollup.length; i++) {
|
|
239
|
+
const raw = doc.statusCheckRollup[i]
|
|
240
|
+
const entry = isObject(raw) ? githubEntry(raw) : null
|
|
241
|
+
if (!entry) return refuse(`statusCheckRollup[${i}] is not a check run or a commit status with a name`)
|
|
242
|
+
const key = `${entry.kind}\u0000${entry.group}\u0000${entry.name}`
|
|
243
|
+
if (!byKey.has(key)) byKey.set(key, [])
|
|
244
|
+
byKey.get(key).push(entry)
|
|
245
|
+
}
|
|
246
|
+
const reported = []
|
|
247
|
+
const unordered = []
|
|
248
|
+
const leftOut = names(exclude)
|
|
249
|
+
for (const runs of byKey.values()) {
|
|
250
|
+
const read = latestRun(runs)
|
|
251
|
+
reported.push(read.entry)
|
|
252
|
+
if (read.unordered && !leftOut.includes(read.entry.name)) {
|
|
253
|
+
unordered.push(
|
|
254
|
+
`the check ${quoted(read.entry.name)} was reported ${runs.length} times and a start time among them could not be read, ` +
|
|
255
|
+
`so no run can be called the last: it reads ${read.entry.state}, the least favourable state among them`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { checks, excluded, notes } = applyCallerNames(reported, { required, exclude, requiredFromCaller: true })
|
|
261
|
+
return { ok: true, input: { sha: doc.headRefOid, checks, excluded }, notes: [...unordered, ...notes] }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- GitLab ----------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The first GitLab step, from the document `glab mr view <iid> --output json`
|
|
268
|
+
* prints (design D4): a complete input with no checks when the merge request has
|
|
269
|
+
* no head pipeline, or a pending record carrying the head commit and the
|
|
270
|
+
* pipeline's identifier when it has one. The record has no check list, so it is
|
|
271
|
+
* not a well-formed pipeline input and the verdict refuses it if the second step
|
|
272
|
+
* never runs. Never throws.
|
|
273
|
+
*
|
|
274
|
+
* @param {unknown} doc
|
|
275
|
+
* @returns {{ ok: true, input: object } | { ok: true, pending: { sha: string, headPipeline: number|string } } | { ok: false, reason: string }}
|
|
276
|
+
*/
|
|
277
|
+
export function pendingFromGitlabMr(doc) {
|
|
278
|
+
if (!isObject(doc)) return refuse('the document is not a JSON object: it is not what "glab mr view <iid> --output json" prints')
|
|
279
|
+
if (!isName(doc.sha)) return refuse('the merge request document carries no head commit (sha)')
|
|
280
|
+
// An absent key is not the platform saying there is no pipeline.
|
|
281
|
+
if (!Object.prototype.hasOwnProperty.call(doc, 'head_pipeline')) {
|
|
282
|
+
return refuse('the merge request document carries no head_pipeline key, so it does not say whether a pipeline exists')
|
|
283
|
+
}
|
|
284
|
+
if (doc.head_pipeline === null) return { ok: true, input: { sha: doc.sha, checks: [], excluded: [] } }
|
|
285
|
+
const id = isObject(doc.head_pipeline) ? doc.head_pipeline.id : undefined
|
|
286
|
+
if (!(Number.isInteger(id) || isName(id))) {
|
|
287
|
+
return refuse('the merge request document carries a head_pipeline with no id, so its pipeline cannot be named')
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, pending: { sha: doc.sha, headPipeline: id } }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The state of one GitLab job. */
|
|
293
|
+
function gitlabState(job) {
|
|
294
|
+
if (job.status === 'manual') {
|
|
295
|
+
return job.allow_failure === true ? GITLAB_STATE_TABLE.manual.allowedToFail : GITLAB_STATE_TABLE.manual.notAllowedToFail
|
|
296
|
+
}
|
|
297
|
+
return lookup(GITLAB_STATE_TABLE.statuses, job.status, GITLAB_STATE_TABLE.otherwise)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The second GitLab step, from the document `glab ci get --merge-request <iid>
|
|
302
|
+
* --output json` prints and the pending record the first step wrote (design
|
|
303
|
+
* D4). The input's `sha` is the record's head commit. A job is required when it
|
|
304
|
+
* is not allowed to fail. Never throws.
|
|
305
|
+
*
|
|
306
|
+
* @param {unknown} doc
|
|
307
|
+
* @param {unknown} pending `{ sha, headPipeline }`
|
|
308
|
+
* @param {{ exclude?: string[] }} [opts]
|
|
309
|
+
* @returns {{ ok: true, input: { sha: string, checks: Array, excluded: string[] }, notes: string[] } | { ok: false, reason: string }}
|
|
310
|
+
*/
|
|
311
|
+
export function pipelineFromGitlab(doc, pending, { exclude } = {}) {
|
|
312
|
+
if (!isObject(pending) || !isName(pending.sha) || !(Number.isInteger(pending.headPipeline) || isName(pending.headPipeline))) {
|
|
313
|
+
return refuse(
|
|
314
|
+
'the record does not carry a head commit (sha) and a head pipeline (headPipeline): it is not what "interlock pipeline --gitlab-mr" writes for a merge request that has a pipeline'
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
if (!isObject(doc) || !Array.isArray(doc.jobs) || !(Number.isInteger(doc.id) || isName(doc.id))) {
|
|
318
|
+
return refuse('the document carries no pipeline id and no jobs list: it is not what "glab ci get --merge-request <iid> --output json" prints')
|
|
319
|
+
}
|
|
320
|
+
if (String(doc.id) !== String(pending.headPipeline)) {
|
|
321
|
+
return refuse(`the document describes pipeline ${doc.id}, not the merge request's head pipeline ${pending.headPipeline}`)
|
|
322
|
+
}
|
|
323
|
+
// A merged-result or merge-train pipeline's own commit is never the head; for
|
|
324
|
+
// any other reference the pipeline must be for the commit the record carries.
|
|
325
|
+
const mergedResult = typeof doc.ref === 'string' && MERGED_RESULT_REF.test(doc.ref)
|
|
326
|
+
if (!mergedResult && doc.sha !== pending.sha) {
|
|
327
|
+
return refuse(
|
|
328
|
+
`the pipeline ran on commit ${quoted(doc.sha)}, not the merge request's head ${pending.sha}, and its reference (${quoted(doc.ref)}) is not a merged-result one`
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const reported = []
|
|
333
|
+
for (let i = 0; i < doc.jobs.length; i++) {
|
|
334
|
+
const job = doc.jobs[i]
|
|
335
|
+
if (!isObject(job) || !isName(job.name)) return refuse(`jobs[${i}] is not a job with a name`)
|
|
336
|
+
reported.push({ name: job.name, state: gitlabState(job), required: job.allow_failure === false })
|
|
337
|
+
}
|
|
338
|
+
const { checks, excluded, notes } = applyCallerNames(reported, { exclude, requiredFromCaller: false })
|
|
339
|
+
return { ok: true, input: { sha: pending.sha, checks, excluded }, notes }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- the one rule ------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether a value is a well-formed pipeline input (design D7): an object
|
|
346
|
+
* carrying a string `sha` and a list `checks` in which every entry is an object
|
|
347
|
+
* with a non-empty string `name`, a `state` that is one of the four, and a
|
|
348
|
+
* boolean `required`; an `excluded` list of names may be present. The reason
|
|
349
|
+
* names the first fault. Never throws.
|
|
350
|
+
*
|
|
351
|
+
* @param {unknown} value
|
|
352
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
353
|
+
*/
|
|
354
|
+
export function validatePipelineInput(value) {
|
|
355
|
+
if (!isObject(value)) return refuse(`it does not hold a JSON object (found ${typeOf(value)})`)
|
|
356
|
+
if (!Object.prototype.hasOwnProperty.call(value, 'checks')) {
|
|
357
|
+
// An absent or misspelt list and an empty list are different statements.
|
|
358
|
+
const record = Object.prototype.hasOwnProperty.call(value, 'headPipeline')
|
|
359
|
+
? ` — it is the pending record of a GitLab merge request (head pipeline ${value.headPipeline}): run "interlock pipeline --gitlab" to complete it`
|
|
360
|
+
: ''
|
|
361
|
+
return refuse(`it carries no "checks" list${record}`)
|
|
362
|
+
}
|
|
363
|
+
if (!Array.isArray(value.checks)) return refuse(`its "checks" is not a list (found ${typeOf(value.checks)})`)
|
|
364
|
+
if (!isName(value.sha)) return refuse('it carries no "sha": a string naming the commit the checks belong to')
|
|
365
|
+
for (let i = 0; i < value.checks.length; i++) {
|
|
366
|
+
const check = value.checks[i]
|
|
367
|
+
if (!isObject(check)) return refuse(`checks[${i}] is not an object (found ${typeOf(check)})`)
|
|
368
|
+
if (!isName(check.name)) return refuse(`checks[${i}] carries no name: a non-empty string`)
|
|
369
|
+
if (!PIPELINE_STATES.includes(check.state)) {
|
|
370
|
+
return refuse(`checks[${i}] (${quoted(check.name)}) has the state ${quoted(check.state)}, which is not one of ${PIPELINE_STATES.join(' | ')}`)
|
|
371
|
+
}
|
|
372
|
+
if (typeof check.required !== 'boolean') {
|
|
373
|
+
return refuse(`checks[${i}] (${quoted(check.name)}) has a "required" that is not true or false`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (value.excluded !== undefined && !(Array.isArray(value.excluded) && value.excluded.every(isName))) {
|
|
377
|
+
return refuse('its "excluded" is not a list of check names')
|
|
378
|
+
}
|
|
379
|
+
return { ok: true }
|
|
380
|
+
}
|
|
@@ -34,14 +34,21 @@ function normalizePaths(changedPaths) {
|
|
|
34
34
|
/**
|
|
35
35
|
* Select the review dimensions for one run's observed changed paths.
|
|
36
36
|
*
|
|
37
|
+
* `domain` is the one dimension a path classifier cannot decide: it needs the
|
|
38
|
+
* graph and the change's own specs. The caller selects its capabilities
|
|
39
|
+
* (`selectDomainCapabilities` in `./domain-context.mjs`) and passes the result as
|
|
40
|
+
* `{ domain }`; called with one argument this returns exactly what it always did.
|
|
41
|
+
*
|
|
37
42
|
* @param {string[]} changedPaths
|
|
43
|
+
* @param {{domain?: {capabilities: Array<{capability: string, via?: string, files?: string[]}>}|null}} [options]
|
|
38
44
|
* @returns {{dimensions: string[], reasons: Record<string, string>}}
|
|
39
45
|
* `dimensions` always starts with the four always-on names, in order, then
|
|
40
|
-
* `devops` and/or `security` when their signal fired
|
|
46
|
+
* `devops` and/or `security` when their signal fired, then `domain` when
|
|
47
|
+
* `options.domain` selected a capability. `reasons` carries one
|
|
41
48
|
* entry per optional dimension actually added — never for the always-on
|
|
42
49
|
* four, which need no justification.
|
|
43
50
|
*/
|
|
44
|
-
export function selectDimensions(changedPaths) {
|
|
51
|
+
export function selectDimensions(changedPaths, options) {
|
|
45
52
|
const paths = normalizePaths(changedPaths)
|
|
46
53
|
const dimensions = [...ALWAYS_ON_DIMENSIONS]
|
|
47
54
|
const reasons = {}
|
|
@@ -66,5 +73,20 @@ export function selectDimensions(changedPaths) {
|
|
|
66
73
|
reasons.security = `authentication/permissions/tenancy path(s): ${authSignal.evidence}`
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
const domainCapabilities = Array.isArray(options?.domain?.capabilities) ? options.domain.capabilities : []
|
|
77
|
+
if (domainCapabilities.length) {
|
|
78
|
+
dimensions.push('domain')
|
|
79
|
+
reasons.domain =
|
|
80
|
+
`living requirements of ${domainCapabilities.length} capability(ies): ` +
|
|
81
|
+
domainCapabilities.map(describeCapability).join('; ')
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
return { dimensions, reasons }
|
|
70
85
|
}
|
|
86
|
+
|
|
87
|
+
function describeCapability(entry) {
|
|
88
|
+
const files = Array.isArray(entry?.files) ? entry.files : []
|
|
89
|
+
return files.length
|
|
90
|
+
? `${entry.capability} (linked to ${files.join(', ')})`
|
|
91
|
+
: `${entry.capability} (declared by the change)`
|
|
92
|
+
}
|