/** * Convert a brooks-lint Markdown report into a SARIF 2.1.0 log. * * SARIF is what GitHub Code Scanning ingests, so emitting it lets brooks-lint * findings appear inline on the PR "Files changed" tab. Findings are parsed by * report-parse.mjs; severity maps to SARIF levels (critical→error, * warning→warning, suggestion→note). File locations are best-effort: a finding * whose Symptom names a file gets a physicalLocation, otherwise it lands at the * run level with no location (still listed, just not pinned to a line). */ import { parseFindings, RISK_CATALOG } from "./report-parse.mjs"; const INFO_URI = "https://github.com/hyhmrright/brooks-lint"; // The field guide only publishes the six production-risk anchors (#r1–#r6); // test risks live in the source file, so route T-codes there to avoid a dead link. const GUIDE_URI = "https://hyhmrright.github.io/brooks-lint/guide.html"; const TEST_RISKS_URI = "https://github.com/hyhmrright/brooks-lint/blob/main/skills/_shared/test-decay-risks.md"; const LEVEL_BY_SEVERITY = { critical: "error", warning: "warning", suggestion: "note", }; function helpUri(code) { return code.startsWith("T") ? TEST_RISKS_URI : `${GUIDE_URI}#${code.toLowerCase()}`; } /** PascalCase a risk name for use as a SARIF rule name. */ function ruleName(name) { return name.replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).join(""); } /** Stable, run-independent fingerprint so re-runs dedupe instead of stacking. */ function fingerprint(parts) { let hash = 5381; const str = parts.join(""); for (let i = 0; i < str.length; i++) hash = ((hash * 33) ^ str.charCodeAt(i)) >>> 0; return hash.toString(16).padStart(8, "0"); } function messageText(f) { const head = f.title ? `${f.riskName} — ${f.title}` : f.riskName; const fields = [ ["Symptom", f.symptom], ["Source", f.source], ["Consequence", f.consequence], ["Remedy", f.remedy], ].filter(([, v]) => v); const body = fields.map(([k, v]) => `${k}: ${v}`).join("\n"); return body ? `${head}\n\n${body}` : head; } /** * @param {string} report - the Markdown report * @param {{mode?: string, toolVersion?: string}} [opts] * @returns {object} a SARIF 2.1.0 log */ export function reportToSarif(report, opts = {}) { const { mode = "review", toolVersion = "0.0.0" } = opts; const findings = parseFindings(report); const usedCodes = [...new Set(findings.map((f) => f.riskCode).filter(Boolean))]; const rules = usedCodes.map((code) => ({ id: code, name: ruleName(RISK_CATALOG[code]), shortDescription: { text: RISK_CATALOG[code] }, helpUri: helpUri(code), })); // Findings whose risk name didn't resolve fall back to ruleId BL000; declare // it so every emitted ruleId resolves to a rule (SARIF 2.1.0 expectation). if (findings.some((f) => !f.riskCode)) { rules.push({ id: "BL000", name: "UncategorizedFinding", shortDescription: { text: "Uncategorized finding" }, }); } const results = findings.map((f) => { const result = { ruleId: f.riskCode ?? "BL000", level: LEVEL_BY_SEVERITY[f.severity] ?? "note", message: { text: messageText(f) }, partialFingerprints: { brooksLint: fingerprint([f.riskCode ?? "", f.title, f.file ?? ""]), }, }; if (f.file) { result.locations = [{ physicalLocation: { artifactLocation: { uri: f.file }, ...(f.line ? { region: { startLine: f.line } } : {}), }, }]; } return result; }); return { $schema: "https://json.schemastore.org/sarif-2.1.0.json", version: "2.1.0", runs: [{ tool: { driver: { name: "brooks-lint", informationUri: INFO_URI, version: toolVersion, rules, properties: { mode }, }, }, results, }], }; }