@applesnort/crosscheck 0.2.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/lenses/ux.md ADDED
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: ux
3
+ summary: usability under interruption, error, and extreme states
4
+ when: [**/*.{jsx,tsx,vue,svelte,html}, "**/components/**", "**/views/**", "**/pages/**"]
5
+ owns: situations where a real, hurried user cannot complete or understand a task
6
+ not-owns: assistive technology and WCAG criteria, correctness, architecture, security
7
+ cites: ["Nielsen's ten usability heuristics"]
8
+ ---
9
+
10
+ # Lens: ux — usability under real conditions
11
+
12
+ You are a product designer using this software as an impatient, busy person who
13
+ did not read any documentation, is interrupted mid-task, and does not care how the
14
+ system works internally. You report where the interface fails that person.
15
+
16
+ You are not the accessibility lens — assistive technology, contrast ratios, and
17
+ WCAG criteria belong there. You are not a visual-taste critic; "I would style this
18
+ differently" is not a finding.
19
+
20
+ ## Standards this lens cites
21
+
22
+ - **Nielsen's ten usability heuristics** — cite the heuristic by name where one
23
+ applies (visibility of system status, match to the real world, user control and
24
+ freedom, consistency, error prevention, recognition over recall, flexibility,
25
+ minimalist design, error recovery, help).
26
+
27
+ The heuristics are a reference frame, not a checklist to recite. A finding that
28
+ maps to none of them still counts if you can describe the user failing.
29
+
30
+ ## What you check
31
+
32
+ **System status**
33
+ - After an action, does the interface say what happened? A mutation with no
34
+ confirmation leaves the user unsure whether to retry.
35
+ - Is slow work distinguishable from broken work?
36
+ - Is a disabled control's reason discoverable, or does the user have to guess what
37
+ unlocks it?
38
+
39
+ **Destruction and reversibility**
40
+ - Irreversible actions without confirmation, and confirmations so frequent they
41
+ get clicked through.
42
+ - Destructive and routine actions adjacent enough to mis-tap.
43
+ - Work that can be lost by navigating away, refreshing, or being interrupted.
44
+ - Absence of undo where the action is reversible in principle.
45
+
46
+ **Error prevention and recovery**
47
+ - Validation that fires while typing, marking a half-entered value wrong.
48
+ - Errors that state what is invalid without stating what would be valid.
49
+ - Errors surfaced away from the field that caused them, or after the user has
50
+ moved on.
51
+ - A failed submission that discards the entered data.
52
+
53
+ **Interruption and re-entry**
54
+ - Can a multi-step flow be left and resumed, or does leaving restart it?
55
+ - Is progress through a long flow visible?
56
+ - Does the interface remember what the user already told it, or ask again?
57
+
58
+ **Recognition over recall**
59
+ - Codes, identifiers, or internal vocabulary shown where a human-readable label
60
+ belongs.
61
+ - A choice presented without the information needed to make it.
62
+ - Domain terms used inconsistently between screens for the same thing.
63
+
64
+ **Empty, first-run, and extreme states**
65
+ - Empty state: does it explain what goes here and how to add it, or show a blank
66
+ panel?
67
+ - Zero, one, and very many items — layouts that only work at the demo count.
68
+ - Long values: names, addresses, and titles that overflow, clip, or truncate the
69
+ part that distinguishes them.
70
+ - Are `0` and "not entered" visually distinct where the difference matters?
71
+
72
+ **Density and effort**
73
+ - Steps, clicks, or confirmations that do not earn their cost.
74
+ - Frequent actions buried; rare actions given prime position.
75
+ - Information needed together, split across screens.
76
+
77
+ ## Project specifics
78
+
79
+ If the project documents interaction rules — a save pattern, a validation
80
+ convention, a toast or empty-state standard, a design system — read them from the
81
+ project's own conventions and enforce them as part of this lens. Deviation from a
82
+ written house pattern is a finding; deviation from your taste is not.
83
+
84
+ ## Output
85
+
86
+ Findings only. One per line, no preamble, no summary:
87
+
88
+ ```
89
+ file:line — SEVERITY — [HEURISTIC] the user, the situation, what they cannot do — the fix
90
+ ```
91
+
92
+ `SEVERITY` is one of:
93
+
94
+ - `BLOCK` — the user loses work, cannot complete the task, or is misled about what
95
+ happened.
96
+ - `FIX` — real friction or a state the interface handles badly.
97
+ - `CONSIDER` — a refinement; say plainly that it is one.
98
+
99
+ Every finding describes a **person in a situation**, not an abstraction. "Poor
100
+ UX" is not a finding; "a user who mis-taps Delete on the row above loses the
101
+ record with no undo" is.
102
+
103
+ If nothing in scope is user-facing, return exactly `NO FINDINGS`.
104
+
105
+ Do not edit any file. This lens reports.
@@ -0,0 +1,78 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Baseline support: the difference between a panel a team keeps running and one
5
+ // they run once.
6
+ //
7
+ // The first run against an existing codebase returns hundreds of findings, and
8
+ // the report gets closed. A baseline records what was already there, so later
9
+ // runs report what the change introduced. Suppressed counts are always
10
+ // returned — a baseline that hides its own size is a way to make a codebase
11
+ // look clean by declaring its problems normal.
12
+
13
+ import { fingerprint } from './sarif.mjs';
14
+
15
+ export const BASELINE_VERSION = 1;
16
+
17
+ export function toBaseline(findings, options = {}) {
18
+ const { note = null } = options;
19
+ const entries = (findings ?? []).map(f => ({
20
+ fingerprint: fingerprint(f),
21
+ file: f.file,
22
+ line: f.line,
23
+ severity: f.severity,
24
+ // Stored for human review of the baseline file, not used for matching.
25
+ issue: f.issue,
26
+ lenses: f.lenses
27
+ }));
28
+ entries.sort((a, b) =>
29
+ a.file.localeCompare(b.file) || a.line - b.line ||
30
+ a.fingerprint.localeCompare(b.fingerprint));
31
+ return {
32
+ version: BASELINE_VERSION,
33
+ note,
34
+ count: entries.length,
35
+ findings: entries
36
+ };
37
+ }
38
+
39
+ export function baselineFingerprints(baseline) {
40
+ if (!baseline) {
41
+ return new Set();
42
+ }
43
+ if (baseline.version !== BASELINE_VERSION) {
44
+ throw new Error(
45
+ `Unsupported baseline version ${baseline.version}; expected ` +
46
+ `${BASELINE_VERSION}. Regenerate the baseline rather than editing it.`);
47
+ }
48
+ return new Set((baseline.findings ?? []).map(f => f.fingerprint));
49
+ }
50
+
51
+ // Returns { findings, suppressed } — findings not present in the baseline, and
52
+ // the ones that were. Both are returned so the caller can report the second
53
+ // number instead of quietly dropping it.
54
+ export function filterAgainstBaseline(findings, baseline) {
55
+ const known = baselineFingerprints(baseline);
56
+ if (known.size === 0) {
57
+ return { findings: findings ?? [], suppressed: [] };
58
+ }
59
+ const fresh = [];
60
+ const suppressed = [];
61
+ for (const finding of findings ?? []) {
62
+ if (known.has(fingerprint(finding))) {
63
+ suppressed.push(finding);
64
+ } else {
65
+ fresh.push(finding);
66
+ }
67
+ }
68
+ return { findings: fresh, suppressed };
69
+ }
70
+
71
+ // Entries in the baseline that no longer appear in a run. Worth surfacing:
72
+ // either they were fixed and the baseline should shrink, or the lens that found
73
+ // them stopped running and coverage silently dropped.
74
+ export function staleBaselineEntries(baseline, findings) {
75
+ const current = new Set((findings ?? []).map(fingerprint));
76
+ return (baseline?.findings ?? [])
77
+ .filter(entry => !current.has(entry.fingerprint));
78
+ }
@@ -0,0 +1,169 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Score a panel run against planted ground truth.
5
+ //
6
+ // Without this, a persona panel is unfalsifiable: you cannot tell whether it
7
+ // works, whether a new lens helped, or whether a prompt edit made it worse.
8
+ // Given a fixture whose defects are known (fixtures/calibration/expected.json)
9
+ // and the panel's own findings, this measures recall per lens, the false
10
+ // positive rate, and — the claim worth checking — whether consensus findings are
11
+ // actually more likely to be real than single-lens ones.
12
+ //
13
+ // This module scores an existing run. It does not run a panel and never
14
+ // fabricates one: if you have no panel output, you have no score.
15
+
16
+ import { normalizeSeverity } from './merge.mjs';
17
+
18
+ // A lens may reasonably anchor a finding on the offending statement, the
19
+ // enclosing function signature, or a line between them.
20
+ export function matchesDefect(finding, defect) {
21
+ if (finding.file !== defect.file) {
22
+ return false;
23
+ }
24
+ const [lo, hi] = defect.span ?? [defect.line, defect.line];
25
+ return finding.line >= lo && finding.line <= hi;
26
+ }
27
+
28
+ export function findDefect(finding, defects) {
29
+ return (defects ?? []).find(d => matchesDefect(finding, d)) ?? null;
30
+ }
31
+
32
+ // A lens gets credit for a defect if it is the expected owner or listed in
33
+ // alsoAcceptedBy. Reporting a real defect outside your remit is not scored as a
34
+ // false positive — it is real — but it does not count toward the owner's recall.
35
+ export function lensIsCredited(lens, defect) {
36
+ return defect.expectedBy === lens ||
37
+ (defect.alsoAcceptedBy ?? []).includes(lens);
38
+ }
39
+
40
+ // findings: merged findings ([{file, line, severity, lenses, consensus,
41
+ // consensusScore, issue}]). expected: the parsed expected.json.
42
+ export function score(findings, expected) {
43
+ const defects = expected?.defects ?? [];
44
+ const list = findings ?? [];
45
+
46
+ const matchedByDefect = new Map();
47
+ const truePositives = [];
48
+ const falsePositives = [];
49
+
50
+ for (const finding of list) {
51
+ const defect = findDefect(finding, defects);
52
+ if (!defect) {
53
+ falsePositives.push(finding);
54
+ continue;
55
+ }
56
+ truePositives.push({ finding, defect });
57
+ if (!matchedByDefect.has(defect.id)) {
58
+ matchedByDefect.set(defect.id, []);
59
+ }
60
+ matchedByDefect.get(defect.id).push(finding);
61
+ }
62
+
63
+ const missed = defects.filter(d => !matchedByDefect.has(d.id));
64
+
65
+ // Per-lens recall against the defects that lens is credited for.
66
+ const lenses = [...new Set(list.flatMap(f => f.lenses ?? []))].sort();
67
+ const perLens = {};
68
+ for (const lens of lenses) {
69
+ const owned = defects.filter(d => lensIsCredited(lens, d));
70
+ const found = owned.filter(d =>
71
+ (matchedByDefect.get(d.id) ?? []).some(f => (f.lenses ?? []).includes(lens)));
72
+ const reported = list.filter(f => (f.lenses ?? []).includes(lens));
73
+ const spurious = reported.filter(f => !findDefect(f, defects));
74
+ perLens[lens] = {
75
+ owned: owned.length,
76
+ found: found.length,
77
+ recall: owned.length === 0 ? null : round(found.length / owned.length),
78
+ reported: reported.length,
79
+ falsePositives: spurious.length,
80
+ precision: reported.length === 0
81
+ ? null : round((reported.length - spurious.length) / reported.length)
82
+ };
83
+ }
84
+
85
+ // Severity agreement on the defects that were found.
86
+ const severityMismatches = truePositives
87
+ .filter(({ finding, defect }) =>
88
+ normalizeSeverity(finding.severity) !== normalizeSeverity(defect.severity))
89
+ .map(({ finding, defect }) => ({
90
+ id: defect.id,
91
+ expected: normalizeSeverity(defect.severity),
92
+ reported: normalizeSeverity(finding.severity)
93
+ }));
94
+
95
+ // The load-bearing claim: is a finding several independent lenses agreed on
96
+ // more likely to be real? If these two precisions come out equal, consensus
97
+ // ranking is decoration.
98
+ const consensusFindings = list.filter(f => f.consensus === true);
99
+ const soloFindings = list.filter(f => f.consensus !== true);
100
+
101
+ return {
102
+ defects: defects.length,
103
+ found: matchedByDefect.size,
104
+ recall: defects.length === 0
105
+ ? null : round(matchedByDefect.size / defects.length),
106
+ missed: missed.map(d => ({ id: d.id, expectedBy: d.expectedBy })),
107
+ reported: list.length,
108
+ falsePositives: falsePositives.length,
109
+ precision: list.length === 0
110
+ ? null : round(truePositives.length / list.length),
111
+ severityMismatches,
112
+ consensusPrecision: precisionOf(consensusFindings, defects),
113
+ soloPrecision: precisionOf(soloFindings, defects),
114
+ consensusCount: consensusFindings.length,
115
+ soloCount: soloFindings.length,
116
+ perLens
117
+ };
118
+ }
119
+
120
+ function precisionOf(findings, defects) {
121
+ if (findings.length === 0) {
122
+ return null;
123
+ }
124
+ const real = findings.filter(f => findDefect(f, defects)).length;
125
+ return round(real / findings.length);
126
+ }
127
+
128
+ function round(n) {
129
+ return Number(n.toFixed(4));
130
+ }
131
+
132
+ export function formatScore(result) {
133
+ const pct = v => v == null ? 'n/a' : `${(v * 100).toFixed(1)}%`;
134
+ const lines = [
135
+ `defects planted: ${result.defects}`,
136
+ `defects found: ${result.found} (recall ${pct(result.recall)})`,
137
+ `findings reported: ${result.reported} (precision ${pct(result.precision)})`,
138
+ `false positives: ${result.falsePositives}`,
139
+ ''
140
+ ];
141
+ if (result.missed.length) {
142
+ lines.push('missed:');
143
+ for (const m of result.missed) {
144
+ lines.push(` - ${m.id} (expected from ${m.expectedBy})`);
145
+ }
146
+ lines.push('');
147
+ }
148
+ if (result.severityMismatches.length) {
149
+ lines.push('severity mismatches:');
150
+ for (const s of result.severityMismatches) {
151
+ lines.push(` - ${s.id}: expected ${s.expected}, reported ${s.reported}`);
152
+ }
153
+ lines.push('');
154
+ }
155
+ lines.push(
156
+ 'consensus vs solo precision — if these are equal, consensus ranking is',
157
+ 'decoration and should be dropped or reweighted:',
158
+ ` consensus (${result.consensusCount}): ${pct(result.consensusPrecision)}`,
159
+ ` solo (${result.soloCount}): ${pct(result.soloPrecision)}`,
160
+ '',
161
+ 'per lens:');
162
+ for (const [lens, s] of Object.entries(result.perLens)) {
163
+ lines.push(
164
+ ` ${lens.padEnd(16)} recall ${pct(s.recall).padStart(6)} ` +
165
+ `(${s.found}/${s.owned}) precision ${pct(s.precision).padStart(6)} ` +
166
+ `(${s.falsePositives} fp of ${s.reported})`);
167
+ }
168
+ return lines.join('\n');
169
+ }
package/lib/corpus.mjs ADDED
@@ -0,0 +1,340 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Adapter for externally authored defect corpora, and file-level scoring.
5
+ //
6
+ // The built-in fixture scores by line span, which works when you planted the
7
+ // defects and know where they are. External corpora label a whole test case
8
+ // instead, and — the reason they matter — they label cases where the code looks
9
+ // vulnerable and is not. Those are the false-positive opportunities a
10
+ // self-authored fixture cannot honestly provide, because the same hand wrote the
11
+ // decoys, the answer key, and the lens prompts.
12
+ //
13
+ // Nothing here vendors a corpus. It reads one that has been fetched locally.
14
+
15
+ // OWASP Benchmark categories mapped to the CWE they carry and the vocabulary a
16
+ // finding would use. A finding counts as matching a case only if it cites the
17
+ // CWE number or this vocabulary — a lens reporting some unrelated real issue in
18
+ // a safe case is neither credited nor penalised.
19
+ export const OWASP_CATEGORIES = {
20
+ sqli: { cwe: 89, terms: ['sql injection', 'sqli', 'sql statement', 'prepared statement', 'parameteriz', 'query concatenat'] },
21
+ weakrand: { cwe: 330, terms: ['weak random', 'insecure random', 'predictable', 'java.util.random', 'securerandom', 'insufficient entropy'] },
22
+ xss: { cwe: 79, terms: ['cross-site scripting', 'xss', 'html escap', 'output encod', 'unescaped'] },
23
+ pathtraver: { cwe: 22, terms: ['path traversal', 'directory traversal', 'file path', 'canonicaliz', '../'] },
24
+ cmdi: { cwe: 78, terms: ['command injection', 'cmdi', 'os command', 'runtime.exec', 'processbuilder', 'shell'] },
25
+ crypto: { cwe: 327, terms: ['weak cipher', 'broken cipher', 'insecure crypto', 'des', 'ecb', 'weak encryption', 'broken crypto'] },
26
+ hash: { cwe: 328, terms: ['weak hash', 'broken hash', 'md5', 'sha1', 'sha-1', 'insecure hash'] },
27
+ trustbound: { cwe: 501, terms: ['trust boundary', 'session attribute', 'untrusted data stored', 'trust violation'] },
28
+ securecookie: { cwe: 614, terms: ['secure flag', 'secure cookie', 'cookie without secure', 'httponly', 'insecure cookie'] },
29
+ ldapi: { cwe: 90, terms: ['ldap injection', 'ldapi', 'ldap filter', 'ldap query'] },
30
+ xpathi: { cwe: 643, terms: ['xpath injection', 'xpathi', 'xpath expression', 'xpath query'] }
31
+ };
32
+
33
+ // `# comment` header, then: testname,category,realVulnerability,cwe
34
+ export function parseExpectedResults(csv) {
35
+ const cases = [];
36
+ for (const line of String(csv ?? '').split(/\r?\n/)) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed || trimmed.startsWith('#')) {
39
+ continue;
40
+ }
41
+ const [name, category, real, cwe] = trimmed.split(',').map(f => f.trim());
42
+ if (!name || !category || !real) {
43
+ continue;
44
+ }
45
+ if (real !== 'true' && real !== 'false') {
46
+ throw new Error(
47
+ `unexpected label "${real}" for ${name}; expected true or false`);
48
+ }
49
+ cases.push({
50
+ name,
51
+ category,
52
+ vulnerable: real === 'true',
53
+ cwe: Number(cwe)
54
+ });
55
+ }
56
+ if (cases.length === 0) {
57
+ throw new Error('no cases parsed — is this the expectedresults CSV?');
58
+ }
59
+ return cases;
60
+ }
61
+
62
+ // The pre-registered sampling rule: for each category, the first N `true` and
63
+ // first N `false` cases by ascending test number. Mechanical and reproducible —
64
+ // no hand-picking, and re-running it on the same corpus yields the same sample.
65
+ export function sampleCases(cases, perLabel = 3) {
66
+ const byCategory = new Map();
67
+ for (const c of cases ?? []) {
68
+ if (!byCategory.has(c.category)) {
69
+ byCategory.set(c.category, []);
70
+ }
71
+ byCategory.get(c.category).push(c);
72
+ }
73
+ const sample = [];
74
+ const shortfalls = [];
75
+ for (const category of [...byCategory.keys()].sort()) {
76
+ const group = [...byCategory.get(category)]
77
+ .sort((a, b) => a.name.localeCompare(b.name));
78
+ for (const vulnerable of [true, false]) {
79
+ const matching = group.filter(c => c.vulnerable === vulnerable);
80
+ const taken = matching.slice(0, perLabel);
81
+ sample.push(...taken);
82
+ if (taken.length < perLabel) {
83
+ shortfalls.push({
84
+ category, vulnerable, wanted: perLabel, available: taken.length
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return { sample, shortfalls };
90
+ }
91
+
92
+ // Does this finding claim the vulnerability the case is about? Citing the CWE
93
+ // number is decisive; otherwise the category vocabulary has to appear.
94
+ export function findingMatchesCase(finding, testCase, categories = OWASP_CATEGORIES) {
95
+ const meta = (categories ?? OWASP_CATEGORIES)[testCase.category];
96
+ const text = `${finding.issue ?? ''} ${finding.fix ?? ''}`.toLowerCase();
97
+ const cwe = meta?.cwe ?? testCase.cwe;
98
+ if (cwe && new RegExp(`cwe[- ]?0*${cwe}\\b`, 'i').test(text)) {
99
+ return true;
100
+ }
101
+ return (meta?.terms ?? []).some(term => text.includes(term));
102
+ }
103
+
104
+ // findings must already be merged (so `lenses` and `consensus` are populated).
105
+ // Returns per-case classification plus the aggregate the claim turns on.
106
+ export function scoreCases(results, options = {}) {
107
+ const { onlyMatching = true } = options;
108
+ const perCase = [];
109
+ let truePositives = 0;
110
+ let falsePositives = 0;
111
+ let missed = 0;
112
+ let declined = 0;
113
+ let unrelated = 0;
114
+ const matchedFindings = [];
115
+
116
+ for (const { testCase, findings } of results ?? []) {
117
+ const matching = (findings ?? []).filter(f =>
118
+ findingMatchesCase(f, testCase));
119
+ const others = (findings ?? []).length - matching.length;
120
+ unrelated += others;
121
+ matchedFindings.push(...matching.map(f => ({ finding: f, testCase })));
122
+
123
+ let outcome;
124
+ if (testCase.vulnerable) {
125
+ outcome = matching.length > 0 ? 'true-positive' : 'missed';
126
+ if (matching.length > 0) {
127
+ truePositives += 1;
128
+ } else {
129
+ missed += 1;
130
+ }
131
+ } else {
132
+ outcome = matching.length > 0 ? 'false-positive' : 'declined';
133
+ if (matching.length > 0) {
134
+ falsePositives += 1;
135
+ } else {
136
+ declined += 1;
137
+ }
138
+ }
139
+ perCase.push({
140
+ name: testCase.name,
141
+ category: testCase.category,
142
+ vulnerable: testCase.vulnerable,
143
+ outcome,
144
+ matched: matching.length,
145
+ unrelated: others,
146
+ lenses: [...new Set(matching.flatMap(f => f.lenses ?? []))]
147
+ });
148
+ }
149
+
150
+ // The comparison the whole ranking rests on. A matched finding in a
151
+ // `false`-labeled case is wrong; in a `true`-labeled case it is right. So
152
+ // precision can be computed separately for consensus and solo findings.
153
+ const pool = onlyMatching ? matchedFindings : matchedFindings;
154
+ const consensus = pool.filter(m => m.finding.consensus === true);
155
+ const solo = pool.filter(m => m.finding.consensus !== true);
156
+
157
+ return {
158
+ cases: perCase.length,
159
+ truePositives,
160
+ falsePositives,
161
+ missed,
162
+ declined,
163
+ unrelatedFindings: unrelated,
164
+ recall: (truePositives + missed) === 0
165
+ ? null : round(truePositives / (truePositives + missed)),
166
+ specificity: (falsePositives + declined) === 0
167
+ ? null : round(declined / (falsePositives + declined)),
168
+ consensusPrecision: precisionOf(consensus),
169
+ soloPrecision: precisionOf(solo),
170
+ consensusCount: consensus.length,
171
+ soloCount: solo.length,
172
+ perCase
173
+ };
174
+ }
175
+
176
+ function precisionOf(matched) {
177
+ if (matched.length === 0) {
178
+ return null;
179
+ }
180
+ const correct = matched.filter(m => m.testCase.vulnerable).length;
181
+ return round(correct / matched.length);
182
+ }
183
+
184
+ // Case-level agreement.
185
+ //
186
+ // On a corpus labeled per case, the meaningful unit of agreement is "two lenses
187
+ // independently flagged this case", not "their line anchors happened to cluster
188
+ // within three lines". Two lenses can describe the same vulnerability from
189
+ // different anchors in a 70-line servlet — one at the sink, one at the source —
190
+ // and line-proximity clustering would score that as two solo findings.
191
+ //
192
+ // results: [{testCase, findings: [{lens, line, severity, issue, fix}]}]
193
+ export function scoreCaseAgreement(results, options = {}) {
194
+ const { categories } = options;
195
+ const detections = [];
196
+ const perCase = [];
197
+
198
+ for (const { testCase, findings } of results ?? []) {
199
+ const matching = (findings ?? []).filter(f =>
200
+ findingMatchesCase(f, testCase, categories));
201
+ const lenses = [...new Set(matching.map(f => f.lens))].sort();
202
+ const outcome = lenses.length === 0
203
+ ? (testCase.vulnerable ? 'missed' : 'declined')
204
+ : (testCase.vulnerable ? 'true-positive' : 'false-positive');
205
+ perCase.push({
206
+ name: testCase.name,
207
+ category: testCase.category,
208
+ vulnerable: testCase.vulnerable,
209
+ outcome,
210
+ lenses,
211
+ unrelated: (findings ?? []).length - matching.length
212
+ });
213
+ if (lenses.length > 0) {
214
+ detections.push({
215
+ name: testCase.name,
216
+ vulnerable: testCase.vulnerable,
217
+ lenses,
218
+ consensus: lenses.length > 1
219
+ });
220
+ }
221
+ }
222
+
223
+ const consensus = detections.filter(d => d.consensus);
224
+ const solo = detections.filter(d => !d.consensus);
225
+ const precision = group => group.length === 0
226
+ ? null
227
+ : round(group.filter(d => d.vulnerable).length / group.length);
228
+
229
+ const truePositives = perCase.filter(c => c.outcome === 'true-positive').length;
230
+ const falsePositives = perCase.filter(c => c.outcome === 'false-positive').length;
231
+ const missed = perCase.filter(c => c.outcome === 'missed').length;
232
+ const declined = perCase.filter(c => c.outcome === 'declined').length;
233
+
234
+ return {
235
+ cases: perCase.length,
236
+ truePositives,
237
+ falsePositives,
238
+ missed,
239
+ declined,
240
+ recall: (truePositives + missed) === 0
241
+ ? null : round(truePositives / (truePositives + missed)),
242
+ specificity: (falsePositives + declined) === 0
243
+ ? null : round(declined / (falsePositives + declined)),
244
+ detections: detections.length,
245
+ consensusCount: consensus.length,
246
+ soloCount: solo.length,
247
+ consensusPrecision: precision(consensus),
248
+ soloPrecision: precision(solo),
249
+ unrelatedFindings: perCase.reduce((n, c) => n + c.unrelated, 0),
250
+ perCase
251
+ };
252
+ }
253
+
254
+ // Measured overlap between two lenses on this corpus: the Jaccard index of the
255
+ // case sets each one flagged. Feeds the independence weighting, and answers
256
+ // whether a second lens in the same domain adds anything.
257
+ export function lensCaseOverlap(results, options = {}) {
258
+ const { categories } = options;
259
+ const byLens = new Map();
260
+ for (const { testCase, findings } of results ?? []) {
261
+ for (const f of findings ?? []) {
262
+ if (!findingMatchesCase(f, testCase, categories)) {
263
+ continue;
264
+ }
265
+ if (!byLens.has(f.lens)) {
266
+ byLens.set(f.lens, new Set());
267
+ }
268
+ byLens.get(f.lens).add(testCase.name);
269
+ }
270
+ }
271
+ const lenses = [...byLens.keys()].sort();
272
+ const overlap = {};
273
+ for (let i = 0; i < lenses.length; i++) {
274
+ for (let j = i + 1; j < lenses.length; j++) {
275
+ const a = byLens.get(lenses[i]);
276
+ const b = byLens.get(lenses[j]);
277
+ const union = new Set([...a, ...b]);
278
+ if (union.size === 0) {
279
+ continue;
280
+ }
281
+ let shared = 0;
282
+ for (const name of a) {
283
+ if (b.has(name)) {
284
+ shared += 1;
285
+ }
286
+ }
287
+ overlap[`${lenses[i]}|${lenses[j]}`] = round(shared / union.size);
288
+ }
289
+ }
290
+ return overlap;
291
+ }
292
+
293
+ function round(n) {
294
+ return Number(n.toFixed(4));
295
+ }
296
+
297
+ export function formatCaseScore(result) {
298
+ const pct = v => v == null ? 'n/a' : `${(v * 100).toFixed(1)}%`;
299
+ const lines = [
300
+ `cases scored: ${result.cases}`,
301
+ `vulnerable found: ${result.truePositives} (recall ${pct(result.recall)})`,
302
+ `vulnerable missed: ${result.missed}`,
303
+ `safe declined: ${result.declined} (specificity ${pct(result.specificity)})`,
304
+ `FALSE POSITIVES: ${result.falsePositives}`,
305
+ `unrelated findings: ${result.unrelatedFindings} (neither credited nor penalised)`,
306
+ '',
307
+ 'consensus vs solo precision — the claim under test:',
308
+ ` consensus (${result.consensusCount}): ${pct(result.consensusPrecision)}`,
309
+ ` solo (${result.soloCount}): ${pct(result.soloPrecision)}`,
310
+ ''
311
+ ];
312
+ if (result.falsePositives < 3) {
313
+ lines.push(
314
+ 'Fewer than 3 false positives — per the pre-registered criteria this is',
315
+ 'INCONCLUSIVE regardless of which precision is higher.', '');
316
+ } else if (result.consensusPrecision != null && result.soloPrecision != null) {
317
+ const delta = result.consensusPrecision - result.soloPrecision;
318
+ lines.push(
319
+ `consensus advantage: ${(delta * 100).toFixed(1)} points`,
320
+ delta >= 0.1 ? 'Criteria met: claim SUPPORTED.'
321
+ : delta <= 0 ? 'Criteria met: claim REFUTED.'
322
+ : 'Between thresholds: INCONCLUSIVE.', '');
323
+ }
324
+ const byCategory = new Map();
325
+ for (const c of result.perCase) {
326
+ if (!byCategory.has(c.category)) {
327
+ byCategory.set(c.category, { tp: 0, fp: 0, missed: 0, declined: 0 });
328
+ }
329
+ const bucket = byCategory.get(c.category);
330
+ if (c.outcome === 'true-positive') bucket.tp += 1;
331
+ else if (c.outcome === 'false-positive') bucket.fp += 1;
332
+ else if (c.outcome === 'missed') bucket.missed += 1;
333
+ else bucket.declined += 1;
334
+ }
335
+ lines.push('per category (tp / missed / fp / declined):');
336
+ for (const [category, b] of [...byCategory.entries()].sort()) {
337
+ lines.push(` ${category.padEnd(14)} ${b.tp} / ${b.missed} / ${b.fp} / ${b.declined}`);
338
+ }
339
+ return lines.join('\n');
340
+ }