@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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +12 -1
  2. package/CHANGELOG.md +104 -0
  3. package/README.md +7 -8
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +21 -20
  6. package/hooks/notify-option.mjs +69 -0
  7. package/hooks/preflight.mjs +6 -42
  8. package/lib/conformance.mjs +75 -0
  9. package/lib/coverage.mjs +387 -0
  10. package/lib/doctor.mjs +17 -13
  11. package/lib/drift.mjs +36 -12
  12. package/lib/findings.mjs +40 -3
  13. package/lib/graph/extract/openspec.mjs +6 -15
  14. package/lib/limits.mjs +78 -1
  15. package/lib/merge-confidence.mjs +1302 -0
  16. package/lib/merge-evidence.mjs +527 -0
  17. package/lib/notify.mjs +10 -20
  18. package/lib/outcomes.mjs +26 -5
  19. package/lib/pipeline.mjs +380 -0
  20. package/lib/prompts/dimensions.mjs +24 -2
  21. package/lib/prompts/domain-context.mjs +271 -0
  22. package/lib/prompts/handoff.mjs +32 -1
  23. package/lib/prompts/index.mjs +57 -0
  24. package/lib/prompts/remediate.mjs +29 -5
  25. package/lib/prompts/review.mjs +21 -7
  26. package/lib/prompts/schemas.mjs +2 -0
  27. package/lib/ready.mjs +13 -1
  28. package/lib/receipt.mjs +72 -2
  29. package/lib/review-core.mjs +224 -21
  30. package/lib/risk.mjs +8 -0
  31. package/lib/run-log.mjs +44 -0
  32. package/lib/run.mjs +859 -27
  33. package/lib/spec-audit.mjs +232 -0
  34. package/lib/spec-coverage.mjs +105 -0
  35. package/lib/spec-text.mjs +257 -0
  36. package/lib/verify.mjs +17 -4
  37. package/package.json +1 -1
  38. package/shared/SESSION-SHAPES.md +1 -1
  39. package/shared/TEST-PROFILE.md +39 -2
  40. package/skills/mr/SKILL.md +161 -11
  41. package/skills/review-code/REVIEW.template.md +24 -6
  42. package/skills/review-code/SKILL.md +16 -6
  43. package/skills/review-code/dimensions/domain.md +21 -0
@@ -0,0 +1,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
- * plugin options `CLAUDE_PLUGIN_OPTION_NTFY_TOPIC` / `CLAUDE_PLUGIN_OPTION_NTFY_URL`
921
- * and the shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL` — by
922
- * literal string rather than by importing the transport. The default server
923
- * comes from `notify-defaults.mjs`, so this row cannot name a different one.
924
- * The topic value is never read into a string that could reach the report;
925
- * only the server URL, which is not a capability, is echoed.
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
- 'INTERLOCK_NTFY_TOPIC is not set — optional; an unattended run that stops will not reach you'
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
- /** Read the graph, or say why we could not. Never reports absence as cleanliness. */
100
- function readGraph(root) {
101
- const path = join(root, GRAPH_PATH)
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
- if (!link || link.relation !== 'implements_spec') continue
128
- if (link.weight !== RELIABLE_EDGE_WEIGHT) continue
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 either a bare array, a single { dimension, findings } object, or an
133
- // array of those — reviewers emit all three shapes depending on fan-out.
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
- let m;
42
- PATH_RE.lastIndex = 0;
43
- while ((m = PATH_RE.exec(text))) {
44
- const mentioned = m[1];
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];