@renzrollon/interlock 1.5.0 → 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 +104 -0
- package/README.md +7 -8
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +21 -20
- 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/coverage.mjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// The coverage reader — what an lcov report says about a whole project and about
|
|
2
|
+
// the lines one change added.
|
|
3
|
+
//
|
|
4
|
+
// Nothing in the codebase read a coverage report before this: `coverage.report_path`
|
|
5
|
+
// was written into a profile and consumed by nothing, and the verify agent's
|
|
6
|
+
// result carried no figures, so the merge verdict's coverage signal had no
|
|
7
|
+
// producer. This module is the pure half of that producer. The CLI owns git, the
|
|
8
|
+
// filesystem and the clock (`interlock verify baseline`, `interlock verify
|
|
9
|
+
// coverage`); every function here takes text or plain objects and returns plain
|
|
10
|
+
// objects. No fs, no git, no clock.
|
|
11
|
+
//
|
|
12
|
+
// One thing the figures cannot say is what the report never saw. A report lists
|
|
13
|
+
// the files the tests loaded, so a new source file no test loads is absent from
|
|
14
|
+
// it, and its lines count neither for nor against any figure: the change that
|
|
15
|
+
// most needs the signal would read "nothing to judge". `unreportedChangedSource`
|
|
16
|
+
// names those files, from the report and the diff alone, and the coverage input
|
|
17
|
+
// carries them so the verdict can say so (design D13 of
|
|
18
|
+
// label-and-harden-the-merge-verdict). The changed-line figure itself is
|
|
19
|
+
// unchanged: it still counts only lines the report instruments.
|
|
20
|
+
//
|
|
21
|
+
// The one thing worth being strict about is the path. An lcov `SF:` line is
|
|
22
|
+
// spelt however the tool that wrote it felt like — `./lib/a.mjs`, an absolute
|
|
23
|
+
// path, a path relative to the directory the report sits in — and a diff hunk
|
|
24
|
+
// names `lib/a.mjs`. Matching the first against the second by string would give
|
|
25
|
+
// "no changed lines" for a file that is fully covered, which reads exactly like
|
|
26
|
+
// a change that touched no code. So every record is resolved to a repository-
|
|
27
|
+
// relative path through `canonicalizePath` before anything is matched, and a
|
|
28
|
+
// record that resolves outside the repository is counted and dropped, never
|
|
29
|
+
// guessed at. design D2 of judge-against-base-and-living-specs.
|
|
30
|
+
|
|
31
|
+
import { isCoverableSource } from './artifacts.mjs'
|
|
32
|
+
import { canonicalizePath } from './risk.mjs'
|
|
33
|
+
|
|
34
|
+
/** The identifier a coverage baseline file carries. */
|
|
35
|
+
export const BASELINE_SCHEMA = 'interlock.coverage-baseline/1'
|
|
36
|
+
|
|
37
|
+
/** Percentages round to one decimal, as `judgeCoverageResult` does. */
|
|
38
|
+
function pct(numerator, denominator) {
|
|
39
|
+
if (!(denominator > 0)) return null
|
|
40
|
+
return Math.round((numerator / denominator) * 1000) / 10
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lexically normalise a slash-separated path: collapse `.`, `..` and repeated
|
|
45
|
+
* separators. Keeps a leading `/` or a `C:` drive prefix; a `..` that would climb
|
|
46
|
+
* above the start of a relative path is kept, so the caller can see it escaped.
|
|
47
|
+
*/
|
|
48
|
+
function lexical(path) {
|
|
49
|
+
const slashed = path.replace(/\\/g, '/')
|
|
50
|
+
const drive = slashed.match(/^[a-zA-Z]:(?=\/)/)?.[0] ?? ''
|
|
51
|
+
const body = drive ? slashed.slice(drive.length) : slashed
|
|
52
|
+
const absolute = body.startsWith('/')
|
|
53
|
+
const out = []
|
|
54
|
+
for (const segment of body.split('/')) {
|
|
55
|
+
if (!segment || segment === '.') continue
|
|
56
|
+
if (segment === '..') {
|
|
57
|
+
if (out.length && out[out.length - 1] !== '..') out.pop()
|
|
58
|
+
else if (!absolute) out.push('..')
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
out.push(segment)
|
|
62
|
+
}
|
|
63
|
+
return `${drive}${absolute ? '/' : ''}${out.join('/')}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isAbsolute = path => path.startsWith('/') || /^[a-zA-Z]:\//.test(path)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve one `SF:` value to a canonical repository-relative path, or null when
|
|
70
|
+
* it lies outside the repository. A relative value is read against `reportDir`
|
|
71
|
+
* (the directory a profile's `report_path` is relative to), an absolute one
|
|
72
|
+
* against nothing.
|
|
73
|
+
*/
|
|
74
|
+
function resolveSourceFile(sf, root, reportDir) {
|
|
75
|
+
const given = sf.trim().replace(/\\/g, '/')
|
|
76
|
+
if (!given) return null
|
|
77
|
+
const absolute = lexical(isAbsolute(given) ? given : `${reportDir}/${given}`)
|
|
78
|
+
const base = lexical(root).replace(/\/+$/, '')
|
|
79
|
+
// `base + '/'` is the boundary: /work/repo-other shares a name prefix with
|
|
80
|
+
// /work/repo and is not inside it.
|
|
81
|
+
if (!absolute.startsWith(`${base}/`)) return null
|
|
82
|
+
return canonicalizePath(absolute.slice(base.length + 1))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse an lcov report into per-file records.
|
|
87
|
+
*
|
|
88
|
+
* A record is `{ path, lines: Map<line, hits>, lf, lh }`. `lf`/`lh` are the
|
|
89
|
+
* report's own LF/LH summary when the record carries one, and are counted from
|
|
90
|
+
* its DA lines otherwise. A path listed twice is one record, its DA hits summed.
|
|
91
|
+
* `outsideRoot` counts the records that resolved outside the repository: they
|
|
92
|
+
* are not kept and take no part in any figure.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} text
|
|
95
|
+
* @param {{ root: string, reportDir?: string }} opts
|
|
96
|
+
* @returns {{ records: Array<{path: string, lines: Map<number, number>, lf: number, lh: number}>, outsideRoot: number }}
|
|
97
|
+
*/
|
|
98
|
+
export function parseLcov(text, { root, reportDir } = {}) {
|
|
99
|
+
const dir = reportDir || root
|
|
100
|
+
const byPath = new Map()
|
|
101
|
+
let outsideRoot = 0
|
|
102
|
+
let current = null // { path|null, lines, lf, lh } for the record being read
|
|
103
|
+
|
|
104
|
+
const finish = () => {
|
|
105
|
+
if (!current) return
|
|
106
|
+
const done = current
|
|
107
|
+
current = null
|
|
108
|
+
if (done.path === null) {
|
|
109
|
+
outsideRoot += 1
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const existing = byPath.get(done.path)
|
|
113
|
+
if (!existing) {
|
|
114
|
+
byPath.set(done.path, done)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
for (const [line, hits] of done.lines) existing.lines.set(line, (existing.lines.get(line) ?? 0) + hits)
|
|
118
|
+
// Two reports of one file: the summaries no longer describe the union, so
|
|
119
|
+
// both are dropped and the totals are counted from the merged lines.
|
|
120
|
+
existing.lf = null
|
|
121
|
+
existing.lh = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
125
|
+
const line = raw.trim()
|
|
126
|
+
if (!line) continue
|
|
127
|
+
if (line.startsWith('SF:')) {
|
|
128
|
+
finish()
|
|
129
|
+
current = {
|
|
130
|
+
path: resolveSourceFile(line.slice(3), root, dir),
|
|
131
|
+
lines: new Map(),
|
|
132
|
+
lf: null,
|
|
133
|
+
lh: null
|
|
134
|
+
}
|
|
135
|
+
} else if (!current) {
|
|
136
|
+
continue
|
|
137
|
+
} else if (line.startsWith('DA:')) {
|
|
138
|
+
const [lineNo, hits] = line.slice(3).split(',')
|
|
139
|
+
const n = Number(lineNo)
|
|
140
|
+
const h = Number(hits)
|
|
141
|
+
if (Number.isInteger(n) && n > 0 && Number.isFinite(h)) {
|
|
142
|
+
current.lines.set(n, (current.lines.get(n) ?? 0) + h)
|
|
143
|
+
}
|
|
144
|
+
} else if (line.startsWith('LF:')) {
|
|
145
|
+
const n = Number(line.slice(3))
|
|
146
|
+
if (Number.isInteger(n) && n >= 0) current.lf = n
|
|
147
|
+
} else if (line.startsWith('LH:')) {
|
|
148
|
+
const n = Number(line.slice(3))
|
|
149
|
+
if (Number.isInteger(n) && n >= 0) current.lh = n
|
|
150
|
+
} else if (line === 'end_of_record') {
|
|
151
|
+
finish()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
finish() // a report truncated before its last end_of_record
|
|
155
|
+
|
|
156
|
+
const records = [...byPath.values()].map(rec => {
|
|
157
|
+
const counted = [...rec.lines.values()]
|
|
158
|
+
return {
|
|
159
|
+
path: rec.path,
|
|
160
|
+
lines: rec.lines,
|
|
161
|
+
lf: rec.lf ?? counted.length,
|
|
162
|
+
lh: rec.lh ?? counted.filter(hits => hits > 0).length
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
return { records, outsideRoot }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The project total over every record. `totalPct` is null when no line is
|
|
170
|
+
* instrumented — a report with nothing in it has no percentage, and zero would
|
|
171
|
+
* read as "tested nothing".
|
|
172
|
+
*
|
|
173
|
+
* @param {Array<{lf: number, lh: number}>} records
|
|
174
|
+
* @returns {{ totalPct: number|null, lf: number, lh: number }}
|
|
175
|
+
*/
|
|
176
|
+
export function coverageTotals(records) {
|
|
177
|
+
let lf = 0
|
|
178
|
+
let lh = 0
|
|
179
|
+
for (const rec of Array.isArray(records) ? records : []) {
|
|
180
|
+
lf += rec.lf
|
|
181
|
+
lh += rec.lh
|
|
182
|
+
}
|
|
183
|
+
return { totalPct: pct(lh, lf), lf, lh }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
187
|
+
|
|
188
|
+
/** The path a `+++ ` header names, or null for a deletion or an unusable name. */
|
|
189
|
+
function newSidePath(header) {
|
|
190
|
+
let name = header.slice(4).replace(/\t.*$/, '').trim()
|
|
191
|
+
if (name === '/dev/null') return null
|
|
192
|
+
if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) {
|
|
193
|
+
// git quotes a path with unusual bytes; unescape what a repository path can carry.
|
|
194
|
+
name = name.slice(1, -1).replace(/\\(["\\])/g, '$1')
|
|
195
|
+
}
|
|
196
|
+
if (!name.startsWith('b/')) return null
|
|
197
|
+
return canonicalizePath(name.slice(2))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The lines a diff adds, by new-side path, from `git diff -U0 --no-color
|
|
202
|
+
* --no-ext-diff <base>...HEAD`. A rename lands on its new path; a deletion
|
|
203
|
+
* (`+++ /dev/null`) adds nothing and is not a key; a pure-deletion hunk
|
|
204
|
+
* (`+start,0`) adds nothing.
|
|
205
|
+
*
|
|
206
|
+
* A hunk is read by its own counts, so an added line whose content begins `++ `
|
|
207
|
+
* (which looks like a `+++ ` header) is not mistaken for one.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} diffText
|
|
210
|
+
* @returns {Map<string, Set<number>>}
|
|
211
|
+
*/
|
|
212
|
+
export function parseAddedLines(diffText) {
|
|
213
|
+
const added = new Map()
|
|
214
|
+
let path = null
|
|
215
|
+
let remOld = 0
|
|
216
|
+
let remNew = 0
|
|
217
|
+
let next = 0
|
|
218
|
+
|
|
219
|
+
for (const line of String(diffText ?? '').split('\n')) {
|
|
220
|
+
if (remOld > 0 || remNew > 0) {
|
|
221
|
+
if (line.startsWith('\\')) continue // ""
|
|
222
|
+
if (remOld > 0 && line.startsWith('-')) {
|
|
223
|
+
remOld -= 1
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (remNew > 0 && line.startsWith('+')) {
|
|
227
|
+
remNew -= 1
|
|
228
|
+
if (path !== null) {
|
|
229
|
+
if (!added.has(path)) added.set(path, new Set())
|
|
230
|
+
added.get(path).add(next)
|
|
231
|
+
}
|
|
232
|
+
next += 1
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
// A hunk that ends early (a truncated diff): fall through to the headers.
|
|
236
|
+
remOld = 0
|
|
237
|
+
remNew = 0
|
|
238
|
+
}
|
|
239
|
+
if (line.startsWith('diff --git ')) {
|
|
240
|
+
path = null
|
|
241
|
+
} else if (line.startsWith('+++ ')) {
|
|
242
|
+
path = newSidePath(line)
|
|
243
|
+
} else {
|
|
244
|
+
const hunk = line.match(HUNK)
|
|
245
|
+
if (hunk) {
|
|
246
|
+
remOld = hunk[1] === undefined ? 1 : Number(hunk[1])
|
|
247
|
+
remNew = hunk[3] === undefined ? 1 : Number(hunk[3])
|
|
248
|
+
next = Number(hunk[2])
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return added
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Coverage of the lines a change added. An added line counts only if the report
|
|
257
|
+
* instruments it (a DA record exists): a comment, a blank line or a line in a
|
|
258
|
+
* file the report never saw is not a "changed line", so it can neither pass nor
|
|
259
|
+
* fail the figure. `diffPct` is null when no added line is measured — absent,
|
|
260
|
+
* never 100 and never 0.
|
|
261
|
+
*
|
|
262
|
+
* @param {Array<{path: string, lines: Map<number, number>}>} records
|
|
263
|
+
* @param {Map<string, Set<number>>} added
|
|
264
|
+
* @returns {{ changedLines: number, coveredLines: number, diffPct: number|null }}
|
|
265
|
+
*/
|
|
266
|
+
export function changedLineCoverage(records, added) {
|
|
267
|
+
const byPath = new Map((Array.isArray(records) ? records : []).map(rec => [rec.path, rec]))
|
|
268
|
+
let changedLines = 0
|
|
269
|
+
let coveredLines = 0
|
|
270
|
+
for (const [path, lines] of added instanceof Map ? added : []) {
|
|
271
|
+
const rec = byPath.get(path)
|
|
272
|
+
if (!rec) continue
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
if (!rec.lines.has(line)) continue
|
|
275
|
+
changedLines += 1
|
|
276
|
+
if (rec.lines.get(line) > 0) coveredLines += 1
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { changedLines, coveredLines, diffPct: pct(coveredLines, changedLines) }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The changed source files a report does not list: the files the diff adds at
|
|
284
|
+
* least one line to, that the project counts as coverable source (never a test,
|
|
285
|
+
* a configuration file or a document), and that the report holds no record for.
|
|
286
|
+
* `files` is in path order; `addedLines` is the lines the diff adds in them.
|
|
287
|
+
*
|
|
288
|
+
* A report cannot tell a file no test loaded from one the project left out of
|
|
289
|
+
* coverage on purpose, so this names and never judges.
|
|
290
|
+
*
|
|
291
|
+
* @param {Array<{path: string}>} records
|
|
292
|
+
* @param {Map<string, Set<number>>} added
|
|
293
|
+
* @returns {{ files: string[], addedLines: number }}
|
|
294
|
+
*/
|
|
295
|
+
export function unreportedChangedSource(records, added) {
|
|
296
|
+
const reported = new Set((Array.isArray(records) ? records : []).map(rec => rec.path))
|
|
297
|
+
const files = []
|
|
298
|
+
let addedLines = 0
|
|
299
|
+
for (const [path, lines] of added instanceof Map ? added : []) {
|
|
300
|
+
const count = lines instanceof Set ? lines.size : 0
|
|
301
|
+
if (!count || reported.has(path) || !isCoverableSource(path)) continue
|
|
302
|
+
files.push(path)
|
|
303
|
+
addedLines += count
|
|
304
|
+
}
|
|
305
|
+
files.sort()
|
|
306
|
+
return { files, addedLines }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The coverage input the merge verdict reads: exactly the nine fields
|
|
311
|
+
* `readCoverage` consumes, and nothing else. Seven are figures; `unreportedFiles`
|
|
312
|
+
* and `unreportedAddedLines` name the changed source the report does not list.
|
|
313
|
+
* They are null when coverage is not configured and when the caller did not
|
|
314
|
+
* hand them in: not stated is never an empty list.
|
|
315
|
+
*
|
|
316
|
+
* With no coverage configured every figure is null. With no baseline every base
|
|
317
|
+
* field is null and the head and changed-line figures remain. A baseline whose
|
|
318
|
+
* commit is not an ancestor of the head is passed through marked
|
|
319
|
+
* `baseIsAncestor: false` — dropped it would read as "no baseline", and banded
|
|
320
|
+
* it would judge a change against code it never descended from; the reader
|
|
321
|
+
* decides what that means.
|
|
322
|
+
*
|
|
323
|
+
* @param {{
|
|
324
|
+
* configured: boolean,
|
|
325
|
+
* head?: { totalPct: number|null }|null,
|
|
326
|
+
* baseline?: { sha: string, totalPct: number }|null,
|
|
327
|
+
* baseIsAncestor?: boolean|null,
|
|
328
|
+
* changed?: { changedLines: number, diffPct: number|null }|null,
|
|
329
|
+
* unreported?: { files: string[], addedLines: number }|null
|
|
330
|
+
* }} parts
|
|
331
|
+
*/
|
|
332
|
+
export function buildCoverageInput({ configured, head, baseline, baseIsAncestor, changed, unreported } = {}) {
|
|
333
|
+
if (configured !== true) {
|
|
334
|
+
return {
|
|
335
|
+
configured: false,
|
|
336
|
+
headTotalPct: null,
|
|
337
|
+
baseTotalPct: null,
|
|
338
|
+
baseSha: null,
|
|
339
|
+
baseIsAncestor: null,
|
|
340
|
+
diffPct: null,
|
|
341
|
+
changedLines: null,
|
|
342
|
+
unreportedFiles: null,
|
|
343
|
+
unreportedAddedLines: null
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const hasBaseline = baseline !== null && baseline !== undefined
|
|
347
|
+
const stated = unreported !== null && typeof unreported === 'object' && Array.isArray(unreported.files)
|
|
348
|
+
return {
|
|
349
|
+
configured: true,
|
|
350
|
+
headTotalPct: head?.totalPct ?? null,
|
|
351
|
+
baseTotalPct: hasBaseline ? (baseline.totalPct ?? null) : null,
|
|
352
|
+
baseSha: hasBaseline ? (baseline.sha ?? null) : null,
|
|
353
|
+
baseIsAncestor: hasBaseline && typeof baseIsAncestor === 'boolean' ? baseIsAncestor : null,
|
|
354
|
+
diffPct: changed?.diffPct ?? null,
|
|
355
|
+
changedLines: changed?.changedLines ?? null,
|
|
356
|
+
unreportedFiles: stated ? [...unreported.files] : null,
|
|
357
|
+
unreportedAddedLines: stated ? (unreported.addedLines ?? null) : null
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Check a parsed baseline file. `ref` is the branch the baseline was taken on and
|
|
363
|
+
* may be null (a detached HEAD); everything else is required.
|
|
364
|
+
*
|
|
365
|
+
* @param {unknown} obj
|
|
366
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
367
|
+
*/
|
|
368
|
+
export function validateBaseline(obj) {
|
|
369
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
370
|
+
return { ok: false, problems: ['the baseline is not a JSON object'] }
|
|
371
|
+
}
|
|
372
|
+
const problems = []
|
|
373
|
+
if (obj.schema !== BASELINE_SCHEMA) {
|
|
374
|
+
problems.push(`schema must be ${BASELINE_SCHEMA}, found ${JSON.stringify(obj.schema ?? null)}`)
|
|
375
|
+
}
|
|
376
|
+
if (typeof obj.sha !== 'string' || !obj.sha.trim()) problems.push('sha must be a non-empty commit id')
|
|
377
|
+
if (obj.ref !== undefined && obj.ref !== null && typeof obj.ref !== 'string') {
|
|
378
|
+
problems.push('ref must be a branch name or null')
|
|
379
|
+
}
|
|
380
|
+
if (typeof obj.totalPct !== 'number' || !Number.isFinite(obj.totalPct) || obj.totalPct < 0 || obj.totalPct > 100) {
|
|
381
|
+
problems.push('totalPct must be a number from 0 to 100')
|
|
382
|
+
}
|
|
383
|
+
if (typeof obj.generatedAt !== 'string' || !obj.generatedAt.trim()) {
|
|
384
|
+
problems.push('generatedAt must be a timestamp string')
|
|
385
|
+
}
|
|
386
|
+
return { ok: problems.length === 0, problems }
|
|
387
|
+
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -917,12 +917,18 @@ function checkPermissions(root, required, { home, sources } = {}) {
|
|
|
917
917
|
* Advice, never a gate: push notification is an optional feature, so this row
|
|
918
918
|
* is `ok` or `skip` and NEVER `fail` — an operator who never configured it has
|
|
919
919
|
* not degraded their run. Reads the same names `lib/notify.mjs` reads — the
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
920
|
+
* shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL`, and never the
|
|
921
|
+
* host's option variables — by literal string rather than by importing the
|
|
922
|
+
* transport. The default server comes from `notify-defaults.mjs`, so this row
|
|
923
|
+
* cannot name a different one. The topic value is never read into a string
|
|
924
|
+
* that could reach the report; only the server URL, which is not a
|
|
925
|
+
* capability, is echoed.
|
|
926
|
+
*
|
|
927
|
+
* The row reports the environment the check ran in. A plugin option reaches
|
|
928
|
+
* the session's commands through its own session-start hook, which runs
|
|
929
|
+
* beside the preflight, so a check at session start cannot see one; the
|
|
930
|
+
* `skip` detail says so and names both ways to set a topic
|
|
931
|
+
* (clear-the-plugin-directory-holds design D7).
|
|
926
932
|
*/
|
|
927
933
|
function configuredValue(env, name) {
|
|
928
934
|
return env && typeof env[name] === 'string' && env[name].trim() ? env[name].trim() : ''
|
|
@@ -930,19 +936,17 @@ function configuredValue(env, name) {
|
|
|
930
936
|
|
|
931
937
|
function checkNotify(env) {
|
|
932
938
|
try {
|
|
933
|
-
const topic =
|
|
934
|
-
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC') || configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
|
|
939
|
+
const topic = configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
|
|
935
940
|
if (!topic) {
|
|
936
941
|
return check(
|
|
937
942
|
'notify',
|
|
938
943
|
'skip',
|
|
939
|
-
|
|
944
|
+
"no topic in this environment: set the plugin's ntfy topic option (it reaches the session's commands at " +
|
|
945
|
+
'session start, so a check that runs beside it cannot see it) or INTERLOCK_NTFY_TOPIC; optional, but an ' +
|
|
946
|
+
'unattended run that stops will not reach you'
|
|
940
947
|
)
|
|
941
948
|
}
|
|
942
|
-
const url =
|
|
943
|
-
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_URL') ||
|
|
944
|
-
configuredValue(env, 'INTERLOCK_NTFY_URL') ||
|
|
945
|
-
DEFAULT_NTFY_URL
|
|
949
|
+
const url = configuredValue(env, 'INTERLOCK_NTFY_URL') || DEFAULT_NTFY_URL
|
|
946
950
|
return check('notify', 'ok', `push configured: ${url}`)
|
|
947
951
|
} catch (err) {
|
|
948
952
|
// Fail-open, and never `fail`: an unreadable env is a reason to skip the
|
package/lib/drift.mjs
CHANGED
|
@@ -96,9 +96,12 @@ export function detectUnarchived(root = '.') {
|
|
|
96
96
|
return found
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Read the graph at a path, or say why we could not. Never reports absence as
|
|
101
|
+
* cleanliness. The path is the caller's, so a graph found anywhere (the state
|
|
102
|
+
* home, a run's recorded `graphPath`) is read by the same code as the root one.
|
|
103
|
+
*/
|
|
104
|
+
export function readGraphAt(path) {
|
|
102
105
|
if (!existsSync(path)) {
|
|
103
106
|
return { links: null, reason: 'no graph has been built — run /interlock:bootstrap once' }
|
|
104
107
|
}
|
|
@@ -120,18 +123,39 @@ function readGraph(root) {
|
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
/** The graph under a repository root. */
|
|
127
|
+
function readGraph(root) {
|
|
128
|
+
return readGraphAt(join(root, GRAPH_PATH))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The reliable living-spec edges of a graph: weight-1 `implements_spec` links
|
|
133
|
+
* whose source is a living spec and whose target is a `file:` node. Returns the
|
|
134
|
+
* link objects themselves, in graph order, and `[]` for a graph with no links.
|
|
135
|
+
* Staleness, orphan detection, domain selection and the spec audit all read
|
|
136
|
+
* through this, so they cannot disagree about which edges count.
|
|
137
|
+
*
|
|
138
|
+
* @param {{ links?: any[]|null }|null|undefined} graph
|
|
139
|
+
* @returns {any[]}
|
|
140
|
+
*/
|
|
141
|
+
export function livingSpecEdges(graph) {
|
|
142
|
+
const links = graph && Array.isArray(graph.links) ? graph.links : []
|
|
143
|
+
return links.filter(link => {
|
|
144
|
+
if (!link || link.relation !== 'implements_spec') return false
|
|
145
|
+
if (link.weight !== RELIABLE_EDGE_WEIGHT) return false
|
|
146
|
+
const spec = typeof link.source_file === 'string' ? link.source_file : ''
|
|
147
|
+
if (!spec.startsWith(LIVING_SPEC_PREFIX)) return false
|
|
148
|
+
const target = typeof link.target === 'string' ? link.target : ''
|
|
149
|
+
return target.startsWith('file:') && target.length > 'file:'.length
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
123
153
|
/** spec path -> set of files it claims to describe. Living specs, weight-1 edges only. */
|
|
124
154
|
function specToFiles(links) {
|
|
125
155
|
const bySpec = new Map()
|
|
126
|
-
for (const link of links) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const spec = typeof link.source_file === 'string' ? link.source_file : ''
|
|
130
|
-
if (!spec.startsWith(LIVING_SPEC_PREFIX)) continue
|
|
131
|
-
const target = typeof link.target === 'string' ? link.target : ''
|
|
132
|
-
if (!target.startsWith('file:')) continue
|
|
133
|
-
const file = target.slice('file:'.length)
|
|
134
|
-
if (!file) continue
|
|
156
|
+
for (const link of livingSpecEdges({ links })) {
|
|
157
|
+
const spec = link.source_file
|
|
158
|
+
const file = link.target.slice('file:'.length)
|
|
135
159
|
if (!bySpec.has(spec)) bySpec.set(spec, new Set())
|
|
136
160
|
bySpec.get(spec).add(file)
|
|
137
161
|
}
|
package/lib/findings.mjs
CHANGED
|
@@ -55,7 +55,12 @@ export const FINDING_SCHEMA = {
|
|
|
55
55
|
line: { type: 'integer' },
|
|
56
56
|
title: { type: 'string' },
|
|
57
57
|
description: { type: 'string' },
|
|
58
|
-
suggestion: { type: 'string' }
|
|
58
|
+
suggestion: { type: 'string' },
|
|
59
|
+
requirement: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description:
|
|
62
|
+
'Optional. The living requirement this finding rests on, as openspec/specs/<capability>/spec.md#<requirement heading>. A domain blocker carries one; the CLI checks its shape and never drops the finding for a bad one.'
|
|
63
|
+
}
|
|
59
64
|
},
|
|
60
65
|
required: ['severity', 'file', 'title', 'description']
|
|
61
66
|
}
|
|
@@ -129,8 +134,17 @@ function fileOf(finding) {
|
|
|
129
134
|
return trimmed
|
|
130
135
|
}
|
|
131
136
|
|
|
132
|
-
// Accept
|
|
133
|
-
//
|
|
137
|
+
// Accept a bare array, a single { dimension, findings } object, an array of
|
|
138
|
+
// those — reviewers emit all three shapes depending on fan-out — or the record
|
|
139
|
+
// `interlock review --json` prints, whose `surviving` list is the findings a
|
|
140
|
+
// gate or a remediation plan is meant to read.
|
|
141
|
+
//
|
|
142
|
+
// Total on purpose: an input in none of those shapes yields no findings rather
|
|
143
|
+
// than throwing, because a run adjudicating mid-flight must not crash on it.
|
|
144
|
+
// That is exactly why "no findings" from here proves nothing by itself. A
|
|
145
|
+
// reader that cannot find the findings has not established that there are none,
|
|
146
|
+
// so every consumer asks `carriesFindingsList` first and states its own
|
|
147
|
+
// consequence (design D10 of label-and-harden-the-merge-verdict).
|
|
134
148
|
function flatten(input) {
|
|
135
149
|
if (Array.isArray(input)) {
|
|
136
150
|
return input.flatMap(entry =>
|
|
@@ -142,9 +156,32 @@ function flatten(input) {
|
|
|
142
156
|
if (input && Array.isArray(input.findings)) {
|
|
143
157
|
return input.findings.map(f => ({ dimension: input.dimension, ...f }))
|
|
144
158
|
}
|
|
159
|
+
if (input && Array.isArray(input.surviving)) return [...input.surviving]
|
|
145
160
|
return []
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Whether an input carries a findings list at all: a list (an empty one is a
|
|
165
|
+
* statement that nothing was found), a list of per-dimension records, one such
|
|
166
|
+
* record, or a review record with its `surviving` list. Anything else — `{}`,
|
|
167
|
+
* null, text, a number, an object with other keys — carries none, and no reader
|
|
168
|
+
* may count it as zero findings.
|
|
169
|
+
*
|
|
170
|
+
* @param {unknown} input
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function carriesFindingsList(input) {
|
|
174
|
+
if (Array.isArray(input)) return true
|
|
175
|
+
if (!input || typeof input !== 'object') return false
|
|
176
|
+
return Array.isArray(input.findings) || Array.isArray(input.surviving)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What a reader says of an input that carries none: the shapes it would have read. */
|
|
180
|
+
export const NO_FINDINGS_LIST =
|
|
181
|
+
'it carries no findings list — expected a list of findings, a list of { dimension, findings } records, ' +
|
|
182
|
+
'one such record, or the record "interlock review --json" prints (its "surviving" list); ' +
|
|
183
|
+
'an empty list is how to say nothing was found'
|
|
184
|
+
|
|
148
185
|
/**
|
|
149
186
|
* Group findings by file.
|
|
150
187
|
* @returns {{ byFile: Array<{file: string, findings: Array}>, unscoped: Array }}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readIndexed, rel } from '../paths.mjs';
|
|
3
3
|
import { walkMarkdown } from './walk.mjs';
|
|
4
|
+
import { specPathMentions } from '../../spec-text.mjs';
|
|
4
5
|
|
|
5
|
-
// Any repo-relative path with an extension. Deliberately NOT restricted to a
|
|
6
|
-
// directory allowlist or an extension allowlist: every match is gated below by
|
|
7
|
-
// `knownFiles.has(mentioned)`, so the graph itself decides what is real. An
|
|
8
|
-
// allowlist here can only produce false negatives — it used to drop Go/Rust/Java
|
|
9
|
-
// layouts (cmd/, internal/, pkg/, apps/, services/) and indexed config files
|
|
10
|
-
// entirely. The leading boundary class excludes URL path segments, which are
|
|
11
|
-
// always preceded by `/`.
|
|
12
|
-
const PATH_RE = /(?:^|[\s`"'(\[])((?:[\w.@-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6})\b/gm;
|
|
13
6
|
const SYMBOL_RE = /`([A-Z][A-Za-z0-9_]{2,})`|\b([A-Z][A-Za-z0-9_]{2,}(?:Service|Client|Router|Handler|Store|Provider|Context|Schema|Util|Utils)?)\b/g;
|
|
14
7
|
|
|
15
8
|
/**
|
|
@@ -38,13 +31,10 @@ export function extractOpenspec(root, knownSymbols = new Set(), knownFiles = new
|
|
|
38
31
|
});
|
|
39
32
|
|
|
40
33
|
const seen = new Set();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
if (seen.has(mentioned)) continue;
|
|
46
|
-
seen.add(mentioned);
|
|
47
|
-
const line = text.slice(0, m.index).split('\n').length;
|
|
34
|
+
// The rule for "a spec mentions this file" is one function shared with the
|
|
35
|
+
// spec-coverage signal; the gate on files the graph indexed stays here.
|
|
36
|
+
for (const { path: mentioned, index } of specPathMentions(text)) {
|
|
37
|
+
const line = text.slice(0, index).split('\n').length;
|
|
48
38
|
if (knownFiles.has(mentioned)) {
|
|
49
39
|
edges.push({
|
|
50
40
|
source: id,
|
|
@@ -58,6 +48,7 @@ export function extractOpenspec(root, knownSymbols = new Set(), knownFiles = new
|
|
|
58
48
|
}
|
|
59
49
|
}
|
|
60
50
|
|
|
51
|
+
let m;
|
|
61
52
|
SYMBOL_RE.lastIndex = 0;
|
|
62
53
|
while ((m = SYMBOL_RE.exec(text))) {
|
|
63
54
|
const name = m[1] || m[2];
|