@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,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
|
+
}
|
package/lib/findings.mjs
ADDED
|
@@ -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
|
+
}
|