@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,713 @@
1
+ // Adversarial review: what survives two skeptics.
2
+ //
3
+ // Every finding a review dimension raises is independently verdicted by two
4
+ // skeptics whose job is to refute it. This module turns those verdicts into a
5
+ // survival decision — and, just as importantly, into *counts*. The README sells
6
+ // the dismissal count as the evidence a review is worth reading line by line;
7
+ // that claim is only true if the number is computed rather than narrated, so it
8
+ // is computed here.
9
+ //
10
+ // The quality band is **not** reimplemented here. `lib/findings.mjs` owns
11
+ // `TOLERANCE_BAND` / `applyToleranceBand` and the gate already applies it; this
12
+ // module calls the same function so a finding cannot be judged weak by one
13
+ // threshold in review and a different one at the gate.
14
+ //
15
+ // The survival decision is pure: no fs, no agent, no spawning, no async. The
16
+ // dynamic-workflow runtime cannot `import()` and has no filesystem of its own,
17
+ // so it reaches this module by shelling out to a `interlock` subcommand — which
18
+ // means the survival path must be a plain data-in / data-out decision.
19
+ //
20
+ // The one exception is `readReviewPolicy(repoRoot)` — it reads the repo-root
21
+ // `REVIEW.md` from disk. It is never called by the workflow (which shells out);
22
+ // only the node CLI (`interlock review` / `interlock review-policy`) invokes it,
23
+ // and there fs is available. Parsing stays pure in `parseReviewPolicy(text)`, so
24
+ // the survival arithmetic below never depends on the filesystem.
25
+ //
26
+ // Feeding the gate: `applyToleranceBand` has already run by the time
27
+ // `resolveReview` returns, so pass `evaluateGate(result.surviving, { band: null,
28
+ // dismissed: [] })` — re-running the band would be harmless but re-counting
29
+ // `droppedByQuality` would double-report it.
30
+
31
+ import { existsSync, readFileSync, statSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+
34
+ import { LIMITS } from './limits.mjs'
35
+ import { SEVERITIES, TOLERANCE_BAND, applyToleranceBand } from './findings.mjs'
36
+ // The locator vocabulary lives in one module because a second surface now
37
+ // audits citations the same way (`auditHandoffEvidence` in lib/waves.mjs). Two
38
+ // definitions of "what a locator looks like" drift silently, each still passing
39
+ // its own tests. See lib/locators.mjs for what is shared and what is not.
40
+ import { CITATION_TOKEN, canonicalizePath, citesLineIn, diffIndex } from './locators.mjs'
41
+
42
+ // Accept the same three input shapes `evaluateGate` accepts (bare array, one
43
+ // `{dimension, findings}` object, or an array of those). `applyToleranceBand`
44
+ // with a disabled band is exactly that flattener and nothing else, so reuse it
45
+ // rather than keeping a second copy of the shape-sniffing in this file.
46
+ function flatten(input) {
47
+ return applyToleranceBand(input, null).reportable
48
+ }
49
+
50
+ // Most severe first: index 0 is the worst. Anything outside this list has no
51
+ // rank and is ignored when refinements are compared.
52
+ function severityRank(severity) {
53
+ const i = SEVERITIES.indexOf(severity)
54
+ return i === -1 ? Infinity : i
55
+ }
56
+
57
+ function titleOf(finding) {
58
+ return finding && typeof finding.title === 'string' ? finding.title : null
59
+ }
60
+
61
+ // Verdicts are keyed to findings by title, narrowed by file when the skeptic
62
+ // supplied one. `VERDICT_SCHEMA.file` is optional because skeptics predate it,
63
+ // so the match degrades rather than fails: a verdict carrying a file only
64
+ // adjudicates the finding in that file, while a verdict without one still
65
+ // adjudicates every finding sharing the title. That asymmetry is deliberate —
66
+ // tightening the fileless case would silently orphan every existing verdict,
67
+ // and an orphaned verdict means an unadjudicated finding survives unreviewed.
68
+ const KEY_SEP = '\u0000'
69
+
70
+ function matchKeys(finding) {
71
+ const title = titleOf(finding)
72
+ if (title === null) return []
73
+ const file = finding && typeof finding.file === 'string' ? finding.file.trim() : ''
74
+ return file ? [title + KEY_SEP + file, title] : [title]
75
+ }
76
+
77
+ function verdictKey(verdict) {
78
+ const title = verdict.findingTitle
79
+ const file = typeof verdict.file === 'string' ? verdict.file.trim() : ''
80
+ return file ? title + KEY_SEP + file : title
81
+ }
82
+
83
+ /**
84
+ * A verdict is a vote that the finding is **not** real when the skeptic said so
85
+ * outright, or when it refined the severity to `dismiss`.
86
+ *
87
+ * `dismiss` is treated as a not-real vote rather than as a severity: it is the
88
+ * skeptic's way of saying "there is nothing here", and folding it into the
89
+ * severity ladder would let a dismissed finding out-rank a real suggestion.
90
+ */
91
+ function votedReal(verdict) {
92
+ return verdict.isReal === true && verdict.refinedSeverity !== 'dismiss'
93
+ }
94
+
95
+ /**
96
+ * Whether a not-real verdict is allowed to actually dismiss.
97
+ *
98
+ * A skeptic that cannot point at what it read has not refuted anything — it has
99
+ * asserted. Refute-or-Promote (arXiv 2604.19049) documents where that ends: 80+
100
+ * agents, adversarial reviewers among them, unanimously endorsing an OpenSSL
101
+ * padding oracle that did not exist. Confident prose is exactly what an LLM
102
+ * produces most reliably, so it is the one thing a dismissal must not rest on.
103
+ *
104
+ * So evidence gates the *dismissing* direction only. Voting a finding real
105
+ * needs none: that direction already resolves toward a human reading it, which
106
+ * is the cheap error. This is `agent_resolved` from shared/DECISION-LEDGER.md —
107
+ * a claim is audited, and an unsubstantiated one is treated as unresolved.
108
+ *
109
+ * An uncited not-real verdict becomes a NON-VOTE, never a deleted verdict. That
110
+ * distinction is load-bearing: dropping the verdict entirely would orphan it
111
+ * (see the keying note above), and its `qualityScore` still deserves to reach
112
+ * the tolerance band — a skeptic can be too lazy to cite and still be right
113
+ * that the finding is badly written.
114
+ *
115
+ * The predicate is a SHAPE check plus diff membership, and deliberately not a
116
+ * semantic one. Judging whether a cited span actually *supports* the claim
117
+ * needs a model, and putting one there recreates this problem one layer down.
118
+ * Shape plus membership is the ceiling: both are deterministic and testable.
119
+ *
120
+ * Two conditions, because either alone is defeated:
121
+ *
122
+ * 1. `path:line` or `path:start-end`. Non-emptiness alone was satisfied by
123
+ * the string "👍".
124
+ * 2. The path canonicalizes to one present in the reviewed diff. Shape alone
125
+ * is satisfied by inventing `lib/nowhere.ts:1`.
126
+ *
127
+ * The line is NOT required to exist in the file. A review runs against a diff,
128
+ * the file on disk may have moved on, and a valid citation to a deleted line
129
+ * would be rejected — path membership is the strongest condition that cannot
130
+ * produce a false rejection.
131
+ *
132
+ * When no diff is supplied the shape half still applies on its own. A caller
133
+ * that cannot name the changed files must not thereby reopen the emoji hole.
134
+ */
135
+ function hasEvidence(verdict, diff) {
136
+ const text = typeof verdict.evidence === 'string' ? verdict.evidence : ''
137
+ if (!text.trim()) return false
138
+
139
+ // A path in the diff, cited with a line. Checked first and by literal match
140
+ // because it is the only form that tolerates a path containing spaces —
141
+ // token-splitting on whitespace cannot recover "src/my file.ts:12".
142
+ if (diff && diff.size) {
143
+ for (const spelling of diff) {
144
+ if (citesLineIn(text, spelling)) return true
145
+ }
146
+ }
147
+
148
+ const cited = [...text.matchAll(CITATION_TOKEN)]
149
+ .map(m => canonicalizePath(m[1]))
150
+ .filter(Boolean)
151
+ if (!cited.length) return false
152
+ if (!diff || !diff.size) return true
153
+ return cited.some(p => diff.has(p))
154
+ }
155
+
156
+ function numericScore(value) {
157
+ return typeof value === 'number' && Number.isFinite(value)
158
+ }
159
+
160
+ /**
161
+ * Resolve a review: which findings survived the skeptics, which the skeptics
162
+ * dismissed, which the quality band dropped, and how many of each.
163
+ *
164
+ * ## Survival rules
165
+ *
166
+ * 1. **Majority survival.** A finding survives when at least as many skeptics
167
+ * voted it real as voted it not-real.
168
+ * 2. **Tie rule — a tie KEEPS the finding.** With exactly two skeptics a 1–1
169
+ * split is the common case, so this is the rule that matters most. Keeping is
170
+ * the right resolution because the two errors are not symmetric: a surviving
171
+ * false positive costs a human ten seconds of reading, while a wrongly
172
+ * dismissed finding is *invisible* — it never reaches the report, so nobody
173
+ * can catch the mistake. Every ambiguous case therefore resolves toward
174
+ * reporting. (This is the same asymmetry `applyToleranceBand` uses when
175
+ * skeptics disagree by more than `drift`.)
176
+ * 3. **No verdicts means survival.** A finding nobody adjudicated is not a
177
+ * finding that was refuted. Absence of adjudication is not dismissal, so it
178
+ * is kept — falling out of rule 1 as 0 >= 0, but stated because it is a
179
+ * decision, not an accident.
180
+ * 4. **Severity refinement — the most severe refinement wins**, counting only
181
+ * skeptics who thought the finding real. A skeptic who says "not real" has
182
+ * not offered an opinion on how bad it would be if it were. Refinements
183
+ * outside `SEVERITIES` are ignored and the reviewer's original severity
184
+ * stands; when a refinement does apply, the original is preserved as
185
+ * `originalSeverity`.
186
+ * 5. **The quality band runs after survival**, via `applyToleranceBand`, so a
187
+ * finding that survived but is too poorly-grounded to be worth a human's
188
+ * attention disappears before the gate counts blockers. Quality scores from
189
+ * every verdict (including not-real ones — a skeptic can judge a finding
190
+ * badly written and still believe it) are attached as `qualityScores` for the
191
+ * band to pool.
192
+ *
193
+ * Verdicts are matched to findings by `findingTitle` → `title`. Two findings
194
+ * that share a title in different files share a verdict set; that is a known
195
+ * coarseness of the skeptic contract, not something this module can fix, since
196
+ * `VERDICT_SCHEMA` carries no file.
197
+ *
198
+ * @param {*} findings findings in any shape `evaluateGate` accepts
199
+ * @param {Array<object>} verdicts flat array of `VERDICT_SCHEMA` objects
200
+ * @param {{band?: object|null, changedPaths?: string[], excludePaths?: string[]}} [opts]
201
+ * `band` overrides the tolerance band (`null` disables it — the default is
202
+ * `TOLERANCE_BAND`). `changedPaths` is the reviewed diff; when supplied, a
203
+ * dismissing verdict's citation must name a path in it (see `hasEvidence`).
204
+ * `excludePaths` is a list of canonical do-not-report prefixes (from a repo's
205
+ * `REVIEW.md`); a finding whose canonical file lies at or under one is dropped
206
+ * BEFORE the band is applied, so exclusions shrink the input but never alter
207
+ * the arithmetic on what remains (add-interlock-review-policy spec §5).
208
+ * @returns {{
209
+ * surviving: Array, dismissed: Array, droppedByQuality: Array, droppedByPolicy: Array,
210
+ * counts: {raised: number, dismissed: number, droppedByQuality: number, droppedByPolicy: number, surviving: number},
211
+ * dimensionStats: Object<string, {raised: number, surviving: number}>,
212
+ * anomalies: {orphanVerdicts: Array, malformedVerdicts: Array}
213
+ * }}
214
+ */
215
+ export function resolveReview(findings, verdicts, opts = {}) {
216
+ const raisedFindings = flatten(findings)
217
+ const list = Array.isArray(verdicts) ? verdicts : verdicts ? [verdicts] : []
218
+ const diff = diffIndex(opts.changedPaths)
219
+
220
+ // Exclusions run FIRST, before any survival decision or the band. A finding
221
+ // on an excluded path never reaches the skeptics' arithmetic — it is data the
222
+ // CLI drops, not a request a reviewer could vote past (spec §3). Matching is
223
+ // path-only and case-sensitive on the canonical form (see `pathExcludedBy`),
224
+ // so a reviewer voting an excluded finding real cannot re-include it.
225
+ const excludePrefixes = Array.isArray(opts.excludePaths)
226
+ ? opts.excludePaths.filter(p => typeof p === 'string' && p)
227
+ : []
228
+ const droppedByPolicy = []
229
+ const all = []
230
+ for (const finding of raisedFindings) {
231
+ const excludingPath = excludePrefixes.find(prefix => pathExcludedBy(finding.file, prefix))
232
+ if (excludingPath) droppedByPolicy.push({ ...finding, excludedBy: excludingPath })
233
+ else all.push(finding)
234
+ }
235
+
236
+ // Index verdicts by what they claim to adjudicate: title, plus file when the
237
+ // skeptic named one.
238
+ const byKey = new Map()
239
+ const malformedVerdicts = []
240
+ for (const v of list) {
241
+ if (!v || typeof v !== 'object' || typeof v.findingTitle !== 'string') {
242
+ malformedVerdicts.push(v)
243
+ continue
244
+ }
245
+ const key = verdictKey(v)
246
+ if (!byKey.has(key)) byKey.set(key, [])
247
+ byKey.get(key).push(v)
248
+ }
249
+
250
+ // A verdict for a finding nobody raised is an anomaly, not a crash: the
251
+ // skeptic hallucinated a title, or the reviewer's output was truncated
252
+ // between the two steps. Either way it is reported and the run continues.
253
+ // Built from every raised finding, including policy-excluded ones: a verdict
254
+ // that adjudicates a finding we dropped for policy is not an orphan (the
255
+ // finding was real, just out of scope), so it must not be reported as one.
256
+ const raisedKeys = new Set(raisedFindings.flatMap(matchKeys))
257
+ const orphanVerdicts = []
258
+ for (const [key, vs] of byKey) {
259
+ if (!raisedKeys.has(key)) orphanVerdicts.push(...vs)
260
+ }
261
+
262
+ const survived = []
263
+ const dismissed = []
264
+ // Reported, never silent: a dismissal quietly downgraded to a non-vote looks
265
+ // identical to a skeptic that simply agreed, and the whole point of the rule
266
+ // is that the reader can see it fired.
267
+ let dismissalsRejected = 0
268
+
269
+ for (const finding of all) {
270
+ // Prefer the file-qualified verdict set; fall back to the title-only one so
271
+ // a skeptic that did not report a file still adjudicates.
272
+ const vs = matchKeys(finding).map(k => byKey.get(k)).find(Boolean) || []
273
+
274
+ let real = 0
275
+ let notReal = 0
276
+ let uncited = 0
277
+ const scores = []
278
+ for (const v of vs) {
279
+ if (votedReal(v)) real += 1
280
+ else if (hasEvidence(v, diff)) notReal += 1
281
+ // An uncited refutation counts as neither: it does not dismiss, and it is
282
+ // not a vote to keep either. It falls through to rule 3.
283
+ else uncited += 1
284
+ if (numericScore(v.qualityScore)) scores.push(v.qualityScore)
285
+ }
286
+ dismissalsRejected += uncited
287
+
288
+ const votes = { real, notReal, uncited, total: vs.length }
289
+
290
+ // Rule 1 + rule 2 + rule 3: majority, ties keep, no verdicts keeps.
291
+ if (real < notReal) {
292
+ dismissed.push({ ...finding, votes })
293
+ continue
294
+ }
295
+
296
+ // Rule 4: most severe refinement among skeptics who thought it real.
297
+ let refined = null
298
+ for (const v of vs) {
299
+ if (!votedReal(v)) continue
300
+ const rank = severityRank(v.refinedSeverity)
301
+ if (rank !== Infinity && (refined === null || rank < severityRank(refined))) {
302
+ refined = v.refinedSeverity
303
+ }
304
+ }
305
+
306
+ // Rule 5: hand the band every score the skeptics produced, alongside any
307
+ // the finding already carried.
308
+ const existing = Array.isArray(finding.qualityScores) ? finding.qualityScores : []
309
+ const pooled = [...existing, ...scores]
310
+
311
+ const out = { ...finding, votes }
312
+ if (pooled.length) out.qualityScores = pooled
313
+ if (refined !== null && refined !== finding.severity) {
314
+ out.originalSeverity = finding.severity
315
+ out.severity = refined
316
+ }
317
+ survived.push(out)
318
+ }
319
+
320
+ const band = opts.band === undefined ? TOLERANCE_BAND : opts.band
321
+ const { reportable: surviving, dropped: droppedByQuality } = applyToleranceBand(survived, band)
322
+
323
+ // Every raised finding lands in exactly one bucket, so the counts always sum
324
+ // back to `raised` (droppedByPolicy + dismissed + droppedByQuality +
325
+ // surviving). That is what makes them safe to emit as metrics. `raised` is
326
+ // the full set the dimensions produced, including the policy-excluded ones —
327
+ // an honest denominator that does not shrink just because a path is exempt.
328
+ const counts = {
329
+ raised: raisedFindings.length,
330
+ droppedByPolicy: droppedByPolicy.length,
331
+ dismissed: dismissed.length,
332
+ droppedByQuality: droppedByQuality.length,
333
+ surviving: surviving.length,
334
+ // Not part of the four-way partition above — it counts verdicts, not
335
+ // findings, so it does not sum with them. Reported because a refutation
336
+ // that was refused for lack of evidence is exactly what a reader needs to
337
+ // know when a finding they expected to be dismissed is still on the list.
338
+ dismissalsRejected
339
+ }
340
+
341
+ const dimensionStats = {}
342
+ const bump = (finding, key) => {
343
+ const d = finding.dimension || 'unknown'
344
+ if (!dimensionStats[d]) dimensionStats[d] = { raised: 0, surviving: 0 }
345
+ dimensionStats[d][key] += 1
346
+ }
347
+ for (const f of all) bump(f, 'raised')
348
+ for (const f of surviving) bump(f, 'surviving')
349
+
350
+ return {
351
+ surviving,
352
+ dismissed,
353
+ droppedByQuality,
354
+ droppedByPolicy,
355
+ counts,
356
+ dimensionStats,
357
+ anomalies: { orphanVerdicts, malformedVerdicts }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Whether a finding's file lies at or under a canonical excluded prefix.
363
+ *
364
+ * The finding's file is canonicalized with the SAME `canonicalizePath` the
365
+ * prefix went through (in `parseReviewPolicy`), so `dist/a.js`, `./dist/a.js`
366
+ * and `dist/a.js` are one identity — a finding cannot slip past an exclusion on
367
+ * an alternate spelling (spec §3 edge). The match is a path-boundary check, not
368
+ * a raw `startsWith`: `dist` excludes `dist/a.js` and `dist` itself, but not a
369
+ * sibling `distant/a.js`. It is case-sensitive by construction — the canonical
370
+ * form preserves case — mirroring how the default filesystem resolves paths, so
371
+ * `DIST/a.js` is NOT excluded by a `dist` rule (design.md D2: a visible
372
+ * under-exclusion beats a silent over-exclusion).
373
+ *
374
+ * A finding whose file cannot be canonicalized (missing, absolute, escaping the
375
+ * root) is not excluded: an exclusion is an affirmative scope decision, and a
376
+ * path we cannot place cannot be affirmatively matched.
377
+ */
378
+ function pathExcludedBy(file, canonicalPrefix) {
379
+ const canonicalFile = canonicalizePath(file)
380
+ if (!canonicalFile) return false
381
+ return canonicalFile === canonicalPrefix || canonicalFile.startsWith(canonicalPrefix + '/')
382
+ }
383
+
384
+ /** Human-readable review summary. */
385
+ export function formatReview(result) {
386
+ const { raised, dismissed, droppedByQuality, droppedByPolicy, surviving, dismissalsRejected } =
387
+ result.counts
388
+ const lines = []
389
+ lines.push(`REVIEW — ${surviving} of ${raised} finding(s) survived`)
390
+ lines.push(
391
+ ` raised=${raised} droppedByPolicy=${droppedByPolicy || 0} dismissed=${dismissed} ` +
392
+ `droppedByQuality=${droppedByQuality} surviving=${surviving}`
393
+ )
394
+ // Each policy drop names the path that excluded it: a drop reported without
395
+ // its excluding path is indistinguishable from a finding that was never
396
+ // raised, and "why isn't my exclusion working" must stay diagnosable.
397
+ for (const f of result.droppedByPolicy || []) {
398
+ lines.push(` excluded by ${f.excludedBy}: ${f.file || '(no file)'} — ${f.title || '(untitled)'}`)
399
+ }
400
+ if (dismissalsRejected) {
401
+ lines.push(
402
+ ` ${dismissalsRejected} refutation(s) cited no evidence and did not dismiss anything`
403
+ )
404
+ }
405
+
406
+ const dims = Object.keys(result.dimensionStats).sort()
407
+ for (const d of dims) {
408
+ const { raised: r, surviving: s } = result.dimensionStats[d]
409
+ lines.push(` ${d}: ${s}/${r} surviving`)
410
+ }
411
+
412
+ for (const f of result.surviving) {
413
+ if (f.originalSeverity) {
414
+ lines.push(` refined ${f.originalSeverity} → ${f.severity}: ${f.title}`)
415
+ }
416
+ }
417
+
418
+ const { orphanVerdicts, malformedVerdicts } = result.anomalies
419
+ if (orphanVerdicts.length) {
420
+ const titles = [...new Set(orphanVerdicts.map(v => v.findingTitle))]
421
+ lines.push(` ANOMALY: ${orphanVerdicts.length} verdict(s) matched no finding: ${titles.join(', ')}`)
422
+ }
423
+ if (malformedVerdicts.length) {
424
+ lines.push(` ANOMALY: ${malformedVerdicts.length} verdict(s) had no usable findingTitle`)
425
+ }
426
+
427
+ return lines.join('\n') + '\n'
428
+ }
429
+
430
+ // --- repo-root REVIEW.md policy -------------------------------------------
431
+ //
432
+ // A repo owns its review policy in one optional, versioned file. The file is
433
+ // split by trust boundary (design.md D1): its prose is advice handed to a
434
+ // reviewer, and its do-not-report paths are enforced here in the CLI as the
435
+ // exclusion filter above — never a request a model could vote past. Everything
436
+ // below is the reader/parser for that file. Parsing is pure; only
437
+ // `readReviewPolicy` touches the filesystem.
438
+
439
+ /** The one location that is read as policy: the repository root. */
440
+ export const REVIEW_POLICY_FILE = 'REVIEW.md'
441
+
442
+ const EMPTY_POLICY = () => ({ prose: '', excludePaths: [], owner: null, problems: [] })
443
+
444
+ // The only recognized front-matter key. The band and nit cap deliberately are
445
+ // NOT here — they live in the CLI so they are not re-argued (spec §5). Any other
446
+ // key (a `nitCap:`, a stray typo) is reported as not-an-accepted-field rather
447
+ // than silently adopted, which is the whole reason the file cannot relocate the
448
+ // gate into markdown.
449
+ const ACCEPTED_FRONT_MATTER_KEYS = new Set(['owner'])
450
+
451
+ // A line that looks like it is trying to set a survival threshold. Matched only
452
+ // at line start as `key:`/`key=` so a prose sentence mentioning "the band" does
453
+ // not trip it — only an actual assignment does.
454
+ const BAND_KEY_LINE = /^\s*(nit ?cap|min ?quality|quality ?band|severity ?band|tolerance|band)\s*[:=]/i
455
+
456
+ // Headings are matched by normalized text so `## What "Important" Means` and
457
+ // `## what important means` are one heading: lowercased, punctuation and quotes
458
+ // flattened to single spaces, trimmed.
459
+ function normalizeHeading(text) {
460
+ return String(text)
461
+ .toLowerCase()
462
+ .replace(/[^a-z0-9]+/g, ' ')
463
+ .trim()
464
+ }
465
+
466
+ const PROSE_HEADINGS = new Set(['owner', 'what important means', 'exclusions rationale'])
467
+ const EXCLUSIONS_HEADING = 'do not report'
468
+
469
+ /**
470
+ * Read the repo-root `REVIEW.md` and parse it into a policy object.
471
+ *
472
+ * Fail-open (design.md D3): an absent file is the empty policy with no problems
473
+ * — absence *is* the off switch, not an error. A file over the scan cap
474
+ * (`LIMITS.maxReviewPolicyBytes`) or unreadable is reported as a problem and the
475
+ * run proceeds under default policy rather than parsing unbounded input.
476
+ *
477
+ * @param {string} repoRoot repository root to look under (defaults to cwd)
478
+ * @returns {{prose: string, excludePaths: string[], owner: string|null, problems: string[]}}
479
+ */
480
+ export function readReviewPolicy(repoRoot) {
481
+ const root = typeof repoRoot === 'string' && repoRoot.trim() ? repoRoot : '.'
482
+ const path = join(root, REVIEW_POLICY_FILE)
483
+ if (!existsSync(path)) return EMPTY_POLICY()
484
+
485
+ let size
486
+ try {
487
+ size = statSync(path).size
488
+ } catch {
489
+ return EMPTY_POLICY()
490
+ }
491
+ if (size > LIMITS.maxReviewPolicyBytes) {
492
+ const policy = EMPTY_POLICY()
493
+ policy.problems.push(
494
+ `${REVIEW_POLICY_FILE} is ${size} bytes, over the ${LIMITS.maxReviewPolicyBytes}-byte scan ` +
495
+ `cap — not parsed; review proceeds under default policy`
496
+ )
497
+ return policy
498
+ }
499
+
500
+ let text
501
+ try {
502
+ text = readFileSync(path, 'utf8')
503
+ } catch (err) {
504
+ const policy = EMPTY_POLICY()
505
+ policy.problems.push(`${REVIEW_POLICY_FILE} could not be read: ${err.message}`)
506
+ return policy
507
+ }
508
+ return parseReviewPolicy(text)
509
+ }
510
+
511
+ /**
512
+ * Parse `REVIEW.md` text into `{ prose, excludePaths, owner, problems }`.
513
+ *
514
+ * Each half is validated independently (design.md D3): a broken exclusions block
515
+ * does not stop valid prose from being returned, and a broken prose section does
516
+ * not stop valid exclusions — the broken half yields its empty value and a
517
+ * problem, never a half-guess. Unknown `##` sections are ignored so the format
518
+ * can grow (D4). Pure: no filesystem, no throw on malformed input.
519
+ */
520
+ export function parseReviewPolicy(text) {
521
+ const policy = EMPTY_POLICY()
522
+ if (typeof text !== 'string' || !text.trim()) return policy
523
+
524
+ const { frontMatter, body } = splitFrontMatter(text)
525
+
526
+ // Front matter: owner is the one accepted key; anything else is reported and
527
+ // never adopted (a `nitCap:` here is the spec §5 failure case).
528
+ let owner = null
529
+ for (const { key, value, raw } of frontMatter) {
530
+ if (BAND_KEY_LINE.test(raw)) {
531
+ policy.problems.push(
532
+ `front-matter line "${raw.trim()}" looks like a survival threshold — REVIEW.md controls ` +
533
+ `scope and advice, not the band or nit cap; ignored`
534
+ )
535
+ continue
536
+ }
537
+ if (key === 'owner') {
538
+ if (value) owner = value
539
+ continue
540
+ }
541
+ if (!ACCEPTED_FRONT_MATTER_KEYS.has(key)) {
542
+ policy.problems.push(`front-matter key "${key}" is not an accepted policy field; ignored`)
543
+ }
544
+ }
545
+
546
+ // A band-like assignment smuggled into the body (outside front matter) is the
547
+ // same failure — report it, never read it as a threshold.
548
+ for (const line of body.split('\n')) {
549
+ if (BAND_KEY_LINE.test(line)) {
550
+ policy.problems.push(
551
+ `line "${line.trim()}" looks like a survival threshold — the band and nit cap live in the ` +
552
+ `CLI, not REVIEW.md; ignored`
553
+ )
554
+ }
555
+ }
556
+
557
+ const sections = splitSections(body)
558
+
559
+ const proseParts = []
560
+ let ownerSectionSeen = false
561
+ for (const section of sections) {
562
+ const norm = normalizeHeading(section.heading)
563
+ if (norm === EXCLUSIONS_HEADING) {
564
+ const { paths, problems } = parseExclusions(section.content)
565
+ policy.excludePaths.push(...paths)
566
+ policy.problems.push(...problems)
567
+ continue
568
+ }
569
+ if (PROSE_HEADINGS.has(norm)) {
570
+ const content = section.content.trim()
571
+ if (norm === 'owner') {
572
+ ownerSectionSeen = true
573
+ if (!owner && content) owner = content.split('\n')[0].trim()
574
+ }
575
+ if (content) proseParts.push(`## ${section.heading.trim()}\n${content}`)
576
+ continue
577
+ }
578
+ // Unknown heading: ignored, forward-compatible (D4).
579
+ }
580
+
581
+ // Fold a front-matter owner into the injected prose when the file did not also
582
+ // spell it out as a `## Owner` section, so the prose the reviewer reads always
583
+ // names the bar's owner (spec: "declared owner but no other content").
584
+ if (owner && !ownerSectionSeen) {
585
+ proseParts.unshift(`## Owner\n${owner}`)
586
+ }
587
+
588
+ policy.prose = proseParts.join('\n\n')
589
+ policy.owner = owner
590
+ // `./dist` and `dist/` canonicalize to one prefix; keep it once.
591
+ policy.excludePaths = [...new Set(policy.excludePaths)]
592
+ return policy
593
+ }
594
+
595
+ /**
596
+ * Split leading YAML-ish front matter (a `---`-fenced block at the very top)
597
+ * from the body. Front matter is a small hand-parse — `key: value` lines only —
598
+ * so the file needs no YAML dependency (design.md D4). A file with no fence has
599
+ * empty front matter and the whole text as body.
600
+ */
601
+ function splitFrontMatter(text) {
602
+ const lines = text.split('\n')
603
+ if (lines[0]?.trim() !== '---') return { frontMatter: [], body: text }
604
+ let end = -1
605
+ for (let i = 1; i < lines.length; i++) {
606
+ if (lines[i].trim() === '---') {
607
+ end = i
608
+ break
609
+ }
610
+ }
611
+ if (end === -1) return { frontMatter: [], body: text }
612
+
613
+ const frontMatter = []
614
+ for (const raw of lines.slice(1, end)) {
615
+ const trimmed = raw.trim()
616
+ if (!trimmed || trimmed.startsWith('#')) continue
617
+ const m = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/)
618
+ if (!m) continue
619
+ frontMatter.push({ key: m[1].toLowerCase(), value: stripQuotes(m[2].trim()), raw })
620
+ }
621
+ return { frontMatter, body: lines.slice(end + 1).join('\n') }
622
+ }
623
+
624
+ function stripQuotes(value) {
625
+ const m = value.match(/^(['"])(.*)\1$/)
626
+ return m ? m[2] : value
627
+ }
628
+
629
+ /** Split a markdown body into `{ heading, content }` for each `##` section. */
630
+ function splitSections(body) {
631
+ const sections = []
632
+ let current = null
633
+ for (const line of body.split('\n')) {
634
+ const m = line.match(/^##\s+(.+?)\s*#*\s*$/)
635
+ if (m) {
636
+ if (current) sections.push(current)
637
+ current = { heading: m[1], contentLines: [] }
638
+ } else if (current) {
639
+ current.contentLines.push(line)
640
+ }
641
+ }
642
+ if (current) sections.push(current)
643
+ return sections.map(s => ({ heading: s.heading, content: s.contentLines.join('\n') }))
644
+ }
645
+
646
+ /**
647
+ * Parse a `## Do Not Report` block into canonical excluded prefixes.
648
+ *
649
+ * Entries are markdown list items (`- path` / `* path`). Each is canonicalized
650
+ * with the same `canonicalizePath` findings go through, so a finding cannot slip
651
+ * an exclusion on an alternate spelling; an entry that cannot be canonicalized
652
+ * (absolute, escaping the root) excludes nothing and is reported (spec §3, task
653
+ * 1.3). A structurally broken block — a fenced code block, or content with no
654
+ * list entries at all — is reported unusable as a whole and yields no
655
+ * exclusions, rather than being half-guessed (spec §4, the partially-valid file).
656
+ */
657
+ function parseExclusions(content) {
658
+ const problems = []
659
+ if (/^\s*```/m.test(content)) {
660
+ problems.push(
661
+ `the "Do Not Report" block contains a code fence and could not be parsed as a path list; ` +
662
+ `no exclusions applied`
663
+ )
664
+ return { paths: [], problems }
665
+ }
666
+
667
+ const contentLines = content
668
+ .split('\n')
669
+ .map(l => l.trim())
670
+ .filter(l => l && !l.startsWith('<!--'))
671
+ if (!contentLines.length) return { paths: [], problems }
672
+
673
+ const entries = []
674
+ let sawNonEntry = false
675
+ for (const line of contentLines) {
676
+ const m = line.match(/^[-*]\s+(.+)$/)
677
+ if (m) entries.push(m[1].trim())
678
+ else sawNonEntry = true
679
+ }
680
+
681
+ // Non-blank content but not a single list item: the block is not a path list.
682
+ if (!entries.length) {
683
+ problems.push(
684
+ `the "Do Not Report" block has content but no path list items (\`- path\`); ` +
685
+ `no exclusions applied`
686
+ )
687
+ return { paths: [], problems }
688
+ }
689
+ if (sawNonEntry) {
690
+ problems.push(
691
+ `the "Do Not Report" block mixes list items with non-list text; only the \`- path\` items ` +
692
+ `were read`
693
+ )
694
+ }
695
+
696
+ const paths = []
697
+ for (const entry of entries) {
698
+ const canonical = canonicalizePath(stripQuotes(entry.replace(/\/+$/, '')))
699
+ if (canonical) paths.push(canonical)
700
+ else problems.push(`exclusion "${entry}" is not a repo-relative path and excludes nothing`)
701
+ }
702
+ return { paths, problems }
703
+ }
704
+
705
+ /** Human-readable dump of a parsed policy, for `interlock review-policy`. */
706
+ export function formatReviewPolicy(policy) {
707
+ const lines = []
708
+ lines.push(`REVIEW POLICY — ${policy.owner ? `owner: ${policy.owner}` : 'no owner declared'}`)
709
+ lines.push(` exclusions: ${policy.excludePaths.length ? policy.excludePaths.join(', ') : '(none)'}`)
710
+ lines.push(` prose: ${policy.prose ? `${policy.prose.length} char(s)` : '(none)'}`)
711
+ for (const problem of policy.problems) lines.push(` PROBLEM: ${problem}`)
712
+ return lines.join('\n') + '\n'
713
+ }