@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,216 @@
1
+ // Eval triage — turn a completed results run into a verdict, without a model.
2
+ //
3
+ // The eval suite runs a real model against Interlock's model-facing surface and
4
+ // writes a results file. Deciding what that file *means* — regression, run-to-run
5
+ // variance, or no signal at all — is a decision with a correct answer, so it
6
+ // lives here rather than in skill prose a model could re-argue on each run. This
7
+ // is the same argument `interlock gate` makes for review findings, and the same
8
+ // exit-code-as-verdict contract (see the design's D13).
9
+ //
10
+ // It reads the results file and nothing else: no model, no network. The same
11
+ // input always yields the same classification.
12
+ //
13
+ // Exposed to skills as `interlock evals triage --results <file>`.
14
+ //
15
+ // The verdict is carried by the exit code so no consumer re-derives it:
16
+ // 0 pass every scored case passed (variance is not a regression)
17
+ // 1 regression at least one case is a regression
18
+ // 2 no signal the run did not complete — classifies nothing
19
+ // 3 configuration zero scorable cases, or every case failed to load
20
+ // No signal and configuration are non-zero on purpose: neither may read as a
21
+ // clean pass, which a shared 0 would make indistinguishable (triage spec,
22
+ // "No signal does not read as success").
23
+
24
+ /** Verdict → exit code. The exit code IS the verdict; nothing re-derives it. */
25
+ export const EXIT = { pass: 0, regression: 1, no_signal: 2, configuration: 3 }
26
+
27
+ // A grader is judged when it names a judge in the loop. Everything else —
28
+ // pattern, tool-invocation, file-existence, ordering, set-membership — is
29
+ // deterministic, so its failure is trustworthy on a single run.
30
+ const JUDGED_TYPES = new Set(['judge', 'judged', 'model-judge', 'llm-judge', 'semantic'])
31
+
32
+ function isJudged(grader) {
33
+ if (grader && grader.judged === true) return true
34
+ const type = grader && typeof grader.type === 'string' ? grader.type.toLowerCase() : ''
35
+ return JUDGED_TYPES.has(type)
36
+ }
37
+
38
+ // Skill-fired graders report whether the plugin caused the behaviour; they are
39
+ // excluded from the score in both arms (case-suite spec), so they can never be
40
+ // the evidence for a regression.
41
+ function isIndicator(grader) {
42
+ if (!grader) return false
43
+ if (grader.indicator === true) return true
44
+ const type = typeof grader.type === 'string' ? grader.type.toLowerCase() : ''
45
+ return type === 'indicator'
46
+ }
47
+
48
+ function graderName(grader, i) {
49
+ return grader && typeof grader.name === 'string' && grader.name ? grader.name : `grader-${i + 1}`
50
+ }
51
+
52
+ /**
53
+ * Classify one case from its per-run graders.
54
+ * @returns {{id, classification: 'regression'|'variance'|'pass', graders: string[], confirm: string|null}}
55
+ */
56
+ function classifyCase(kase) {
57
+ const id = kase && typeof kase.id === 'string' && kase.id ? kase.id : '(unnamed)'
58
+ const runs = Array.isArray(kase && kase.runs) ? kase.runs : []
59
+ const runCount = runs.length
60
+
61
+ // Deterministic failures are a regression on a single run. Judged failures
62
+ // are counted across runs so a majority can be distinguished from a dip.
63
+ const deterministicFails = []
64
+ const judgedFailCounts = new Map()
65
+ let judgedFailSeen = false
66
+
67
+ for (const run of runs) {
68
+ const graders = Array.isArray(run && run.graders) ? run.graders : []
69
+ graders.forEach((g, i) => {
70
+ if (isIndicator(g)) return
71
+ if (g && g.passed === true) return
72
+ const name = graderName(g, i)
73
+ if (isJudged(g)) {
74
+ judgedFailSeen = true
75
+ judgedFailCounts.set(name, (judgedFailCounts.get(name) || 0) + 1)
76
+ } else {
77
+ if (!deterministicFails.includes(name)) deterministicFails.push(name)
78
+ }
79
+ })
80
+ }
81
+
82
+ // A judged grader failing on a majority of runs is a regression — but only
83
+ // when there is more than one run to form a majority. A single judged dip is
84
+ // variance, never a regression.
85
+ const judgedRegressions = []
86
+ if (runCount > 1) {
87
+ for (const [name, fails] of judgedFailCounts) {
88
+ if (fails * 2 > runCount) judgedRegressions.push(name)
89
+ }
90
+ }
91
+
92
+ if (deterministicFails.length || judgedRegressions.length) {
93
+ return {
94
+ id,
95
+ classification: 'regression',
96
+ graders: [...deterministicFails, ...judgedRegressions],
97
+ confirm: null
98
+ }
99
+ }
100
+
101
+ if (judgedFailSeen) {
102
+ const graders = [...judgedFailCounts.keys()]
103
+ const confirm =
104
+ runCount <= 1
105
+ ? 'a judged grader dipped on the only run; further runs failing the same grader would confirm a regression'
106
+ : 'a judged grader failed on a minority of runs; failing on a majority would confirm a regression'
107
+ return { id, classification: 'variance', graders, confirm }
108
+ }
109
+
110
+ return { id, classification: 'pass', graders: [], confirm: null }
111
+ }
112
+
113
+ /**
114
+ * Triage a completed eval results file into a verdict.
115
+ * @param {any} results parsed results file
116
+ * @returns {{verdict, exitCode, reason, cases, unloadable, counts}}
117
+ */
118
+ export function triage(results) {
119
+ const r = results && typeof results === 'object' && !Array.isArray(results) ? results : {}
120
+
121
+ // An incomplete run classifies nothing. A partial results file, an aborted
122
+ // run, or a rejected credential is no signal — not a suite failure and not a
123
+ // pass.
124
+ const partialReason =
125
+ typeof r.reason === 'string' && r.reason
126
+ ? r.reason
127
+ : typeof r.partial_reason === 'string' && r.partial_reason
128
+ ? r.partial_reason
129
+ : null
130
+ if (r.partial === true || r.complete === false || partialReason) {
131
+ return {
132
+ verdict: 'no_signal',
133
+ exitCode: EXIT.no_signal,
134
+ reason: partialReason || 'run reported itself partial',
135
+ cases: [],
136
+ unloadable: [],
137
+ counts: { regressions: 0, variance: 0, pass: 0 }
138
+ }
139
+ }
140
+
141
+ const allCases = Array.isArray(r.cases) ? r.cases : []
142
+ const unloadable = allCases
143
+ .filter(c => c && c.loaded === false)
144
+ .map(c => (typeof c.id === 'string' && c.id ? c.id : '(unnamed)'))
145
+ const scorable = allCases.filter(c => c && c.loaded !== false)
146
+
147
+ // Zero cases, or a suite where nothing loaded, is a configuration problem —
148
+ // reported distinctly from both a clean pass and a regression.
149
+ if (scorable.length === 0) {
150
+ const reason =
151
+ allCases.length === 0
152
+ ? 'the run discovered no cases'
153
+ : `every case failed to load (${unloadable.length})`
154
+ return {
155
+ verdict: 'configuration',
156
+ exitCode: EXIT.configuration,
157
+ reason,
158
+ cases: [],
159
+ unloadable,
160
+ counts: { regressions: 0, variance: 0, pass: 0 }
161
+ }
162
+ }
163
+
164
+ const cases = scorable.map(classifyCase)
165
+ const counts = {
166
+ regressions: cases.filter(c => c.classification === 'regression').length,
167
+ variance: cases.filter(c => c.classification === 'variance').length,
168
+ pass: cases.filter(c => c.classification === 'pass').length
169
+ }
170
+ const verdict = counts.regressions > 0 ? 'regression' : 'pass'
171
+ return {
172
+ verdict,
173
+ exitCode: counts.regressions > 0 ? EXIT.regression : EXIT.pass,
174
+ reason: null,
175
+ cases,
176
+ unloadable,
177
+ counts
178
+ }
179
+ }
180
+
181
+ /** Human-readable rendering. The exit code, not this text, is the verdict. */
182
+ export function formatTriage(result) {
183
+ const lines = []
184
+ if (result.verdict === 'no_signal') {
185
+ lines.push(`no signal — ${result.reason}`)
186
+ lines.push('classified no case; the run did not complete')
187
+ return lines.join('\n') + '\n'
188
+ }
189
+ if (result.verdict === 'configuration') {
190
+ lines.push(`configuration problem — ${result.reason}`)
191
+ if (result.unloadable.length) lines.push(`unloadable: ${result.unloadable.join(', ')}`)
192
+ lines.push('this is not a pass')
193
+ return lines.join('\n') + '\n'
194
+ }
195
+
196
+ const { regressions, variance, pass } = result.counts
197
+ lines.push(
198
+ result.verdict === 'regression'
199
+ ? `regression — ${regressions} case(s)`
200
+ : variance
201
+ ? 'no regression (variance present)'
202
+ : 'pass'
203
+ )
204
+ for (const c of result.cases) {
205
+ if (c.classification === 'regression') {
206
+ lines.push(` regression ${c.id} (failed: ${c.graders.join(', ')})`)
207
+ } else if (c.classification === 'variance') {
208
+ lines.push(` variance ${c.id} (${c.graders.join(', ')}) — ${c.confirm}`)
209
+ }
210
+ }
211
+ if (result.unloadable.length) {
212
+ lines.push(` unloadable ${result.unloadable.join(', ')}`)
213
+ }
214
+ lines.push(` scored ${result.cases.length}: ${regressions} regression, ${variance} variance, ${pass} pass`)
215
+ return lines.join('\n') + '\n'
216
+ }
@@ -0,0 +1,340 @@
1
+ // Review-finding schemas and the gate decision.
2
+ //
3
+ // Reviewers produce findings; a gate decides whether the flow may proceed. That
4
+ // decision is a count, not a judgement, so it lives here rather than being
5
+ // re-argued in prose on every run: a gate blocks if and only if at least one
6
+ // surviving finding is severity `blocker`.
7
+ //
8
+ // The same argument applies to the tolerance band: after two skeptics score
9
+ // each surviving finding, one that is too poorly-grounded to be worth a human's
10
+ // attention must disappear *before* the gate counts. That filter runs inside
11
+ // `evaluateGate` rather than being described in a skill, so no prose path can
12
+ // skip it and let a quality-2 finding block a gate.
13
+ //
14
+ // This module also owns the per-file grouping that remediation fans out over,
15
+ // so the fixer agents are spawned against a stable, deduplicated partition
16
+ // rather than whatever order the reviewers happened to emit.
17
+ //
18
+ // Pure: no fs, no agent, no I/O.
19
+ // Exposed to skills as `interlock gate --findings <file>`.
20
+
21
+ export const SEVERITIES = ['blocker', 'warning', 'suggestion']
22
+
23
+ /**
24
+ * A severity, canonicalized — or null when it is not one of ours.
25
+ *
26
+ * Casing and surrounding whitespace are folded because that is the transform
27
+ * boundary this value has: `"BLOCKER"` and `" blocker "` are the same claim.
28
+ * Everything else is rejected rather than bucketed, and the direction of the
29
+ * bug that motivates it is worth stating: a reviewer emitting `"critical"` — a
30
+ * *more* alarming word than the enum's maximum — used to land in an `unknown`
31
+ * bucket the gate does not block on, so the scarier word passed the gate more
32
+ * easily than `blocker` did.
33
+ *
34
+ * @param {unknown} value
35
+ * @returns {'blocker'|'warning'|'suggestion'|null}
36
+ */
37
+ export function normalizeSeverity(value) {
38
+ if (typeof value !== 'string') return null
39
+ const folded = value.trim().toLowerCase()
40
+ return SEVERITIES.includes(folded) ? folded : null
41
+ }
42
+
43
+ /** Schema a review dimension agent must satisfy. */
44
+ export const FINDING_SCHEMA = {
45
+ type: 'object',
46
+ properties: {
47
+ dimension: { type: 'string' },
48
+ findings: {
49
+ type: 'array',
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ severity: { type: 'string', enum: [...SEVERITIES] },
54
+ file: { type: 'string' },
55
+ line: { type: 'integer' },
56
+ title: { type: 'string' },
57
+ description: { type: 'string' },
58
+ suggestion: { type: 'string' }
59
+ },
60
+ required: ['severity', 'file', 'title', 'description']
61
+ }
62
+ }
63
+ },
64
+ required: ['dimension', 'findings']
65
+ }
66
+
67
+ /** Schema a skeptic must satisfy when adversarially verifying one finding. */
68
+ export const VERDICT_SCHEMA = {
69
+ type: 'object',
70
+ properties: {
71
+ findingTitle: { type: 'string' },
72
+ file: {
73
+ type: 'string',
74
+ description:
75
+ 'The file of the finding being adjudicated. Optional, but supply it whenever the finding has one: title alone is not unique, and two findings sharing a title in different files would otherwise share one verdict set.'
76
+ },
77
+ isReal: { type: 'boolean', description: 'true if the finding is a genuine issue' },
78
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
79
+ reasoning: { type: 'string' },
80
+ evidence: {
81
+ type: 'string',
82
+ description:
83
+ 'A file:line span you actually opened, e.g. "src/auth.ts:41-58". REQUIRED to vote a finding not real: an uncited refutation does not dismiss anything, it simply does not count, and the finding survives. Voting a finding real needs no evidence — that direction is already conservative.'
84
+ },
85
+ refinedSeverity: { type: 'string', enum: [...SEVERITIES, 'dismiss'] },
86
+ qualityScore: {
87
+ type: 'integer',
88
+ minimum: 0,
89
+ maximum: 5,
90
+ description:
91
+ 'How well-grounded, actionable, and clearly-explained the finding is. 0=incomprehensible, 3=usable, 5=exemplary.'
92
+ },
93
+ severityScore: {
94
+ type: 'integer',
95
+ minimum: 0,
96
+ maximum: 5,
97
+ description:
98
+ 'Impact if the finding is real. 0=cosmetic, 3=user-visible bug, 5=data loss / security breach.'
99
+ }
100
+ },
101
+ required: [
102
+ 'findingTitle',
103
+ 'isReal',
104
+ 'confidence',
105
+ 'reasoning',
106
+ 'refinedSeverity',
107
+ 'qualityScore',
108
+ 'severityScore'
109
+ ]
110
+ }
111
+
112
+ /**
113
+ * Quality floor applied to skeptic-scored findings before the gate counts them.
114
+ * `drift` is how far the two skeptics may disagree before their disagreement is
115
+ * treated as signal in its own right. Applied by `applyToleranceBand`.
116
+ */
117
+ export const TOLERANCE_BAND = {
118
+ minQualityToReport: 3,
119
+ drift: 1
120
+ }
121
+
122
+ export const UNSCOPED_GROUP = '__unscoped__'
123
+
124
+ function fileOf(finding) {
125
+ const f = finding && finding.file
126
+ if (typeof f !== 'string') return UNSCOPED_GROUP
127
+ const trimmed = f.trim()
128
+ if (!trimmed || trimmed === '-' || /^n\/?a$/i.test(trimmed)) return UNSCOPED_GROUP
129
+ return trimmed
130
+ }
131
+
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.
134
+ function flatten(input) {
135
+ if (Array.isArray(input)) {
136
+ return input.flatMap(entry =>
137
+ entry && Array.isArray(entry.findings)
138
+ ? entry.findings.map(f => ({ dimension: entry.dimension, ...f }))
139
+ : [entry]
140
+ )
141
+ }
142
+ if (input && Array.isArray(input.findings)) {
143
+ return input.findings.map(f => ({ dimension: input.dimension, ...f }))
144
+ }
145
+ return []
146
+ }
147
+
148
+ /**
149
+ * Group findings by file.
150
+ * @returns {{ byFile: Array<{file: string, findings: Array}>, unscoped: Array }}
151
+ * byFile is safe to fan out in parallel (disjoint files); unscoped findings
152
+ * have no single owning file and must be applied last, sequentially.
153
+ */
154
+ export function groupByFile(findings) {
155
+ const map = new Map()
156
+ for (const f of findings) {
157
+ const key = fileOf(f)
158
+ if (!map.has(key)) map.set(key, [])
159
+ map.get(key).push(f)
160
+ }
161
+ const unscoped = map.get(UNSCOPED_GROUP) || []
162
+ map.delete(UNSCOPED_GROUP)
163
+ const byFile = [...map.entries()]
164
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
165
+ .map(([file, fs]) => ({ file, findings: fs }))
166
+ return { byFile, unscoped }
167
+ }
168
+
169
+ // Every quality score attached to one finding object: the canonical
170
+ // `qualityScores` array plus its own single `qualityScore`, ignoring anything
171
+ // non-numeric (a skeptic that answered "n/a" has not scored the finding).
172
+ function qualityScoresOf(finding) {
173
+ const out = []
174
+ const many = finding && finding.qualityScores
175
+ if (Array.isArray(many)) {
176
+ for (const s of many) if (typeof s === 'number' && Number.isFinite(s)) out.push(s)
177
+ }
178
+ const one = finding && finding.qualityScore
179
+ if (typeof one === 'number' && Number.isFinite(one)) out.push(one)
180
+ return out
181
+ }
182
+
183
+ // Identity used to pool scores across repeated entries: title, plus the file
184
+ // when the finding names one.
185
+ function identityOf(finding) {
186
+ const title = finding && typeof finding.title === 'string' ? finding.title : ''
187
+ return `${title}\u0000${fileOf(finding)}`
188
+ }
189
+
190
+ /**
191
+ * Partition findings by the tolerance band — drop the scored-and-weak, keep
192
+ * everything else. Dropping is invisible to the human, so every ambiguous case
193
+ * resolves towards keeping.
194
+ *
195
+ * Semantics:
196
+ * - **Unscored findings are kept.** A missing or non-numeric `qualityScore`
197
+ * means no skeptic scored it; absence of a score is not evidence of low
198
+ * quality. The band suppresses *scored-and-weak*, never *unscored*.
199
+ * - A finding is dropped when its effective quality is **strictly below**
200
+ * `band.minQualityToReport`.
201
+ * - `band.drift` is the tolerance for disagreement between the two skeptics.
202
+ * When a finding carries more than one score and `max - min > drift`, the
203
+ * disagreement is itself signal: resolve conservatively and **keep** it. When
204
+ * the scores agree within `drift`, the **higher** score is the effective
205
+ * quality.
206
+ *
207
+ * Two multi-score input shapes are supported. Canonical is `qualityScores:
208
+ * number[]` on a single finding object. Repeated entries sharing a title (and
209
+ * file, when present) each carrying their own `qualityScore` are also pooled
210
+ * into one score set — such a group is kept or dropped as a unit.
211
+ *
212
+ * @param {*} input findings in any of the shapes `evaluateGate` accepts
213
+ * @param {{minQualityToReport?: number, drift?: number}|null} [band]
214
+ * pass `null` to disable the band entirely
215
+ * @returns {{reportable: Array, dropped: Array}} flattened partition of the input
216
+ */
217
+ export function applyToleranceBand(input, band = TOLERANCE_BAND) {
218
+ const all = flatten(input).filter(Boolean)
219
+ const min = band ? Number(band.minQualityToReport) : NaN
220
+ if (!Number.isFinite(min)) return { reportable: all, dropped: [] }
221
+ const drift = Number(band.drift)
222
+ const tolerance = Number.isFinite(drift) ? drift : 0
223
+
224
+ const pooled = new Map()
225
+ for (const f of all) {
226
+ const key = identityOf(f)
227
+ if (!pooled.has(key)) pooled.set(key, [])
228
+ pooled.get(key).push(...qualityScoresOf(f))
229
+ }
230
+
231
+ const reportable = []
232
+ const dropped = []
233
+ for (const f of all) {
234
+ const scores = pooled.get(identityOf(f))
235
+ const high = Math.max(...scores)
236
+ const disagree = scores.length > 1 && high - Math.min(...scores) > tolerance
237
+ const keep = scores.length === 0 || disagree || high >= min
238
+ ;(keep ? reportable : dropped).push(f)
239
+ }
240
+ return { reportable, dropped }
241
+ }
242
+
243
+ /**
244
+ * Decide whether a gate passes.
245
+ * @param {*} input findings in any of the accepted shapes
246
+ * @param {{dismissed?: string[], band?: object|null}} opts
247
+ * `dismissed` are titles the skeptics verified away; `band` overrides the
248
+ * tolerance band (`null` disables it — the default is `TOLERANCE_BAND`).
249
+ */
250
+ export function evaluateGate(input, opts = {}) {
251
+ const dismissed = new Set(opts.dismissed || [])
252
+ const all = flatten(input).filter(Boolean)
253
+
254
+ // Order matters: dismissals first (the finding was verified away), then the
255
+ // quality band (the finding may be real but is not worth reporting), then
256
+ // counting. Both filters run here so no caller can count before filtering.
257
+ const kept = all.filter(f => !dismissed.has(f.title))
258
+ const band = opts.band === undefined ? TOLERANCE_BAND : opts.band
259
+ const { reportable: surviving, dropped } = applyToleranceBand(kept, band)
260
+
261
+ // Severity is resolved before anything is counted, and a value outside the
262
+ // enum is a REJECTION rather than a bucket. A finding whose severity the gate
263
+ // does not recognise is a finding the gate cannot act on, so it is reported
264
+ // as malformed and it blocks — silently filing it under a severity nothing
265
+ // blocks on is how "critical" became easier to pass than "blocker".
266
+ const counts = { blocker: 0, warning: 0, suggestion: 0 }
267
+ const malformed = []
268
+ const resolved = []
269
+ for (const f of surviving) {
270
+ const severity = normalizeSeverity(f.severity)
271
+ if (severity === null) {
272
+ malformed.push({
273
+ ...f,
274
+ reason:
275
+ `severity ${JSON.stringify(f.severity)} is not one of ${SEVERITIES.join('|')} — ` +
276
+ `the finding is rejected rather than counted into a bucket the gate ignores`
277
+ })
278
+ continue
279
+ }
280
+ counts[severity] += 1
281
+ resolved.push(severity === f.severity ? f : { ...f, severity })
282
+ }
283
+
284
+ const blockers = resolved.filter(f => f.severity === 'blocker')
285
+ const { byFile, unscoped } = groupByFile(resolved)
286
+
287
+ const byDimension = {}
288
+ for (const f of resolved) {
289
+ const d = f.dimension || 'unknown'
290
+ byDimension[d] = (byDimension[d] || 0) + 1
291
+ }
292
+
293
+ return {
294
+ passed: blockers.length === 0 && malformed.length === 0,
295
+ total: resolved.length,
296
+ // Findings the gate refused to interpret. Never empty-and-ignored: the
297
+ // gate does not pass while one is outstanding.
298
+ malformed,
299
+ // Two different things, reported separately: a skeptic said the finding is
300
+ // not real vs. the skeptics scored it too weakly to be worth reporting.
301
+ dismissedCount: all.length - kept.length,
302
+ droppedByQuality: dropped.length,
303
+ counts,
304
+ byDimension,
305
+ blockers,
306
+ byFile,
307
+ unscoped,
308
+ // What the caller feeds straight back into `interlock autonomy record`.
309
+ autonomyOutcome: { blockers: blockers.length }
310
+ }
311
+ }
312
+
313
+ /** Human-readable gate verdict. */
314
+ export function formatGate(result) {
315
+ const lines = []
316
+ const malformed = result.malformed || []
317
+ lines.push(
318
+ result.passed
319
+ ? `GATE PASS — ${result.total} finding(s), no blockers`
320
+ : `GATE BLOCKED — ${result.blockers.length} blocker(s) of ${result.total} finding(s)` +
321
+ (malformed.length ? ` and ${malformed.length} malformed` : '')
322
+ )
323
+ const { blocker, warning, suggestion } = result.counts
324
+ lines.push(` blocker=${blocker} warning=${warning} suggestion=${suggestion}`)
325
+ for (const m of malformed) {
326
+ lines.push(` [malformed] ${m.file || '(unscoped)'} — ${m.title}: ${m.reason}`)
327
+ }
328
+ if (result.dismissedCount) lines.push(` dismissed by skeptics: ${result.dismissedCount}`)
329
+ if (result.droppedByQuality) {
330
+ lines.push(` dropped by quality band: ${result.droppedByQuality}`)
331
+ }
332
+ for (const b of result.blockers) {
333
+ lines.push(` [blocker] ${b.file || '(unscoped)'}${b.line ? `:${b.line}` : ''} — ${b.title}`)
334
+ }
335
+ if (result.byFile.length) {
336
+ lines.push(` remediation groups: ${result.byFile.length} file(s)` +
337
+ (result.unscoped.length ? ` + ${result.unscoped.length} unscoped` : ''))
338
+ }
339
+ return lines.join('\n') + '\n'
340
+ }