@fitsummehari/mergeguard 0.1.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/src/review.js ADDED
@@ -0,0 +1,137 @@
1
+ import { loadConfig, normalizeConfig } from "./config.js";
2
+ import { collectChangedFiles, repositoryInfo, repositoryRoot, resolveReviewScope } from "./git.js";
3
+ import { buildRepositoryContext, contextForCandidate } from "./context.js";
4
+ import { diffRegressionCandidates, patternCandidates, staticSignals } from "./detectors/patterns.js";
5
+ import { repositoryAwareCandidates } from "./detectors/repository.js";
6
+ import { verifyCandidates } from "./verifiers/index.js";
7
+ import { dedupe, isAtLeastSeverity, matchesAnyGlob, severityRank } from "./utils.js";
8
+ import { stripControlChars } from "./paths.js";
9
+ import { VERSION } from "./version.js";
10
+
11
+ /**
12
+ * Review a Git change set and return structured findings.
13
+ * @param {object} [options]
14
+ * @param {string} [options.cwd]
15
+ * @param {string} [options.base]
16
+ * @param {string} [options.head]
17
+ * @param {boolean} [options.staged]
18
+ * @param {boolean} [options.push]
19
+ * @returns {Promise<import("./types.js").ReviewResult>}
20
+ */
21
+ export async function review(options = {}) {
22
+ const root = repositoryRoot(options.cwd || process.cwd());
23
+ const loaded = loadConfig(root, options.configPath);
24
+ const config = applyOverrides(loaded.config, options);
25
+ const scope = resolveReviewScope(root, options);
26
+ const changed = collectChangedFiles(root, scope, {
27
+ maxFiles: config.maxFiles,
28
+ includeUntracked: options.includeUntracked !== false,
29
+ ignore: config.ignore,
30
+ });
31
+ const files = changed.files;
32
+ const warnings = [...(loaded.warnings || [])];
33
+ if (changed.truncated) warnings.push(`${changed.totalFiles} files changed; only the first ${config.maxFiles} were analyzed.`);
34
+
35
+ const repoContext = buildRepositoryContext(root, files, config);
36
+ let candidates = [
37
+ ...patternCandidates(files, { maxCandidates: config.maxCandidates }),
38
+ ...diffRegressionCandidates(files),
39
+ ...repositoryAwareCandidates(repoContext, files),
40
+ ];
41
+ candidates = dedupeCandidates(candidates)
42
+ .filter((candidate) => config.categories[candidate.category] !== false)
43
+ .sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || (b.reviewerConfidence || 0) - (a.reviewerConfidence || 0))
44
+ .slice(0, config.maxCandidates);
45
+
46
+ const verification = await verifyCandidates(
47
+ candidates,
48
+ (candidate) => contextForCandidate(repoContext, candidate, config),
49
+ config,
50
+ { onWarning: (warning) => warnings.push(warning) },
51
+ );
52
+ const findings = verification.findings.map(sanitizeFinding);
53
+ const summary = summarize(files, findings, changed.totalFiles, changed.ignoredFiles);
54
+ const blocking = findings.some((finding) => isAtLeastSeverity(finding.severity, config.failOn));
55
+ const info = repositoryInfo(root);
56
+ return {
57
+ version: VERSION,
58
+ passed: !blocking,
59
+ blocking,
60
+ failOn: config.failOn,
61
+ repository: { root, branch: info.branch, headSha: info.headSha, remoteUrl: info.remoteUrl, stack: repoContext.stack, languages: repoContext.languages },
62
+ scope: { mode: scope.mode, label: scope.label, baseRef: scope.baseRef, headRef: scope.headRef },
63
+ config: { path: loaded.path, failOn: config.failOn, confidence: config.confidence, verifier: config.verifier },
64
+ verifier: verification.provider,
65
+ findings,
66
+ signals: staticSignals(files),
67
+ summary,
68
+ warnings,
69
+ metadata: { filesReviewed: files.length, candidateCount: candidates.length, truncated: changed.truncated },
70
+ };
71
+ }
72
+
73
+ export async function reviewChangeSet({ root, files, scope = { mode: "synthetic", label: "provided change set" }, config: inputConfig = {}, repository = {} }) {
74
+ const config = normalizeConfig(inputConfig);
75
+ const filtered = files.filter((file) => !matchesAnyGlob(file.path, config.ignore));
76
+ const repoContext = buildRepositoryContext(root, filtered, config);
77
+ let candidates = [...patternCandidates(filtered, { maxCandidates: config.maxCandidates }), ...diffRegressionCandidates(filtered), ...repositoryAwareCandidates(repoContext, filtered)];
78
+ candidates = dedupeCandidates(candidates).filter((item) => config.categories[item.category] !== false).slice(0, config.maxCandidates);
79
+ const verification = await verifyCandidates(candidates, (candidate) => contextForCandidate(repoContext, candidate, config), config);
80
+ const findings = verification.findings.map(sanitizeFinding);
81
+ const blocking = findings.some((f) => isAtLeastSeverity(f.severity, config.failOn));
82
+ return {
83
+ version: VERSION,
84
+ passed: !blocking,
85
+ blocking,
86
+ failOn: config.failOn,
87
+ repository: { root, ...repository, stack: repoContext.stack, languages: repoContext.languages },
88
+ scope,
89
+ config: { failOn: config.failOn, confidence: config.confidence, verifier: config.verifier },
90
+ verifier: verification.provider,
91
+ findings,
92
+ signals: staticSignals(filtered),
93
+ summary: summarize(filtered, findings, files.length),
94
+ warnings: [],
95
+ metadata: { filesReviewed: filtered.length, candidateCount: candidates.length, truncated: false },
96
+ };
97
+ }
98
+
99
+ function applyOverrides(base, options) {
100
+ const config = structuredClone(base);
101
+ if (options.failOn) config.failOn = String(options.failOn).toLowerCase();
102
+ if (options.verifier) config.verifier = options.verifier === "deterministic" ? "offline" : options.verifier;
103
+ if (options.noAi) config.verifier = "offline";
104
+ if (Number.isFinite(options.confidence)) config.confidence = Number(options.confidence);
105
+ if (Number.isInteger(options.maxFiles) && options.maxFiles > 0) config.maxFiles = options.maxFiles;
106
+ if (Number.isInteger(options.maxCandidates) && options.maxCandidates > 0) config.maxCandidates = options.maxCandidates;
107
+ return config;
108
+ }
109
+
110
+ function dedupeCandidates(candidates) {
111
+ return dedupe(candidates, (item) => {
112
+ const detector = item.detector || item.title;
113
+ const family = /check-then-create|read-check-write|repo-no-unique-protection/.test(detector)
114
+ ? "concurrency-race"
115
+ : /auth-protection-removed|repo-sensitive-route-no-auth|allow-anonymous/.test(detector)
116
+ ? "authorization-boundary"
117
+ : detector;
118
+ return `${item.file}:${item.startLine || 0}:${family}`;
119
+ });
120
+ }
121
+
122
+ function summarize(files, findings, totalChangedFiles, ignoredFiles = 0) {
123
+ const summary = { filesReviewed: files.length, totalChangedFiles, ignoredFiles, findings: findings.length, critical: 0, high: 0, medium: 0, low: 0, info: 0 };
124
+ for (const finding of findings) summary[finding.severity]++;
125
+ return summary;
126
+ }
127
+
128
+ function sanitizeFinding(finding) {
129
+ return {
130
+ ...finding,
131
+ title: stripControlChars(finding.title),
132
+ description: stripControlChars(finding.description),
133
+ remediation: finding.remediation ? stripControlChars(finding.remediation) : finding.remediation,
134
+ file: stripControlChars(finding.file),
135
+ evidence: (finding.evidence || []).map((item) => stripControlChars(item)),
136
+ };
137
+ }
package/src/types.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Public review contracts. The CLI is a wrapper around {@link review}.
3
+ *
4
+ * @typedef {"critical"|"high"|"medium"|"low"|"info"} Severity
5
+ * @typedef {"correctness"|"security"|"concurrency"|"database"|"authorization"|"tenant-isolation"|"reliability"|"performance"|"api"} Category
6
+ *
7
+ * @typedef {object} ChangedFile
8
+ * @property {string} path
9
+ * @property {"added"|"modified"|"deleted"|"renamed"} status
10
+ * @property {string} [patch]
11
+ * @property {string} [headContent]
12
+ * @property {string} [baseContent]
13
+ *
14
+ * @typedef {object} Finding
15
+ * @property {string} id
16
+ * @property {string} detector
17
+ * @property {Category} category
18
+ * @property {Severity} severity
19
+ * @property {string} title
20
+ * @property {string} description
21
+ * @property {string} file
22
+ * @property {number} [startLine]
23
+ * @property {string[]} [evidence]
24
+ * @property {string} [remediation]
25
+ * @property {number} confidence
26
+ *
27
+ * @typedef {object} ReviewResult
28
+ * @property {string} version
29
+ * @property {boolean} passed
30
+ * @property {boolean} blocking
31
+ * @property {Finding[]} findings
32
+ * @property {object} summary
33
+ * @property {object} metadata
34
+ */
35
+
36
+ export {};
package/src/utils.js ADDED
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
6
+ export const CATEGORIES = [
7
+ "correctness",
8
+ "security",
9
+ "concurrency",
10
+ "database",
11
+ "authorization",
12
+ "tenant-isolation",
13
+ "reliability",
14
+ "performance",
15
+ "api",
16
+ ];
17
+
18
+ export function stableId(parts) {
19
+ const text = Array.isArray(parts) ? parts.join("|") : String(parts);
20
+ return createHash("sha1").update(text).digest("hex").slice(0, 16);
21
+ }
22
+
23
+ export function clamp01(value) {
24
+ const n = Number(value);
25
+ if (!Number.isFinite(n)) return 0;
26
+ return Math.max(0, Math.min(1, n));
27
+ }
28
+
29
+ export function severityRank(severity) {
30
+ const index = SEVERITIES.indexOf(severity);
31
+ return index === -1 ? 99 : index;
32
+ }
33
+
34
+ export function isAtLeastSeverity(severity, threshold) {
35
+ if (!threshold || threshold === "none") return false;
36
+ return severityRank(severity) <= severityRank(threshold);
37
+ }
38
+
39
+ export function findUp(filename, start = process.cwd()) {
40
+ let current = resolve(start);
41
+ for (;;) {
42
+ const candidate = resolve(current, filename);
43
+ if (existsSync(candidate)) return candidate;
44
+ const parent = dirname(current);
45
+ if (parent === current) return undefined;
46
+ current = parent;
47
+ }
48
+ }
49
+
50
+ export function readText(path, maxBytes = 1_500_000) {
51
+ try {
52
+ const buffer = readFileSync(path);
53
+ if (buffer.includes(0)) return undefined;
54
+ return buffer.subarray(0, maxBytes).toString("utf8");
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ export function normalizePath(path) {
61
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
62
+ }
63
+
64
+ export function globToRegExp(glob) {
65
+ const normalized = normalizePath(glob);
66
+ let source = "";
67
+ for (let i = 0; i < normalized.length; i++) {
68
+ const char = normalized[i];
69
+ if (char === "*") {
70
+ if (normalized[i + 1] === "*") {
71
+ i++;
72
+ if (normalized[i + 1] === "/") {
73
+ i++;
74
+ source += "(?:.*/)?";
75
+ } else source += ".*";
76
+ } else source += "[^/]*";
77
+ } else if (char === "?") source += "[^/]";
78
+ else source += char.replace(/[\\^$+?.()|{}[\]]/g, "\\$&");
79
+ }
80
+ return new RegExp(`^${source}$`);
81
+ }
82
+
83
+ export function matchesAnyGlob(path, globs = []) {
84
+ const normalized = normalizePath(path);
85
+ return globs.some((glob) => {
86
+ try {
87
+ return globToRegExp(glob).test(normalized);
88
+ } catch {
89
+ return false;
90
+ }
91
+ });
92
+ }
93
+
94
+ export function truncate(text, max = 800) {
95
+ if (!text) return "";
96
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
97
+ }
98
+
99
+ export function dedupe(items, keyFn) {
100
+ const map = new Map();
101
+ for (const item of items) {
102
+ const key = keyFn(item);
103
+ const existing = map.get(key);
104
+ if (!existing || (item.confidence ?? item.reviewerConfidence ?? 0) > (existing.confidence ?? existing.reviewerConfidence ?? 0)) {
105
+ map.set(key, item);
106
+ }
107
+ }
108
+ return [...map.values()];
109
+ }
110
+
111
+ export function parseScalar(raw) {
112
+ const value = raw.trim();
113
+ if (!value) return {};
114
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
115
+ if (value === "true") return true;
116
+ if (value === "false") return false;
117
+ if (value === "null" || value === "~") return null;
118
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
119
+ if (value.startsWith("[") && value.endsWith("]")) {
120
+ return value.slice(1, -1).split(",").map((x) => parseScalar(x)).filter((x) => x !== "");
121
+ }
122
+ return value;
123
+ }
124
+
125
+ export function parseSimpleYaml(text) {
126
+ const root = {};
127
+ const stack = [{ indent: -1, value: root }];
128
+ const lines = text.replace(/\t/g, " ").split(/\r?\n/);
129
+ for (let index = 0; index < lines.length; index++) {
130
+ const original = lines[index];
131
+ const noComment = stripYamlComment(original);
132
+ if (!noComment.trim()) continue;
133
+ const indent = noComment.match(/^ */)[0].length;
134
+ const content = noComment.trim();
135
+ while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop();
136
+ const parent = stack.at(-1).value;
137
+ if (content.startsWith("- ")) {
138
+ if (!Array.isArray(parent)) throw new Error(`Unsupported YAML list placement at line ${index + 1}`);
139
+ parent.push(parseScalar(content.slice(2)));
140
+ continue;
141
+ }
142
+ const colon = content.indexOf(":");
143
+ if (colon < 1) throw new Error(`Invalid YAML at line ${index + 1}`);
144
+ const key = content.slice(0, colon).trim();
145
+ const rest = content.slice(colon + 1).trim();
146
+ if (rest) {
147
+ parent[key] = parseScalar(rest);
148
+ continue;
149
+ }
150
+ const next = nextMeaningful(lines, index + 1);
151
+ const child = next && next.indent > indent && next.content.startsWith("- ") ? [] : {};
152
+ parent[key] = child;
153
+ stack.push({ indent, value: child });
154
+ }
155
+ return root;
156
+ }
157
+
158
+ function nextMeaningful(lines, start) {
159
+ for (let i = start; i < lines.length; i++) {
160
+ const line = stripYamlComment(lines[i]).replace(/\t/g, " ");
161
+ if (!line.trim()) continue;
162
+ return { indent: line.match(/^ */)[0].length, content: line.trim() };
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function stripYamlComment(line) {
168
+ let quote = null;
169
+ for (let i = 0; i < line.length; i++) {
170
+ const char = line[i];
171
+ if ((char === '"' || char === "'") && line[i - 1] !== "\\") quote = quote === char ? null : quote || char;
172
+ if (char === "#" && !quote) return line.slice(0, i);
173
+ }
174
+ return line;
175
+ }
176
+
177
+ export function pathLineSnippet(content, line, radius = 2) {
178
+ if (!content || !line) return undefined;
179
+ const lines = content.split(/\r?\n/);
180
+ const start = Math.max(0, line - 1 - radius);
181
+ const end = Math.min(lines.length, line + radius);
182
+ return lines.slice(start, end).map((value, i) => `${String(start + i + 1).padStart(5)} | ${value}`).join("\n");
183
+ }
@@ -0,0 +1,48 @@
1
+ import { stableId, clamp01 } from "../utils.js";
2
+ import { offlineVerify } from "./offline.js";
3
+ import { detectLaya, verifyWithLaya } from "./laya.js";
4
+ import { verifyWithJev } from "./jev.js";
5
+
6
+ export function resolveVerifier(config = {}) {
7
+ const configured = config.verifier === "deterministic" ? "offline" : (config.verifier || "offline");
8
+ if (configured === "auto") {
9
+ const laya = detectLaya(config);
10
+ return {
11
+ configured: "auto",
12
+ effective: laya.available ? "laya" : "offline",
13
+ reason: laya.available ? "Laya is installed" : "Laya is not installed",
14
+ };
15
+ }
16
+ return { configured, effective: configured, reason: "explicit configuration" };
17
+ }
18
+
19
+ export async function verifyCandidates(candidates, contextFor, config, { onWarning } = {}) {
20
+ const items = candidates.map((candidate) => ({ candidate, context: contextFor(candidate) }));
21
+ let verifications;
22
+ const resolved = resolveVerifier(config);
23
+ let provider = resolved.effective;
24
+
25
+ if (provider === "laya") {
26
+ try { verifications = verifyWithLaya(items, config); }
27
+ catch (error) {
28
+ if (config.verifier === "laya") throw error;
29
+ onWarning?.(`Laya unavailable; falling back to deterministic verification (${error.message})`);
30
+ provider = "offline";
31
+ }
32
+ }
33
+ if (provider === "jev") verifications = await verifyWithJev(items, config);
34
+ if (!verifications) verifications = items.map(({candidate,context}) => offlineVerify(candidate,context,config));
35
+
36
+ const findings=[];
37
+ for(let i=0;i<candidates.length;i++){
38
+ const candidate=candidates[i], verification=verifications[i];
39
+ if(verification.verdict!=="report") continue;
40
+ const confidence=clamp01((candidate.reviewerConfidence??.6)*.34+verification.plausible*.24+verification.reachable*.12+(1-verification.existingProtection)*.10+verification.worthReporting*.20);
41
+ findings.push({...candidate,id:candidate.id||stableId([candidate.file,candidate.detector||candidate.title,String(candidate.startLine||0)]),confidence:Number(confidence.toFixed(3)),verification});
42
+ }
43
+ findings.sort((a,b)=>severityScore(b.severity)-severityScore(a.severity)||b.confidence-a.confidence);
44
+ return { findings, provider };
45
+ }
46
+
47
+ function severityScore(s){return {critical:5,high:4,medium:3,low:2,info:1}[s]||0;}
48
+ export { detectLaya, detectPython, LAYA_MISSING_MESSAGE } from "./laya.js";
@@ -0,0 +1,75 @@
1
+ import { verificationQuestions, IMPACT_LABELS } from "./questions.js";
2
+ import { clamp01 } from "../utils.js";
3
+
4
+ export async function verifyWithJev(items, config) {
5
+ const key = process.env.TYPESAFE_API_KEY || process.env.JEV_API_KEY;
6
+ if (!key) throw new Error("Jev verifier selected but TYPESAFE_API_KEY or JEV_API_KEY is not set.");
7
+ const timeoutMs = Number(process.env.MERGEGUARD_JEV_TIMEOUT_MS || 30_000);
8
+ const out = [];
9
+ for (const { candidate, context } of items) {
10
+ const controller = new AbortController();
11
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
12
+ let response;
13
+ try {
14
+ response = await fetch(config.jev.url, {
15
+ method: "POST",
16
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
17
+ body: JSON.stringify({ model: config.jev.model, state: context, questions: verificationQuestions }),
18
+ signal: controller.signal,
19
+ });
20
+ } catch (error) {
21
+ if (error?.name === "AbortError") throw new Error(`Jev verification timed out after ${timeoutMs}ms`);
22
+ throw new Error("Jev verification request failed");
23
+ } finally {
24
+ clearTimeout(timer);
25
+ }
26
+ if (!response.ok) throw new Error(`Jev verification failed with HTTP ${response.status}`);
27
+ let json;
28
+ try {
29
+ json = await response.json();
30
+ } catch {
31
+ throw new Error("Jev verification returned invalid JSON");
32
+ }
33
+ out.push(normalize(candidate, json.answers || {}, config));
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function normalize(candidate, answers, config) {
39
+ const noul = (key, fallback) => typeof answers[key]?.noul === "number" ? answers[key].noul : fallback;
40
+ const plausible = noul("plausible", candidate.reviewerConfidence ?? 0.6);
41
+ const reachable = noul("reachable", 0.68);
42
+ const existingProtection = noul("protected", 0.2);
43
+ const worthReporting = noul("report", candidate.reviewerConfidence ?? 0.6);
44
+ const impact = scoreTo01(answers.severe, candidate.severity);
45
+ const strength = plausible * 0.31 + reachable * 0.18 + impact * 0.12 + (1 - existingProtection) * 0.14 + worthReporting * 0.25;
46
+ return {
47
+ plausible: r(plausible),
48
+ reachable: r(reachable),
49
+ impact: r(impact),
50
+ existingProtection: r(existingProtection),
51
+ worthReporting: r(worthReporting),
52
+ strength: r(strength),
53
+ verdict: strength >= config.confidence ? "report" : "suppress",
54
+ provider: "jev",
55
+ };
56
+ }
57
+
58
+ function scoreTo01(answer, severity) {
59
+ if (typeof answer?.score === "number") return answer.score > 1 ? Math.min(1, answer.score / 4) : clamp01(answer.score);
60
+ if (answer?.choice) return IMPACT_LABELS[answer.choice] ?? 0.55;
61
+ if (answer?.probabilities && typeof answer.probabilities === "object") {
62
+ let sum = 0;
63
+ let total = 0;
64
+ for (const [label, probability] of Object.entries(answer.probabilities)) {
65
+ if (Object.hasOwn(IMPACT_LABELS, label) && typeof probability === "number") {
66
+ sum += IMPACT_LABELS[label] * probability;
67
+ total += probability;
68
+ }
69
+ }
70
+ if (total) return sum / total;
71
+ }
72
+ return { critical: 1, high: 0.8, medium: 0.55, low: 0.3, info: 0.15 }[severity] ?? 0.55;
73
+ }
74
+
75
+ function r(value) { return Number(clamp01(value).toFixed(3)); }
@@ -0,0 +1,131 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { verificationQuestions, IMPACT_LABELS } from "./questions.js";
4
+ import { clamp01 } from "../utils.js";
5
+
6
+ const bridgePath = fileURLToPath(new URL("../../scripts/laya_bridge.py", import.meta.url));
7
+
8
+ export const LAYA_MISSING_MESSAGE = `Laya verification was requested but Laya is not installed.
9
+
10
+ Install:
11
+ python -m pip install laya
12
+
13
+ Then verify:
14
+ mergeguard doctor
15
+
16
+ Or use:
17
+ mergeguard review --verifier offline`;
18
+
19
+ export function detectPython(config = { laya: {} }) {
20
+ for (const spec of pythonCommands(config)) {
21
+ const check = spawnSync(spec.command, [...spec.prefix, "--version"], {
22
+ encoding: "utf8",
23
+ timeout: 5000,
24
+ windowsHide: true,
25
+ });
26
+ const text = `${check.stdout || ""}${check.stderr || ""}`.trim();
27
+ if (check.status === 0 && /python/i.test(text)) {
28
+ return { available: true, command: spec.command, prefix: spec.prefix, version: text.split("\n")[0] };
29
+ }
30
+ }
31
+ return { available: false };
32
+ }
33
+
34
+ export function detectLaya(config = { laya: {} }) {
35
+ for (const spec of pythonCommands(config)) {
36
+ const check = spawnSync(spec.command, [...spec.prefix, "-c", "import laya; print(getattr(laya, '__version__', 'installed'))"], {
37
+ encoding: "utf8",
38
+ timeout: 8000,
39
+ windowsHide: true,
40
+ });
41
+ if (check.status === 0) return { available: true, command: spec.command, prefix: spec.prefix, version: (check.stdout || "").trim() || "installed" };
42
+ }
43
+ return { available: false };
44
+ }
45
+
46
+ export function verifyWithLaya(items, config) {
47
+ const detected = detectLaya(config);
48
+ if (!detected.available) {
49
+ throw new Error(LAYA_MISSING_MESSAGE);
50
+ }
51
+ const payload = {
52
+ model: config.laya?.model || null,
53
+ max_len: config.laya?.maxLen || 4096,
54
+ questions: verificationQuestions,
55
+ items: items.map(({ candidate, context }) => ({ id: candidate.id, state: context })),
56
+ };
57
+ const timeout = Number(process.env.MERGEGUARD_LAYA_TIMEOUT_MS || 180_000);
58
+ const result = spawnSync(detected.command, [...detected.prefix, bridgePath], {
59
+ input: JSON.stringify(payload),
60
+ encoding: "utf8",
61
+ maxBuffer: 32 * 1024 * 1024,
62
+ timeout,
63
+ windowsHide: true,
64
+ });
65
+ if (result.error?.code === "ETIMEDOUT" || result.signal === "SIGTERM") {
66
+ throw new Error(`Laya verification timed out after ${timeout}ms`);
67
+ }
68
+ if (result.status !== 0) {
69
+ const detail = truncateMessage(result.stderr || result.stdout || result.error?.message || "unknown error");
70
+ throw new Error(`Laya verification failed: ${detail}`);
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse((result.stdout || "").trim());
75
+ } catch {
76
+ throw new Error(`Laya returned invalid JSON: ${truncateMessage(result.stdout)}`);
77
+ }
78
+ const byId = new Map((parsed.items || []).map((item) => [item.id, item]));
79
+ return items.map(({ candidate }) => normalizeLaya(candidate, byId.get(candidate.id)?.answers || {}, config));
80
+ }
81
+
82
+ function normalizeLaya(candidate, answers, config) {
83
+ const noul = (key, fallback) => typeof answers[key]?.noul === "number" ? answers[key].noul : fallback;
84
+ const plausible = noul("plausible", candidate.reviewerConfidence ?? 0.6);
85
+ const reachable = noul("reachable", 0.68);
86
+ const existingProtection = noul("protected", 0.2);
87
+ const worthReporting = noul("report", candidate.reviewerConfidence ?? 0.6);
88
+ const impact = scoreTo01(answers.severe, candidate.severity);
89
+ const strength = plausible * 0.31 + reachable * 0.18 + impact * 0.12 + (1 - existingProtection) * 0.14 + worthReporting * 0.25;
90
+ return {
91
+ plausible: round(plausible),
92
+ reachable: round(reachable),
93
+ impact: round(impact),
94
+ existingProtection: round(existingProtection),
95
+ worthReporting: round(worthReporting),
96
+ strength: round(strength),
97
+ verdict: strength >= config.confidence ? "report" : "suppress",
98
+ provider: "laya",
99
+ };
100
+ }
101
+
102
+ function scoreTo01(answer, severity) {
103
+ if (typeof answer?.score === "number") return answer.score > 1 ? Math.min(1, answer.score / 4) : clamp01(answer.score);
104
+ if (answer?.choice) return IMPACT_LABELS[answer.choice] ?? 0.55;
105
+ if (answer?.probabilities && typeof answer.probabilities === "object") {
106
+ let sum = 0;
107
+ let total = 0;
108
+ for (const [label, probability] of Object.entries(answer.probabilities)) {
109
+ if (Object.hasOwn(IMPACT_LABELS, label) && typeof probability === "number") {
110
+ sum += IMPACT_LABELS[label] * probability;
111
+ total += probability;
112
+ }
113
+ }
114
+ if (total) return sum / total;
115
+ }
116
+ return { critical: 1, high: 0.8, medium: 0.55, low: 0.3, info: 0.15 }[severity] ?? 0.55;
117
+ }
118
+
119
+ function pythonCommands(config) {
120
+ const explicit = config?.laya?.python || process.env.MERGEGUARD_PYTHON;
121
+ if (explicit) return [{ command: explicit, prefix: [] }];
122
+ return process.platform === "win32"
123
+ ? [{ command: "py", prefix: ["-3"] }, { command: "python", prefix: [] }]
124
+ : [{ command: "python3", prefix: [] }, { command: "python", prefix: [] }];
125
+ }
126
+
127
+ function round(value) { return Number(clamp01(value).toFixed(3)); }
128
+
129
+ function truncateMessage(text) {
130
+ return String(text || "").replace(/\s+/g, " ").trim().slice(0, 400);
131
+ }
@@ -0,0 +1,42 @@
1
+ import { clamp01 } from "../utils.js";
2
+
3
+ const IMPACT = { critical: 1, high: 0.8, medium: 0.55, low: 0.3, info: 0.15 };
4
+
5
+ export function offlineVerify(candidate, context, config) {
6
+ let plausible = candidate.reviewerConfidence ?? 0.6;
7
+ let reachable = 0.68;
8
+ let existingProtection = 0.12;
9
+ let worthReporting = plausible;
10
+ const protection = context?.repository?.protections || {};
11
+ const filesText = (context?.files || []).map((file) => file.content).join("\n");
12
+
13
+ if (/check-then-create|read-check-write/.test(candidate.detector || "")) {
14
+ if (protection.uniqueness && /@unique|unique\s*:\s*true|UNIQUE|UniqueConstraint|unique_together/i.test(filesText)) existingProtection = Math.max(existingProtection, 0.68);
15
+ if (protection.transaction && /transaction|\$transaction|atomic|BeginTransaction|@Transactional/i.test(filesText)) existingProtection = Math.max(existingProtection, 0.58);
16
+ reachable = 0.78;
17
+ }
18
+ if (/multiple-writes|transaction-boundary-removed/.test(candidate.detector || "")) {
19
+ if (/\$transaction|transaction\.atomic|@Transactional|BeginTransaction|BEGIN\s+TRANSACTION/i.test(filesText)) existingProtection = Math.max(existingProtection, 0.78);
20
+ }
21
+ if (candidate.category === "authorization") {
22
+ if (/APP_GUARD|global.*guard|UseAuthentication|UseAuthorization/i.test(filesText) && !/removed|bypass|anonymous/i.test(candidate.title)) existingProtection = Math.max(existingProtection, 0.65);
23
+ reachable = 0.76;
24
+ }
25
+ if (candidate.category === "tenant-isolation") reachable = 0.82;
26
+ if (/tls-disabled|unbounded-delete|unsafe-sql|dynamic-eval|tenant-filter-removed|uniqueness-removed/.test(candidate.detector || "")) {
27
+ plausible = Math.max(plausible, 0.9);
28
+ worthReporting = Math.max(worthReporting, 0.9);
29
+ reachable = Math.max(reachable, 0.75);
30
+ existingProtection = Math.min(existingProtection, 0.15);
31
+ }
32
+ if (/hardcoded-localhost|unbounded-parallelism|blocking-index|floating-promise|cors-wildcard/.test(candidate.detector || "")) worthReporting *= 0.78;
33
+
34
+ const impact = IMPACT[candidate.severity] ?? 0.55;
35
+ const strength = plausible * 0.31 + reachable * 0.18 + impact * 0.12 + (1 - existingProtection) * 0.14 + worthReporting * 0.25;
36
+ const verdict = strength >= config.confidence ? "report" : "suppress";
37
+ return {
38
+ plausible: round(plausible), reachable: round(reachable), impact: round(impact), existingProtection: round(existingProtection), worthReporting: round(worthReporting), strength: round(clamp01(strength)), verdict, provider: "offline",
39
+ };
40
+ }
41
+
42
+ function round(value) { return Number(clamp01(value).toFixed(3)); }