Add providers.yaml `language` / Action input `language`; prompts and published summary/inline honor it. Soften fail-closed messaging when models 429; prefer cohere/nvidia in the first-3 parallel set for dogfood.
241 lines
8.1 KiB
JavaScript
241 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Merge parallel model outputs into agreed findings + disputed + fail-closed PR risk tier.
|
|
* Zero npm dependencies (Node 20 stdlib only).
|
|
*
|
|
* Finding-level severity (Critical/High/Medium/Low) is distinct from PR risk tier.
|
|
* Every finding carries: severity, exact location, why_it_matters.
|
|
*
|
|
* Agreement: fingerprint must appear in >= agreementMin distinct models (default 2).
|
|
* Security: agreed security findings block; scan hard-fail always blocks.
|
|
*/
|
|
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const dir = args.dir;
|
|
const floor = args.floor || "low";
|
|
const agreementMin = Number(args["agreement-min"] || 2);
|
|
const scanHardFail = args["scan-hard-fail"] === "true";
|
|
const language = (args.language || process.env.GATEKEEP_LANGUAGE || "en").trim() || "en";
|
|
const outPath = args.out;
|
|
|
|
const tierRank = { low: 0, medium: 1, high: 2 };
|
|
const sevRank = { Critical: 0, High: 1, Medium: 2, Low: 3 };
|
|
const maxTier = (a, b) => (tierRank[a] >= tierRank[b] ? a : b);
|
|
const normTier = (t) => {
|
|
const s = String(t || "").toLowerCase();
|
|
if (s === "low" || s === "medium" || s === "high") return s;
|
|
if (s === "easy") return "low";
|
|
if (s === "hard") return "high";
|
|
return "high";
|
|
};
|
|
|
|
const files = readdirSync(dir).filter((f) => f.startsWith("model-") && f.endsWith(".json"));
|
|
const models = [];
|
|
for (const f of files) {
|
|
try {
|
|
const j = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
if (j.error || j.parse_error) {
|
|
models.push({
|
|
model: j._model || f,
|
|
error: j.error || j.parse_error,
|
|
findings: [],
|
|
risk_tier: "high",
|
|
});
|
|
continue;
|
|
}
|
|
models.push({
|
|
model: j._model || f,
|
|
risk_tier: normTier(j.risk_tier),
|
|
summary: j.summary || "",
|
|
rationale: j.rationale || "",
|
|
findings: Array.isArray(j.findings) ? j.findings : [],
|
|
human_focus: Array.isArray(j.human_focus) ? j.human_focus : [],
|
|
});
|
|
} catch (e) {
|
|
models.push({ model: f, error: String(e), findings: [], risk_tier: "high" });
|
|
}
|
|
}
|
|
|
|
function fingerprint(f) {
|
|
const path = String(f.path || "").replace(/\\/g, "/").toLowerCase();
|
|
const lineBucket = f.line ? String(Math.floor(Number(f.line) / 5) * 5) : "file";
|
|
const kind = String(f.kind || "other").toLowerCase();
|
|
const title = String(f.title || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.slice(0, 8)
|
|
.join(" ");
|
|
return `${kind}|${path}|${lineBucket}|${title}`;
|
|
}
|
|
|
|
function normSeverity(raw, kind, tag) {
|
|
const s = String(raw || "").trim();
|
|
const map = {
|
|
critical: "Critical",
|
|
crit: "Critical",
|
|
high: "High",
|
|
medium: "Medium",
|
|
med: "Medium",
|
|
low: "Low",
|
|
important: "High",
|
|
nit: "Low",
|
|
};
|
|
if (map[s.toLowerCase()]) return map[s.toLowerCase()];
|
|
// Infer from kind/tag when model omitted severity
|
|
if (String(kind).toLowerCase() === "security") return "Critical";
|
|
if (tag === "Important") return "High";
|
|
if (tag === "Nit") return "Low";
|
|
if (tag === "Pre-existing") return "Medium";
|
|
return "Medium";
|
|
}
|
|
|
|
function formatLocation(path, line, lineEnd) {
|
|
const p = String(path || "").trim() || "(unknown file)";
|
|
if (line == null || line === "") return p;
|
|
if (lineEnd != null && Number(lineEnd) > Number(line)) return `${p}:${line}-${lineEnd}`;
|
|
return `${p}:${line}`;
|
|
}
|
|
|
|
function normalizeFinding(f, model) {
|
|
const kind = ["security", "correctness", "behavior", "other"].includes(String(f.kind).toLowerCase())
|
|
? String(f.kind).toLowerCase()
|
|
: "other";
|
|
let tag = ["Important", "Nit", "Pre-existing"].includes(f.tag) ? f.tag : "Important";
|
|
if (f.pre_existing === true) tag = "Pre-existing";
|
|
const severity = normSeverity(f.severity, kind, tag);
|
|
const path = String(f.path || "");
|
|
const line = typeof f.line === "number" ? f.line : undefined;
|
|
const line_end = typeof f.line_end === "number" ? f.line_end : undefined;
|
|
const why =
|
|
String(f.why_it_matters || f.why || f.impact || "").trim() ||
|
|
// Fall back: last sentence-ish of body, else body itself
|
|
(() => {
|
|
const body = String(f.body || "").trim();
|
|
if (!body) return "Impact not spelled out — treat as unresolved until clarified.";
|
|
const parts = body.split(/\n+/).map((x) => x.trim()).filter(Boolean);
|
|
return parts[parts.length - 1] || body;
|
|
})();
|
|
const title = String(f.title || "Finding");
|
|
const body = String(f.body || "");
|
|
return {
|
|
path,
|
|
line,
|
|
line_end,
|
|
location: formatLocation(path, line, line_end),
|
|
severity,
|
|
tag,
|
|
kind,
|
|
title,
|
|
body,
|
|
why_it_matters: why,
|
|
blocking: Boolean(f.blocking) || kind === "security" || severity === "Critical",
|
|
models: [model],
|
|
fingerprint: fingerprint({ ...f, kind, title }),
|
|
};
|
|
}
|
|
|
|
function preferSeverity(a, b) {
|
|
return (sevRank[a] ?? 9) <= (sevRank[b] ?? 9) ? a : b;
|
|
}
|
|
|
|
/** @type {Map<string, any>} */
|
|
const bucket = new Map();
|
|
for (const m of models) {
|
|
for (const raw of m.findings) {
|
|
const n = normalizeFinding(raw, m.model);
|
|
const prev = bucket.get(n.fingerprint);
|
|
if (!prev) {
|
|
bucket.set(n.fingerprint, n);
|
|
} else {
|
|
if (!prev.models.includes(m.model)) prev.models.push(m.model);
|
|
prev.severity = preferSeverity(prev.severity, n.severity);
|
|
if (n.tag === "Important") prev.tag = "Important";
|
|
if (n.tag === "Pre-existing" && prev.tag !== "Important") prev.tag = "Pre-existing";
|
|
if ((n.why_it_matters || "").length > (prev.why_it_matters || "").length) {
|
|
prev.why_it_matters = n.why_it_matters;
|
|
}
|
|
if ((n.body || "").length > (prev.body || "").length) prev.body = n.body;
|
|
if (n.blocking) prev.blocking = true;
|
|
if (!prev.line && n.line) prev.line = n.line;
|
|
if (!prev.line_end && n.line_end) prev.line_end = n.line_end;
|
|
prev.location = formatLocation(prev.path, prev.line, prev.line_end);
|
|
}
|
|
}
|
|
}
|
|
|
|
const agreed = [];
|
|
const disputed = [];
|
|
for (const f of bucket.values()) {
|
|
if (f.models.length >= agreementMin) agreed.push(f);
|
|
else disputed.push(f);
|
|
}
|
|
|
|
const bySeverity = (a, b) => (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ?? 9);
|
|
agreed.sort(bySeverity);
|
|
disputed.sort(bySeverity);
|
|
|
|
// Prefer successful model tiers; only if every call failed, fail-closed high.
|
|
const okModels = models.filter((m) => !m.error);
|
|
const tierSources = okModels.length ? okModels : models;
|
|
let risk_tier = floor;
|
|
for (const m of tierSources) risk_tier = maxTier(risk_tier, m.risk_tier);
|
|
if (!okModels.length) risk_tier = maxTier(risk_tier, "high");
|
|
for (const f of agreed) {
|
|
if (f.kind === "security" || f.severity === "Critical") risk_tier = maxTier(risk_tier, "high");
|
|
else if (f.severity === "High" || f.tag === "Important") risk_tier = maxTier(risk_tier, "medium");
|
|
}
|
|
if (scanHardFail) risk_tier = maxTier(risk_tier, "high");
|
|
|
|
const openSecurityBlocking =
|
|
scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking);
|
|
|
|
const modelErrors = models.filter((m) => m.error).length;
|
|
|
|
const result = {
|
|
risk_tier,
|
|
policy_floor: floor,
|
|
agreement_min: agreementMin,
|
|
language,
|
|
model_errors: modelErrors,
|
|
models_ok: okModels.length,
|
|
models: models.map((m) => ({
|
|
id: m.model,
|
|
risk_tier: m.risk_tier,
|
|
finding_count: m.findings.length,
|
|
error: m.error || null,
|
|
})),
|
|
agreed_findings: agreed,
|
|
disputed_findings: disputed,
|
|
open_security_blocking: openSecurityBlocking,
|
|
scan_hard_fail: scanHardFail,
|
|
human_focus: [...new Set(models.flatMap((m) => m.human_focus || []))].slice(0, 12),
|
|
summaries: models.map((m) => ({ model: m.model, summary: m.summary, rationale: m.rationale })),
|
|
};
|
|
|
|
writeFileSync(outPath, JSON.stringify(result, null, 2));
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
risk_tier: result.risk_tier,
|
|
security_blocking: openSecurityBlocking,
|
|
agreed_count: agreed.length,
|
|
disputed_count: disputed.length,
|
|
}) + "\n",
|
|
);
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith("--")) {
|
|
const key = a.slice(2);
|
|
const val = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
out[key] = val;
|
|
}
|
|
}
|
|
return out;
|
|
}
|