playbook/brooks-lint/scripts/sarif.mjs

119 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
}],
};
}