// lib/review/fill-finding-ids.mjs // Finding ids are computed by code, never by the coordinator. // // The review-coordinator agent has Read/Glob/Grep and no shell, so it cannot // compute the SHA1 a finding id is. In the voyage-lekerepo trial (2026-10-06) // it wrote `@@ID_1@@` placeholders and the orchestrator filled them by hand, a // step no document named. Now the contract says it: the coordinator writes // `@@ID_@@` wherever finding n's id belongs (n = 1-based position in the // trailing JSON block's `findings` array), and this tool replaces every // placeholder with computeFindingId(file, line, rule_key) of that finding. // // Fail-closed: a placeholder with no JSON finding behind it, or a 40-hex id // in the JSON block that is not the computed one, is an error -- never a guess. // A review that is already correct passes unchanged (idempotent). // // CLI: node lib/review/fill-finding-ids.mjs // exit 0 = filled (file rewritten in place) or already correct; // 1 = unresolved placeholder / wrong id (errors on stderr, file untouched); // 2 = could not run (unreadable file, no JSON block). import { readFileSync, writeFileSync, renameSync } from 'node:fs'; import { computeFindingId } from '../parsers/finding-id.mjs'; import { extractFindingsBlock } from './findings-schema.mjs'; import { isMain } from '../util/is-main.mjs'; const PLACEHOLDER = /@@ID_(\d+)@@/g; const HEX40 = /^[0-9a-f]{40}$/; /** * @param {string} text - review.md content * @returns {{ ok: boolean, text: string, errors: string[], fatal?: boolean }} */ export function fillFindingIds(text) { const block = extractFindingsBlock(text); if (block === null) return { ok: false, fatal: true, text, errors: ['no trailing ```json block: nothing to compute ids from'] }; let parsed; try { parsed = JSON.parse(block); } catch (e) { return { ok: false, fatal: true, text, errors: [`the JSON block did not parse: ${e.message}`] }; } const findings = Array.isArray(parsed?.findings) ? parsed.findings : []; const errors = []; const ids = findings.map((f, i) => { try { return computeFindingId(f.file, f.line, f.rule_key); } catch (e) { errors.push(`findings[${i}]: cannot compute an id (${e.message})`); return null; } }); findings.forEach((f, i) => { if (typeof f.id === 'string' && HEX40.test(f.id) && ids[i] && f.id !== ids[i]) { errors.push(`findings[${i}].id ${f.id} does not match the id computed over (file, line, rule_key): ${ids[i]}`); } }); const out = text.replace(PLACEHOLDER, (m, n) => { const id = ids[Number(n) - 1]; if (!id) { errors.push(`${m} has no JSON finding ${n} behind it (${findings.length} findings)`); return m; } return id; }); if (errors.length > 0) return { ok: false, text, errors }; return { ok: true, text: out, errors: [] }; } if (isMain(import.meta.url)) { const path = process.argv[2]; if (!path) { process.stderr.write('Usage: fill-finding-ids.mjs \n'); process.exit(2); } let text; try { text = readFileSync(path, 'utf-8'); } catch (e) { process.stderr.write(`fill-finding-ids: cannot read ${path}: ${e.message}\n`); process.exit(2); } const r = fillFindingIds(text); if (!r.ok) { for (const e of r.errors) process.stderr.write(`fill-finding-ids: ${e}\n`); process.exit(r.fatal ? 2 : 1); } if (r.text !== text) { const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, r.text); renameSync(tmp, path); } process.stdout.write(`fill-finding-ids: ok ${path}\n`); }