@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/lib/lenses.mjs ADDED
@@ -0,0 +1,200 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Lens metadata and routing.
5
+ //
6
+ // Routing used to be a prose table a model was asked to interpret. Declaring it
7
+ // in each lens's frontmatter makes the roster decision testable, and lets a bad
8
+ // roster fail before any agent is dispatched rather than after.
9
+ //
10
+ // No YAML dependency: the frontmatter this reads is a deliberately small subset
11
+ // (scalars and inline `[a, b]` lists), and a parser for that subset is smaller
12
+ // than the risk of pulling in a dependency for a five-key header.
13
+
14
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
15
+
16
+ // Split a list on commas, but NOT on commas inside {...} or quotes. A glob like
17
+ // `**/*.{js,mjs}` is one pattern; splitting it yields three broken ones, and the
18
+ // lens then matches nothing at all.
19
+ function splitList(body) {
20
+ const items = [];
21
+ let current = '';
22
+ let depth = 0;
23
+ let quote = null;
24
+ for (const ch of body) {
25
+ if (quote) {
26
+ if (ch === quote) {
27
+ quote = null;
28
+ } else {
29
+ current += ch;
30
+ }
31
+ continue;
32
+ }
33
+ if (ch === '"' || ch === "'") {
34
+ quote = ch;
35
+ continue;
36
+ }
37
+ if (ch === '{' || ch === '[') {
38
+ depth += 1;
39
+ } else if (ch === '}' || ch === ']') {
40
+ depth -= 1;
41
+ }
42
+ if (ch === ',' && depth === 0) {
43
+ items.push(current.trim());
44
+ current = '';
45
+ continue;
46
+ }
47
+ current += ch;
48
+ }
49
+ items.push(current.trim());
50
+ return items.filter(Boolean);
51
+ }
52
+
53
+ function parseScalar(raw) {
54
+ const value = raw.trim();
55
+ if (value.startsWith('[') && value.endsWith(']')) {
56
+ return splitList(value.slice(1, -1));
57
+ }
58
+ if (value === 'true') {
59
+ return true;
60
+ }
61
+ if (value === 'false') {
62
+ return false;
63
+ }
64
+ return value.replace(/^["']|["']$/g, '');
65
+ }
66
+
67
+ export function parseFrontmatter(text) {
68
+ const match = FRONTMATTER.exec(String(text ?? ''));
69
+ if (!match) {
70
+ return null;
71
+ }
72
+ const meta = {};
73
+ for (const line of match[1].split(/\r?\n/)) {
74
+ if (!line.trim() || line.trimStart().startsWith('#')) {
75
+ continue;
76
+ }
77
+ const kv = /^([A-Za-z][\w-]*):(.*)$/.exec(line);
78
+ if (!kv) {
79
+ continue;
80
+ }
81
+ meta[kv[1]] = parseScalar(kv[2]);
82
+ }
83
+ return meta;
84
+ }
85
+
86
+ export const REQUIRED_KEYS = ['name', 'summary', 'when', 'owns'];
87
+
88
+ export function validateLens(meta) {
89
+ const problems = [];
90
+ if (!meta) {
91
+ return { ok: false, problems: ['missing frontmatter block'] };
92
+ }
93
+ for (const key of REQUIRED_KEYS) {
94
+ const value = meta[key];
95
+ const empty = value == null || value === '' ||
96
+ (Array.isArray(value) && value.length === 0);
97
+ if (empty) {
98
+ problems.push(`missing or empty required key: ${key}`);
99
+ }
100
+ }
101
+ if (meta.when && !Array.isArray(meta.when)) {
102
+ problems.push('`when` must be a list of globs, e.g. [**/*.js]');
103
+ }
104
+ // A lens that never declines is a lens that dilutes consensus, so the scope
105
+ // boundary is required rather than optional.
106
+ if (meta['not-owns'] == null || meta['not-owns'] === '') {
107
+ problems.push('missing `not-owns`: every lens must state what it excludes');
108
+ }
109
+ return { ok: problems.length === 0, problems };
110
+ }
111
+
112
+ // Minimal glob matching: `**` across separators, `*` and `?` within a segment.
113
+ // Enough for path routing; deliberately not a full glob implementation.
114
+ export function globToRegExp(glob) {
115
+ let out = '';
116
+ const pattern = String(glob ?? '');
117
+ for (let i = 0; i < pattern.length; i++) {
118
+ const c = pattern[i];
119
+ if (c === '*') {
120
+ if (pattern[i + 1] === '*') {
121
+ // `**/` may match zero directories, so the separator is optional.
122
+ if (pattern[i + 2] === '/') {
123
+ out += '(?:.*/)?';
124
+ i += 2;
125
+ } else {
126
+ out += '.*';
127
+ i += 1;
128
+ }
129
+ } else {
130
+ out += '[^/]*';
131
+ }
132
+ continue;
133
+ }
134
+ if (c === '?') {
135
+ out += '[^/]';
136
+ continue;
137
+ }
138
+ if (c === '{') {
139
+ const close = pattern.indexOf('}', i);
140
+ if (close > i) {
141
+ const alts = pattern.slice(i + 1, close).split(',')
142
+ .map(a => a.replace(/[.+^${}()|[\]\\]/g, '\\$&'));
143
+ out += `(?:${alts.join('|')})`;
144
+ i = close;
145
+ continue;
146
+ }
147
+ }
148
+ out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
149
+ }
150
+ return new RegExp(`^${out}$`);
151
+ }
152
+
153
+ export function matchesAny(file, globs) {
154
+ return (globs ?? []).some(g => globToRegExp(g).test(file));
155
+ }
156
+
157
+ // lenses: [{name, when: [globs], ...}], files: [paths in scope]
158
+ // Returns { roster, skipped } — skipped carries a reason per lens, because a
159
+ // silently omitted lens reads as coverage that never happened.
160
+ export function routeRoster(lenses, files) {
161
+ const roster = [];
162
+ const skipped = [];
163
+ for (const lens of lenses ?? []) {
164
+ const matched = (files ?? []).filter(f => matchesAny(f, lens.when));
165
+ if (matched.length === 0) {
166
+ skipped.push({
167
+ lens: lens.name,
168
+ reason: `nothing in scope matches ${(lens.when ?? []).join(', ')}`
169
+ });
170
+ continue;
171
+ }
172
+ roster.push({ ...lens, files: matched });
173
+ }
174
+ return { roster, skipped };
175
+ }
176
+
177
+ // `--only a,b` / `--skip x,y` overrides, applied after routing so an explicit
178
+ // request wins over the glob decision but is still reported.
179
+ export function applyOverrides(routed, { only, skip } = {}) {
180
+ let { roster, skipped } = routed;
181
+ if (only?.length) {
182
+ const keep = new Set(only);
183
+ skipped = [
184
+ ...skipped,
185
+ ...roster.filter(l => !keep.has(l.name))
186
+ .map(l => ({ lens: l.name, reason: 'excluded by --only' }))
187
+ ];
188
+ roster = roster.filter(l => keep.has(l.name));
189
+ }
190
+ if (skip?.length) {
191
+ const drop = new Set(skip);
192
+ skipped = [
193
+ ...skipped,
194
+ ...roster.filter(l => drop.has(l.name))
195
+ .map(l => ({ lens: l.name, reason: 'excluded by --skip' }))
196
+ ];
197
+ roster = roster.filter(l => !drop.has(l.name));
198
+ }
199
+ return { roster, skipped };
200
+ }
package/lib/merge.mjs ADDED
@@ -0,0 +1,310 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Merge per-lens findings into one deduped, ranked set.
5
+ //
6
+ // This is the whole point of the panel: lenses run blind to each other, so two
7
+ // of them landing on the same location is evidence neither one alone provides.
8
+ // The merge normalizes their differing severity vocabularies onto one scale,
9
+ // collapses duplicates, and scores how much independent confirmation a finding
10
+ // actually has.
11
+ //
12
+ // Pure functions only — no IO, no process exit. The CLI in bin/ does that.
13
+
14
+ export const SEVERITIES = ['CONSIDER', 'FIX', 'BLOCK'];
15
+
16
+ const TOP_TIER =
17
+ /^(block|blocker|must[- ]fix|critical|violation|data[- ]corrupting|invisible[- ]failure|high|error)$/i;
18
+ const MIDDLE_TIER =
19
+ /^(fix|should[- ]fix|warning|warn|medium|moderate)$/i;
20
+
21
+ export function normalizeSeverity(raw) {
22
+ const value = String(raw ?? '').replace(/[[\]]/g, '').trim();
23
+ if (TOP_TIER.test(value)) {
24
+ return 'BLOCK';
25
+ }
26
+ if (MIDDLE_TIER.test(value)) {
27
+ return 'FIX';
28
+ }
29
+ return 'CONSIDER';
30
+ }
31
+
32
+ export function higherSeverity(a, b) {
33
+ return SEVERITIES.indexOf(a) >= SEVERITIES.indexOf(b) ? a : b;
34
+ }
35
+
36
+ // Same place, same issue in substance. Normalizing the text is what lets two
37
+ // lenses phrase one defect differently and still collapse.
38
+ export function normalizeIssue(issue) {
39
+ return String(issue ?? '')
40
+ .toLowerCase()
41
+ .replace(/[`'"]/g, '')
42
+ .replace(/[^a-z0-9]+/g, ' ')
43
+ .trim();
44
+ }
45
+
46
+ // A stable identity for one finding. Used for baselines and SARIF fingerprints,
47
+ // where an exact key is what is wanted. Cross-lens matching does NOT use this —
48
+ // see sameFinding().
49
+ export function findingKey(finding) {
50
+ return `${finding.file}:${finding.line}|${normalizeIssue(finding.issue)}`;
51
+ }
52
+
53
+ // Words that carry no signal about which defect is being described. Without
54
+ // this, two findings match on "the" and "a" and everything collapses.
55
+ const STOPWORDS = new Set([
56
+ 'the', 'and', 'for', 'that', 'this', 'with', 'from', 'into', 'not', 'but',
57
+ 'are', 'was', 'were', 'has', 'have', 'had', 'can', 'could', 'would', 'will',
58
+ 'its', 'it', 'is', 'be', 'been', 'so', 'than', 'then', 'when', 'which',
59
+ 'who', 'what', 'how', 'any', 'all', 'each', 'every', 'same', 'other',
60
+ 'instead', 'rather', 'without', 'before', 'after', 'here', 'there', 'they',
61
+ 'them', 'their', 'you', 'your', 'use', 'used', 'using', 'fix', 'should',
62
+ 'must', 'may', 'might', 'does', 'doing', 'done', 'line', 'lines', 'file',
63
+ 'code', 'call', 'calls', 'called', 'caller', 'callers', 'function', 'later',
64
+ 'means', 'make', 'makes', 'still', 'once', 'also', 'both', 'one', 'two'
65
+ ]);
66
+
67
+ export function issueTokens(issue) {
68
+ return new Set(normalizeIssue(issue).split(' ')
69
+ .filter(t => t.length >= 3 && !STOPWORDS.has(t)));
70
+ }
71
+
72
+ // Jaccard similarity over content words, in [0, 1]. Two lenses describing one
73
+ // defect share its nouns — the identifier, the operator, the failure — even when
74
+ // the sentences differ entirely.
75
+ export function issueSimilarity(a, b) {
76
+ const left = issueTokens(a);
77
+ const right = issueTokens(b);
78
+ if (left.size === 0 || right.size === 0) {
79
+ return 0;
80
+ }
81
+ let shared = 0;
82
+ for (const token of left) {
83
+ if (right.has(token)) {
84
+ shared += 1;
85
+ }
86
+ }
87
+ return Number((shared / (left.size + right.size - shared)).toFixed(4));
88
+ }
89
+
90
+ // Defaults derived from measured lens output, not from hand-written fixtures.
91
+ //
92
+ // Line tolerance: lenses anchor the same defect several lines apart — in one
93
+ // run a swallowed error was cited at 54, 56, and 57 by three lenses — so
94
+ // exact-line matching misses nearly every genuine agreement.
95
+ //
96
+ // Similarity threshold: across two runs, pairs describing the SAME defect
97
+ // scored 0.161 / 0.210 / 0.300 / 0.538, while pairs describing DIFFERENT
98
+ // defects that happened to share a line scored 0.038 / 0.050 (a timing side
99
+ // channel vs a nullish comparison on one expression; an unenforced expiry
100
+ // guard vs a split store contract on another). 0.12 sits in the gap between
101
+ // those bands. The margin is thinner above than below, so under-merging is the
102
+ // failure mode to expect first. Six pairs is a small sample — re-measure when
103
+ // the roster or the lens prompts change.
104
+ export const DEFAULT_LINE_TOLERANCE = 3;
105
+ export const DEFAULT_SIMILARITY_THRESHOLD = 0.12;
106
+
107
+ export function sameFinding(a, b, options = {}) {
108
+ const {
109
+ lineTolerance = DEFAULT_LINE_TOLERANCE,
110
+ similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD
111
+ } = options;
112
+ if (a.file !== b.file) {
113
+ return false;
114
+ }
115
+ if (Math.abs(a.line - b.line) > lineTolerance) {
116
+ return false;
117
+ }
118
+ // Proximity alone is not enough: two unrelated defects can share a line, as
119
+ // when one lens reports a timing side channel and another a nullish
120
+ // comparison on the same expression. Those must stay separate.
121
+ return issueSimilarity(a.issue, b.issue) >= similarityThreshold;
122
+ }
123
+
124
+ // Independence between two lenses, in [0, 1]. 1 means they never report the
125
+ // same thing, so their agreement is maximally informative; 0 means they are
126
+ // redundant and agreement adds nothing. Derived from calibration data by
127
+ // scripts/calibrate.mjs — see lensOverlap().
128
+ export function independence(a, b, overlap) {
129
+ if (a === b) {
130
+ return 0;
131
+ }
132
+ const key = [a, b].sort().join('|');
133
+ const measured = overlap?.[key];
134
+ // Absent data is treated as fully independent, and the caller is expected to
135
+ // say so rather than let an unmeasured pair look measured.
136
+ return measured == null ? 1 : 1 - measured;
137
+ }
138
+
139
+ // "Effective independent confirmations": 1 for a single lens, and for a set,
140
+ // 1 plus the summed independence of every distinct pair. Two unrelated lenses
141
+ // agreeing scores 2.0; two redundant lenses agreeing scores 1.0. This is what
142
+ // makes consensus mean something beyond counting heads.
143
+ export function consensusScore(lenses, overlap) {
144
+ const list = [...new Set(lenses ?? [])];
145
+ if (list.length <= 1) {
146
+ return 1;
147
+ }
148
+ let score = 1;
149
+ for (let i = 0; i < list.length; i++) {
150
+ for (let j = i + 1; j < list.length; j++) {
151
+ score += independence(list[i], list[j], overlap);
152
+ }
153
+ }
154
+ return Number(score.toFixed(4));
155
+ }
156
+
157
+ // Pairwise overlap measured from a set of per-lens findings: the Jaccard index
158
+ // of the finding keys each pair reported. Feed this back in as `overlap`.
159
+ export function lensOverlap(reports) {
160
+ const byLens = new Map();
161
+ for (const report of reports ?? []) {
162
+ if (!report || report.findings == null) {
163
+ continue;
164
+ }
165
+ byLens.set(report.lens,
166
+ new Set(report.findings.map(findingKey)));
167
+ }
168
+ const lenses = [...byLens.keys()].sort();
169
+ const overlap = {};
170
+ for (let i = 0; i < lenses.length; i++) {
171
+ for (let j = i + 1; j < lenses.length; j++) {
172
+ const a = byLens.get(lenses[i]);
173
+ const b = byLens.get(lenses[j]);
174
+ const union = new Set([...a, ...b]);
175
+ if (union.size === 0) {
176
+ continue;
177
+ }
178
+ let shared = 0;
179
+ for (const key of a) {
180
+ if (b.has(key)) {
181
+ shared += 1;
182
+ }
183
+ }
184
+ overlap[`${lenses[i]}|${lenses[j]}`] =
185
+ Number((shared / union.size).toFixed(4));
186
+ }
187
+ }
188
+ return overlap;
189
+ }
190
+
191
+ // reports: [{lens, findings: [{file, line, severity, issue, fix}], unparsed?}]
192
+ // findings === null means that lens did not complete.
193
+ //
194
+ // options:
195
+ // overlap — pairwise overlap map from lensOverlap(), for consensus scoring
196
+ // escalate — (finding, severity) => severity|null, a policy hook for callers
197
+ // with non-negotiable categories of their own
198
+ export function mergeFindings(reports, options = {}) {
199
+ const { overlap, escalate } = options;
200
+ const clusters = [];
201
+ const incomplete = [];
202
+ const unparsed = [];
203
+
204
+ for (const report of reports ?? []) {
205
+ if (!report || report.findings == null) {
206
+ incomplete.push(report?.lens ?? '<unknown>');
207
+ continue;
208
+ }
209
+ for (const line of report.unparsed ?? []) {
210
+ unparsed.push({ lens: report.lens, line });
211
+ }
212
+ for (const finding of report.findings) {
213
+ let severity = normalizeSeverity(finding.severity);
214
+ if (typeof escalate === 'function') {
215
+ severity = escalate(finding, severity) ?? severity;
216
+ }
217
+ // Match against clusters rather than a hash key: independently written
218
+ // prose never collides exactly, and lenses anchor the same defect a few
219
+ // lines apart. An exact-key merge reports zero agreement on real output.
220
+ const existing = clusters.find(c =>
221
+ c.members.some(m => sameFinding(m, finding, options)));
222
+ if (existing) {
223
+ existing.severity = higherSeverity(existing.severity, severity);
224
+ if (!existing.lenses.includes(report.lens)) {
225
+ existing.lenses.push(report.lens);
226
+ }
227
+ if (!existing.fix && finding.fix) {
228
+ existing.fix = finding.fix;
229
+ }
230
+ // Report the earliest anchor, so the location is stable regardless of
231
+ // which lens happened to run first.
232
+ if (finding.line < existing.line) {
233
+ existing.line = finding.line;
234
+ }
235
+ existing.members.push(finding);
236
+ existing.alsoReported.push({ lens: report.lens, issue: finding.issue });
237
+ continue;
238
+ }
239
+ clusters.push({
240
+ file: finding.file,
241
+ line: finding.line,
242
+ issue: finding.issue,
243
+ fix: finding.fix ?? null,
244
+ severity,
245
+ lenses: [report.lens],
246
+ members: [finding],
247
+ alsoReported: []
248
+ });
249
+ }
250
+ }
251
+
252
+ const findings = clusters.map(c => {
253
+ const { members, ...rest } = c;
254
+ const finding = {
255
+ ...rest,
256
+ consensus: c.lenses.length > 1,
257
+ consensusScore: consensusScore(c.lenses, overlap)
258
+ };
259
+ // The key is derived after clustering, from the reported anchor and the
260
+ // representative issue, so a baseline stays stable across runs.
261
+ finding.key = findingKey(finding);
262
+ return finding;
263
+ });
264
+
265
+ // Severity first, then the strength of independent confirmation, then
266
+ // location so the order is stable across runs.
267
+ findings.sort((a, b) =>
268
+ SEVERITIES.indexOf(b.severity) - SEVERITIES.indexOf(a.severity) ||
269
+ b.consensusScore - a.consensusScore ||
270
+ a.file.localeCompare(b.file) ||
271
+ a.line - b.line);
272
+
273
+ return { findings, incomplete, unparsed };
274
+ }
275
+
276
+ // Apply refutation verdicts from a verify pass. verdicts is keyed by finding
277
+ // key: {refuted: boolean, reason?}. Refuted findings are removed from the
278
+ // report and returned separately — a dropped finding that vanishes without a
279
+ // count is indistinguishable from one that was never found.
280
+ export function applyVerdicts(findings, verdicts) {
281
+ const kept = [];
282
+ const refuted = [];
283
+ for (const finding of findings ?? []) {
284
+ const verdict = verdicts?.[finding.key];
285
+ if (verdict?.refuted) {
286
+ refuted.push({ ...finding, refutedReason: verdict.reason ?? null });
287
+ } else {
288
+ kept.push(finding);
289
+ }
290
+ }
291
+ return { findings: kept, refuted };
292
+ }
293
+
294
+ export function countsBySeverity(findings) {
295
+ const counts = { BLOCK: 0, FIX: 0, CONSIDER: 0 };
296
+ for (const finding of findings ?? []) {
297
+ counts[finding.severity] += 1;
298
+ }
299
+ return counts;
300
+ }
301
+
302
+ export function panelVerdict(counts) {
303
+ if (counts.BLOCK > 0) {
304
+ return 'Do not ship — blockers present';
305
+ }
306
+ if (counts.FIX > 0) {
307
+ return 'Fix before merge';
308
+ }
309
+ return 'Ship';
310
+ }
package/lib/parse.mjs ADDED
@@ -0,0 +1,96 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Parse a lens's raw text output into structured findings.
5
+ //
6
+ // Lenses emit one finding per line against a fixed contract:
7
+ // file:line — SEVERITY — issue — fix
8
+ // and return exactly `NO FINDINGS` when nothing in scope is relevant.
9
+ //
10
+ // Parsing is deliberately lenient about the separator (em dash, en dash, or a
11
+ // double hyphen) and strict about everything else: a line that does not carry a
12
+ // location and a severity is not a finding, and is reported as unparsed rather
13
+ // than dropped. Silently discarding a lens's output would turn a broken lens
14
+ // into a clean one.
15
+
16
+ export const NO_FINDINGS = 'NO FINDINGS';
17
+
18
+ const SEPARATOR = /\s+(?:—|–|--)\s+/;
19
+ const LOCATION = /^(.+?):(\d+)(?::\d+)?$/;
20
+
21
+ // A lens that produced nothing usable still has to be distinguishable from a
22
+ // lens that looked and found nothing.
23
+ export function isNoFindings(text) {
24
+ return String(text ?? '').trim().toUpperCase() === NO_FINDINGS;
25
+ }
26
+
27
+ function stripBullet(line) {
28
+ return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim();
29
+ }
30
+
31
+ export function parseFindingLine(line) {
32
+ const cleaned = stripBullet(String(line ?? ''));
33
+ if (!cleaned) {
34
+ return null;
35
+ }
36
+ const parts = cleaned.split(SEPARATOR).map(p => p.trim()).filter(Boolean);
37
+ if (parts.length < 2) {
38
+ return null;
39
+ }
40
+ const location = LOCATION.exec(parts[0]);
41
+ if (!location) {
42
+ return null;
43
+ }
44
+ const [, file, line_] = location;
45
+ const severity = parts[1];
46
+ // The issue text may itself contain a separator, so the fix is the last
47
+ // segment and the issue is everything between severity and fix.
48
+ const fix = parts.length >= 4 ? parts[parts.length - 1] : null;
49
+ const issueParts = parts.length >= 4
50
+ ? parts.slice(2, parts.length - 1)
51
+ : parts.slice(2);
52
+ const issue = issueParts.join(' — ');
53
+ if (!issue) {
54
+ return null;
55
+ }
56
+ return { file, line: Number(line_), severity, issue, fix };
57
+ }
58
+
59
+ // Returns { findings, unparsed, noFindings } for one lens's raw output.
60
+ // `report` of null/undefined means the lens did not complete at all — that is
61
+ // the caller's concern (see mergeFindings), not a parse result.
62
+ export function parseLensOutput(text) {
63
+ const raw = String(text ?? '');
64
+ if (isNoFindings(raw)) {
65
+ return { findings: [], unparsed: [], noFindings: true };
66
+ }
67
+ const findings = [];
68
+ const unparsed = [];
69
+ for (const line of raw.split('\n')) {
70
+ if (!line.trim()) {
71
+ continue;
72
+ }
73
+ if (isNoFindings(line)) {
74
+ continue;
75
+ }
76
+ const parsed = parseFindingLine(line);
77
+ if (parsed) {
78
+ findings.push(parsed);
79
+ } else {
80
+ unparsed.push(line.trim());
81
+ }
82
+ }
83
+ return { findings, unparsed, noFindings: false };
84
+ }
85
+
86
+ // reports: [{lens, output}] where output is the lens's raw text, or null if the
87
+ // lens did not complete. Produces the shape mergeFindings consumes.
88
+ export function parseReports(reports) {
89
+ return (reports ?? []).map(r => {
90
+ if (!r || r.output == null) {
91
+ return { lens: r?.lens ?? '<unknown>', findings: null, unparsed: [] };
92
+ }
93
+ const { findings, unparsed } = parseLensOutput(r.output);
94
+ return { lens: r.lens, findings, unparsed };
95
+ });
96
+ }
package/lib/prompt.mjs ADDED
@@ -0,0 +1,85 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Build the prompt sent to one lens.
5
+ //
6
+ // This is the piece that has to be right for the whole pipeline to work: the
7
+ // output contract in the prompt is what `parse.mjs` expects on the way back. They
8
+ // are two halves of one agreement, so the contract text lives here rather than
9
+ // being retyped by every caller.
10
+
11
+ export const CONTRACT_LINE =
12
+ 'file:line — SEVERITY — issue — fix';
13
+
14
+ // A lens that reports on everything is useless, and a lens that quietly reviews
15
+ // outside its remit corrupts the consensus signal — so both halves of its scope
16
+ // are restated in the prompt, not just the part it owns.
17
+ export function buildLensPrompt(lens, files, options = {}) {
18
+ const {
19
+ definitionPath = null,
20
+ definition = null,
21
+ mixedCorpus = false,
22
+ extra = null
23
+ } = options;
24
+
25
+ if (!lens?.name) {
26
+ throw new Error('buildLensPrompt requires a lens with a name');
27
+ }
28
+ if (!Array.isArray(files) || files.length === 0) {
29
+ throw new Error(`no files in scope for lens ${lens.name}`);
30
+ }
31
+
32
+ const parts = [];
33
+ parts.push(`You are running the **${lens.name}** audit lens.`);
34
+
35
+ if (definition) {
36
+ parts.push(
37
+ 'Adopt this lens completely — its method, its framing, its severity ' +
38
+ 'scale, and its output contract:\n\n' +
39
+ '--- BEGIN LENS DEFINITION ---\n' + definition.trim() +
40
+ '\n--- END LENS DEFINITION ---');
41
+ } else if (definitionPath) {
42
+ parts.push(
43
+ `Read \`${definitionPath}\` and adopt it completely — its method, its ` +
44
+ 'framing, its severity scale, and its output contract.');
45
+ } else {
46
+ throw new Error(
47
+ `lens ${lens.name} has neither a definition nor a definitionPath`);
48
+ }
49
+
50
+ if (lens.owns) {
51
+ parts.push(`You own: ${lens.owns}`);
52
+ }
53
+ if (lens['not-owns']) {
54
+ parts.push(
55
+ `You do NOT own: ${lens['not-owns']}. Another lens covers those; ` +
56
+ 'reporting them here duplicates that lens and weakens the panel.');
57
+ }
58
+
59
+ parts.push(
60
+ `Audit exactly these ${files.length} file(s), reading each one completely ` +
61
+ 'before reporting on it:\n' + files.map(f => ` - ${f}`).join('\n'));
62
+
63
+ if (mixedCorpus) {
64
+ parts.push(
65
+ 'Some of these files are safe. Report a file ONLY when you can state the ' +
66
+ 'specific reason it is defective. A wrong finding costs more than a ' +
67
+ 'missed one, so silence is the correct answer wherever you cannot make ' +
68
+ 'the case.');
69
+ }
70
+
71
+ if (extra) {
72
+ parts.push(extra);
73
+ }
74
+
75
+ parts.push('Do not edit any file. This lens reports.');
76
+
77
+ parts.push(
78
+ 'Reply with ONLY finding lines, one per line, in exactly this form:\n\n' +
79
+ ` ${CONTRACT_LINE}\n\n` +
80
+ 'SEVERITY is BLOCK, FIX, or CONSIDER. Give the path as listed above. No ' +
81
+ 'preamble, no summary, no commentary between findings. If nothing in scope ' +
82
+ 'is relevant to your lens, reply with exactly:\n\n NO FINDINGS');
83
+
84
+ return parts.join('\n\n');
85
+ }