@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- 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
|
+
}
|