@cleartrip/frontguard 0.1.6 → 0.1.8
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/dist/cli.js +682 -133
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/bitbucket-pipelines.yml +51 -4
package/dist/cli.js
CHANGED
|
@@ -2461,15 +2461,18 @@ export default defineConfig({
|
|
|
2461
2461
|
// bundle: { enabled: true, maxDeltaBytes: 50_000, maxTotalBytes: null },
|
|
2462
2462
|
// cycles: { enabled: true },
|
|
2463
2463
|
// deadCode: { enabled: true, gate: 'info' },
|
|
2464
|
-
// // LLM in CI
|
|
2465
|
-
// //
|
|
2466
|
-
// //
|
|
2467
|
-
// // or FRONTGUARD_MANUAL_APPENDIX_FILE / FRONTGUARD_MANUAL_APPENDIX
|
|
2464
|
+
// // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
|
|
2465
|
+
// // ollama serve && ollama pull llama3.2
|
|
2466
|
+
// // Paste workflow (no key): frontguard run --append ./.frontguard/review-notes.md
|
|
2468
2467
|
// llm: {
|
|
2469
2468
|
// enabled: true,
|
|
2470
|
-
// provider: '
|
|
2471
|
-
//
|
|
2469
|
+
// provider: 'ollama',
|
|
2470
|
+
// model: 'llama3.2',
|
|
2471
|
+
// ollamaUrl: 'http://127.0.0.1:11434',
|
|
2472
|
+
// perFindingFixes: true,
|
|
2473
|
+
// maxFixSuggestions: 8,
|
|
2472
2474
|
// },
|
|
2475
|
+
// // llm: { enabled: true, provider: 'openai', apiKeyEnv: 'OPENAI_API_KEY' },
|
|
2473
2476
|
// },
|
|
2474
2477
|
})
|
|
2475
2478
|
`;
|
|
@@ -2517,6 +2520,86 @@ async function initFrontGuard(cwd) {
|
|
|
2517
2520
|
}
|
|
2518
2521
|
}
|
|
2519
2522
|
|
|
2523
|
+
// src/ci/bitbucket-pr-snippet.ts
|
|
2524
|
+
function bitbucketPipelineResultsUrl() {
|
|
2525
|
+
const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
|
|
2526
|
+
const bn = process.env.BITBUCKET_BUILD_NUMBER?.trim();
|
|
2527
|
+
if (full && bn) {
|
|
2528
|
+
return `https://bitbucket.org/${full}/pipelines/results/${bn}`;
|
|
2529
|
+
}
|
|
2530
|
+
const ws = process.env.BITBUCKET_WORKSPACE?.trim();
|
|
2531
|
+
const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
|
|
2532
|
+
if (ws && slug && bn) {
|
|
2533
|
+
return `https://bitbucket.org/${ws}/${slug}/pipelines/results/${bn}`;
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
}
|
|
2537
|
+
function bitbucketDownloadsPageUrl() {
|
|
2538
|
+
const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
|
|
2539
|
+
if (full) return `https://bitbucket.org/${full}/downloads/`;
|
|
2540
|
+
const ws = process.env.BITBUCKET_WORKSPACE?.trim();
|
|
2541
|
+
const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
|
|
2542
|
+
if (ws && slug) return `https://bitbucket.org/${ws}/${slug}/downloads/`;
|
|
2543
|
+
return null;
|
|
2544
|
+
}
|
|
2545
|
+
function formatBitbucketPrSnippet(report) {
|
|
2546
|
+
const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
|
|
2547
|
+
const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
|
|
2548
|
+
const downloadsPage = bitbucketDownloadsPageUrl();
|
|
2549
|
+
const pipeline = bitbucketPipelineResultsUrl();
|
|
2550
|
+
const { riskScore, results } = report;
|
|
2551
|
+
const blocks = results.reduce(
|
|
2552
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "block").length,
|
|
2553
|
+
0
|
|
2554
|
+
);
|
|
2555
|
+
const warns = results.reduce(
|
|
2556
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
|
|
2557
|
+
0
|
|
2558
|
+
);
|
|
2559
|
+
const out = [
|
|
2560
|
+
"FrontGuard report (short summary)",
|
|
2561
|
+
"",
|
|
2562
|
+
`Risk: ${riskScore} | Blocking: ${blocks} | Warnings: ${warns}`,
|
|
2563
|
+
""
|
|
2564
|
+
];
|
|
2565
|
+
if (publicReport) {
|
|
2566
|
+
out.push("Full interactive report (open in browser):");
|
|
2567
|
+
out.push(publicReport);
|
|
2568
|
+
out.push("");
|
|
2569
|
+
} else if (downloadsName && downloadsPage) {
|
|
2570
|
+
out.push("HTML report is in Repository \u2192 Downloads. Open this page while logged in:");
|
|
2571
|
+
out.push(downloadsPage);
|
|
2572
|
+
out.push(`File name: ${downloadsName}`);
|
|
2573
|
+
out.push("Download the file, then open it in a browser.");
|
|
2574
|
+
out.push("");
|
|
2575
|
+
} else if (pipeline) {
|
|
2576
|
+
out.push(
|
|
2577
|
+
"There is no direct \u201CHTML URL\u201D for pipeline artifacts in Bitbucket. Use this pipeline run (log in), then Artifacts \u2192 frontguard-report.html:"
|
|
2578
|
+
);
|
|
2579
|
+
out.push(pipeline);
|
|
2580
|
+
out.push("");
|
|
2581
|
+
out.push(
|
|
2582
|
+
"Steps: open the link \u2192 scroll to Artifacts \u2192 download frontguard-report.html \u2192 open the file on your machine."
|
|
2583
|
+
);
|
|
2584
|
+
out.push("");
|
|
2585
|
+
} else {
|
|
2586
|
+
out.push(
|
|
2587
|
+
"Add a link: run FrontGuard inside Bitbucket Pipelines, or set FRONTGUARD_PUBLIC_REPORT_URL after uploading the HTML somewhere HTTPS."
|
|
2588
|
+
);
|
|
2589
|
+
out.push("");
|
|
2590
|
+
}
|
|
2591
|
+
out.push("Checks:");
|
|
2592
|
+
for (const r4 of results) {
|
|
2593
|
+
const status = r4.skipped ? `skipped (${r4.skipped.slice(0, 100)}${r4.skipped.length > 100 ? "\u2026" : ""})` : r4.findings.length === 0 ? "clean" : `${r4.findings.length} finding(s)`;
|
|
2594
|
+
out.push(`- ${r4.checkId}: ${status}`);
|
|
2595
|
+
}
|
|
2596
|
+
out.push("");
|
|
2597
|
+
out.push(
|
|
2598
|
+
"Do not paste the long frontguard-report.md into PR comments. Full text output is in that file / pipeline artifacts only."
|
|
2599
|
+
);
|
|
2600
|
+
return out.join("\n");
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2520
2603
|
// src/ci/parse-ai-disclosure.ts
|
|
2521
2604
|
function extractAiSection(body) {
|
|
2522
2605
|
const lines = body.split(/\r?\n/);
|
|
@@ -2614,10 +2697,10 @@ async function resolvePrNumber() {
|
|
|
2614
2697
|
const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
|
|
2615
2698
|
const n3 = Number(raw);
|
|
2616
2699
|
if (Number.isFinite(n3) && n3 > 0) return n3;
|
|
2617
|
-
const
|
|
2618
|
-
if (!
|
|
2700
|
+
const path17 = process.env.GITHUB_EVENT_PATH;
|
|
2701
|
+
if (!path17) return null;
|
|
2619
2702
|
try {
|
|
2620
|
-
const payload = JSON.parse(await fs.readFile(
|
|
2703
|
+
const payload = JSON.parse(await fs.readFile(path17, "utf8"));
|
|
2621
2704
|
const num = payload.pull_request?.number;
|
|
2622
2705
|
return typeof num === "number" && num > 0 ? num : null;
|
|
2623
2706
|
} catch {
|
|
@@ -2797,7 +2880,11 @@ var defaultConfig = {
|
|
|
2797
2880
|
model: "gpt-4o-mini",
|
|
2798
2881
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
2799
2882
|
maxDiffChars: 48e3,
|
|
2800
|
-
timeoutMs: 6e4
|
|
2883
|
+
timeoutMs: 6e4,
|
|
2884
|
+
ollamaUrl: "http://127.0.0.1:11434",
|
|
2885
|
+
perFindingFixes: false,
|
|
2886
|
+
maxFixSuggestions: 12,
|
|
2887
|
+
maxFileContextChars: 24e3
|
|
2801
2888
|
}
|
|
2802
2889
|
}
|
|
2803
2890
|
};
|
|
@@ -2931,6 +3018,16 @@ async function detectStack(cwd) {
|
|
|
2931
3018
|
tsStrict
|
|
2932
3019
|
};
|
|
2933
3020
|
}
|
|
3021
|
+
function formatStackOneLiner(s3) {
|
|
3022
|
+
const bits = [];
|
|
3023
|
+
if (s3.hasNext) bits.push("Next.js");
|
|
3024
|
+
if (s3.hasReactNative) bits.push("React Native");
|
|
3025
|
+
else if (s3.hasReact) bits.push("React");
|
|
3026
|
+
if (s3.hasTypeScript) bits.push("TypeScript");
|
|
3027
|
+
if (s3.tsStrict === true) bits.push("strict TS");
|
|
3028
|
+
bits.push(`pkg: ${s3.packageManager}`);
|
|
3029
|
+
return bits.join(" \xB7 ") || "unknown";
|
|
3030
|
+
}
|
|
2934
3031
|
function stripFileUrl(p2) {
|
|
2935
3032
|
let s3 = p2.trim();
|
|
2936
3033
|
if (!/^file:/i.test(s3)) return s3;
|
|
@@ -4841,6 +4938,265 @@ function applyAiAssistedEscalation(results, pr, config) {
|
|
|
4841
4938
|
|
|
4842
4939
|
// src/report/builder.ts
|
|
4843
4940
|
var import_picocolors = __toESM(require_picocolors());
|
|
4941
|
+
|
|
4942
|
+
// src/lib/html-escape.ts
|
|
4943
|
+
function escapeHtml(s3) {
|
|
4944
|
+
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
// src/report/html-report.ts
|
|
4948
|
+
function shieldUrl(label, message, color) {
|
|
4949
|
+
const q2 = new URLSearchParams({ label, message, color, style: "for-the-badge" });
|
|
4950
|
+
return `https://img.shields.io/static/v1?${q2}`;
|
|
4951
|
+
}
|
|
4952
|
+
function riskColor(risk) {
|
|
4953
|
+
if (risk === "LOW") return "brightgreen";
|
|
4954
|
+
if (risk === "MEDIUM") return "orange";
|
|
4955
|
+
return "red";
|
|
4956
|
+
}
|
|
4957
|
+
function modeColor(mode) {
|
|
4958
|
+
return mode === "enforce" ? "critical" : "blue";
|
|
4959
|
+
}
|
|
4960
|
+
function countColor(kind, n3) {
|
|
4961
|
+
if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
|
|
4962
|
+
if (kind === "info") return n3 === 0 ? "inactive" : "informational";
|
|
4963
|
+
if (n3 === 0) return "brightgreen";
|
|
4964
|
+
if (n3 <= 10) return "yellow";
|
|
4965
|
+
return "orange";
|
|
4966
|
+
}
|
|
4967
|
+
function parseLineHint(detail) {
|
|
4968
|
+
if (!detail) return 0;
|
|
4969
|
+
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
4970
|
+
return m3 ? Number(m3[1]) : 0;
|
|
4971
|
+
}
|
|
4972
|
+
function normalizeFinding(cwd, f4) {
|
|
4973
|
+
return {
|
|
4974
|
+
file: toRepoRelativePath(cwd, f4.file),
|
|
4975
|
+
message: stripRepoAbsolutePaths(cwd, f4.message),
|
|
4976
|
+
detail: f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0
|
|
4977
|
+
};
|
|
4978
|
+
}
|
|
4979
|
+
function sortFindings(cwd, items) {
|
|
4980
|
+
return [...items].sort((a3, b3) => {
|
|
4981
|
+
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
4982
|
+
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
4983
|
+
if (af !== bf) return af.localeCompare(bf);
|
|
4984
|
+
const lineA = parseLineHint(a3.f.detail);
|
|
4985
|
+
const lineB = parseLineHint(b3.f.detail);
|
|
4986
|
+
if (lineA !== lineB) return lineA - lineB;
|
|
4987
|
+
return a3.f.message.localeCompare(b3.f.message);
|
|
4988
|
+
});
|
|
4989
|
+
}
|
|
4990
|
+
function formatDuration(ms) {
|
|
4991
|
+
if (ms < 1e3) return `${ms} ms`;
|
|
4992
|
+
const s3 = Math.round(ms / 1e3);
|
|
4993
|
+
if (s3 < 60) return `${s3}s`;
|
|
4994
|
+
const m3 = Math.floor(s3 / 60);
|
|
4995
|
+
const r4 = s3 % 60;
|
|
4996
|
+
return r4 ? `${m3}m ${r4}s` : `${m3}m`;
|
|
4997
|
+
}
|
|
4998
|
+
function renderFindingCard(cwd, r4, f4) {
|
|
4999
|
+
const d3 = normalizeFinding(cwd, f4);
|
|
5000
|
+
const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
|
|
5001
|
+
const fixBlock = f4.suggestedFix ? `<div class="suggested-fix"><h5>Suggested fix <span class="tag">LLM</span></h5><div class="fix-md">${escapeHtml(f4.suggestedFix.summary)}</div>${f4.suggestedFix.code ? `<pre class="code"><code>${escapeHtml(f4.suggestedFix.code)}</code></pre>` : ""}<p class="disclaimer">Suggestions are non-binding; review and test before applying.</p></div>` : "";
|
|
5002
|
+
const hintRow = d3.detail && !d3.detail.includes("\n") && d3.detail.length <= 220 && !d3.detail.includes("|") ? `<tr><th>Hint</th><td>${escapeHtml(d3.detail)}</td></tr>` : "";
|
|
5003
|
+
const detailFence = d3.detail && (d3.detail.includes("\n") || d3.detail.length > 220 || d3.detail.includes("|")) ? `<pre class="code"><code>${escapeHtml(d3.detail)}</code></pre>` : "";
|
|
5004
|
+
return `<article class="card ${sevClass}"><h4>${escapeHtml(d3.file ?? "\u2014")} <span class="muted">\xB7</span> ${escapeHtml(d3.message)}</h4><table class="meta"><tr><th>Check</th><td><code>${escapeHtml(r4.checkId)}</code></td></tr><tr><th>Rule</th><td><code>${escapeHtml(f4.id)}</code></td></tr>${d3.file ? `<tr><th>File</th><td><code>${escapeHtml(d3.file)}</code></td></tr>` : ""}${hintRow}</table>${detailFence}${fixBlock}</article>`;
|
|
5005
|
+
}
|
|
5006
|
+
function buildHtmlReport(p2) {
|
|
5007
|
+
const {
|
|
5008
|
+
cwd,
|
|
5009
|
+
riskScore,
|
|
5010
|
+
mode,
|
|
5011
|
+
stack,
|
|
5012
|
+
pr,
|
|
5013
|
+
results,
|
|
5014
|
+
warns,
|
|
5015
|
+
infos,
|
|
5016
|
+
blocks,
|
|
5017
|
+
lines,
|
|
5018
|
+
llmAppendix
|
|
5019
|
+
} = p2;
|
|
5020
|
+
const modeLabel = mode === "enforce" ? "enforce" : "warn only";
|
|
5021
|
+
const badges = [
|
|
5022
|
+
["risk", riskScore, riskColor(riskScore)],
|
|
5023
|
+
["mode", modeLabel, modeColor(mode)],
|
|
5024
|
+
["blocking", String(blocks), countColor("block", blocks)],
|
|
5025
|
+
["warnings", String(warns), countColor("warn", warns)],
|
|
5026
|
+
["info", String(infos), countColor("info", infos)]
|
|
5027
|
+
];
|
|
5028
|
+
const badgeImgs = badges.map(([l3, m3, c4]) => {
|
|
5029
|
+
const alt = `${l3}: ${m3}`;
|
|
5030
|
+
return `<img class="badge" src="${escapeHtml(shieldUrl(l3, m3, c4))}" alt="${escapeHtml(alt)}" loading="lazy" />`;
|
|
5031
|
+
}).join(" ");
|
|
5032
|
+
const checkRows = results.map((r4) => {
|
|
5033
|
+
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
|
|
5034
|
+
return `<tr><td>${r4.skipped ? "\u23ED\uFE0F" : r4.findings.length === 0 ? "\u{1F7E2}" : r4.findings.some((x3) => x3.severity === "block") ? "\u{1F534}" : "\u{1F7E1}"}</td><td><strong>${escapeHtml(r4.checkId)}</strong></td><td>${status}</td><td>${r4.skipped ? "\u2014" : r4.findings.length}</td><td>${formatDuration(r4.durationMs)}</td></tr>`;
|
|
5035
|
+
}).join("\n");
|
|
5036
|
+
const blockItems = sortFindings(
|
|
5037
|
+
cwd,
|
|
5038
|
+
results.flatMap(
|
|
5039
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5040
|
+
)
|
|
5041
|
+
);
|
|
5042
|
+
const warnItems = sortFindings(
|
|
5043
|
+
cwd,
|
|
5044
|
+
results.flatMap(
|
|
5045
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5046
|
+
)
|
|
5047
|
+
);
|
|
5048
|
+
const infoItems = sortFindings(
|
|
5049
|
+
cwd,
|
|
5050
|
+
results.flatMap(
|
|
5051
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5052
|
+
)
|
|
5053
|
+
);
|
|
5054
|
+
const byCheck = /* @__PURE__ */ new Map();
|
|
5055
|
+
for (const item of warnItems) {
|
|
5056
|
+
const list = byCheck.get(item.r.checkId) ?? [];
|
|
5057
|
+
list.push(item);
|
|
5058
|
+
byCheck.set(item.r.checkId, list);
|
|
5059
|
+
}
|
|
5060
|
+
const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
|
|
5061
|
+
const blockingHtml = blockItems.length === 0 ? '<p class="ok">No blocking findings.</p>' : blockItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5062
|
+
let warningsHtml = "";
|
|
5063
|
+
if (warnItems.length === 0) {
|
|
5064
|
+
warningsHtml = '<p class="ok">No warnings.</p>';
|
|
5065
|
+
} else {
|
|
5066
|
+
for (const cid of checkOrder) {
|
|
5067
|
+
const group = sortFindings(cwd, byCheck.get(cid));
|
|
5068
|
+
warningsHtml += `<h3 class="grp">${escapeHtml(cid)} <span class="count">(${group.length})</span></h3>`;
|
|
5069
|
+
warningsHtml += group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5070
|
+
}
|
|
5071
|
+
}
|
|
5072
|
+
const infoHtml = infoItems.length === 0 ? '<p class="muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5073
|
+
const prBlock = pr && lines != null ? `<tr><th>PR size</th><td>${lines} LOC (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
|
|
5074
|
+
const appendix = llmAppendix?.trim() ? `<section class="appendix"><h2>AI / manual appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
|
|
5075
|
+
return `<!DOCTYPE html>
|
|
5076
|
+
<html lang="en">
|
|
5077
|
+
<head>
|
|
5078
|
+
<meta charset="utf-8" />
|
|
5079
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5080
|
+
<title>FrontGuard report</title>
|
|
5081
|
+
<style>
|
|
5082
|
+
:root {
|
|
5083
|
+
--bg: #0f1419;
|
|
5084
|
+
--panel: #1a2332;
|
|
5085
|
+
--text: #e7ecf3;
|
|
5086
|
+
--muted: #8b9aab;
|
|
5087
|
+
--border: #2d3d52;
|
|
5088
|
+
--block: #f87171;
|
|
5089
|
+
--warn: #fbbf24;
|
|
5090
|
+
--info: #38bdf8;
|
|
5091
|
+
--accent: #a78bfa;
|
|
5092
|
+
--ok: #4ade80;
|
|
5093
|
+
}
|
|
5094
|
+
* { box-sizing: border-box; }
|
|
5095
|
+
body {
|
|
5096
|
+
margin: 0; font-family: ui-sans-serif, system-ui, sans-serif;
|
|
5097
|
+
background: var(--bg); color: var(--text); line-height: 1.5;
|
|
5098
|
+
padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem;
|
|
5099
|
+
max-width: 58rem; margin-left: auto; margin-right: auto;
|
|
5100
|
+
}
|
|
5101
|
+
h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
|
|
5102
|
+
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
|
|
5103
|
+
h3.grp { margin: 1.5rem 0 0.5rem; font-size: 1rem; color: var(--warn); }
|
|
5104
|
+
h3.grp .count { color: var(--muted); font-weight: normal; }
|
|
5105
|
+
h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
|
|
5106
|
+
h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
|
|
5107
|
+
.badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
|
5108
|
+
.badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
|
|
5109
|
+
details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
|
|
5110
|
+
details.open-default { border-color: #3d4f6a; }
|
|
5111
|
+
summary {
|
|
5112
|
+
cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
|
|
5113
|
+
list-style: none; display: flex; align-items: center; gap: 0.5rem;
|
|
5114
|
+
}
|
|
5115
|
+
summary::-webkit-details-marker { display: none; }
|
|
5116
|
+
details[open] > summary { border-bottom: 1px solid var(--border); }
|
|
5117
|
+
.details-body { padding: 0.75rem 1rem 1rem; }
|
|
5118
|
+
table.results { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0.5rem 0 1rem; }
|
|
5119
|
+
table.results th, table.results td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
|
|
5120
|
+
table.results th { background: #243044; color: var(--muted); font-weight: 600; }
|
|
5121
|
+
.snapshot { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 0.5rem 0; }
|
|
5122
|
+
.snapshot th, .snapshot td { border: 1px solid var(--border); padding: 0.5rem 0.65rem; vertical-align: top; }
|
|
5123
|
+
.snapshot th { width: 10rem; background: #243044; color: var(--muted); text-align: left; }
|
|
5124
|
+
.card {
|
|
5125
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem 1rem;
|
|
5126
|
+
margin-bottom: 0.65rem; background: #131c28;
|
|
5127
|
+
}
|
|
5128
|
+
.card.sev-block { border-left: 4px solid var(--block); }
|
|
5129
|
+
.card.sev-warn { border-left: 4px solid var(--warn); }
|
|
5130
|
+
.card.sev-info { border-left: 4px solid var(--info); }
|
|
5131
|
+
table.meta { width: 100%; font-size: 0.8rem; border-collapse: collapse; margin: 0.5rem 0; }
|
|
5132
|
+
table.meta th { text-align: left; color: var(--muted); width: 5.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
|
|
5133
|
+
table.meta td { padding: 0.2rem 0; }
|
|
5134
|
+
.muted { color: var(--muted); }
|
|
5135
|
+
.ok { color: var(--ok); }
|
|
5136
|
+
pre.code {
|
|
5137
|
+
margin: 0.5rem 0 0; padding: 0.65rem 0.75rem; background: #0a0e14; border-radius: 6px;
|
|
5138
|
+
overflow: auto; font-size: 0.78rem; border: 1px solid var(--border);
|
|
5139
|
+
}
|
|
5140
|
+
pre.code code { font-family: ui-monospace, monospace; white-space: pre; }
|
|
5141
|
+
.suggested-fix {
|
|
5142
|
+
margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
|
|
5143
|
+
}
|
|
5144
|
+
.fix-md { font-size: 0.85rem; white-space: pre-wrap; margin: 0.25rem 0 0.5rem; }
|
|
5145
|
+
.tag {
|
|
5146
|
+
font-size: 0.65rem; background: var(--accent); color: var(--bg);
|
|
5147
|
+
padding: 0.1rem 0.35rem; border-radius: 4px; vertical-align: middle;
|
|
5148
|
+
}
|
|
5149
|
+
.disclaimer { font-size: 0.72rem; color: var(--muted); margin: 0.5rem 0 0; }
|
|
5150
|
+
.appendix pre.md-raw {
|
|
5151
|
+
white-space: pre-wrap; font-size: 0.85rem; background: var(--panel);
|
|
5152
|
+
padding: 1rem; border-radius: 8px; border: 1px solid var(--border);
|
|
5153
|
+
}
|
|
5154
|
+
footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--muted); }
|
|
5155
|
+
</style>
|
|
5156
|
+
</head>
|
|
5157
|
+
<body>
|
|
5158
|
+
<h1>FrontGuard review</h1>
|
|
5159
|
+
<div class="badges">${badgeImgs}</div>
|
|
5160
|
+
|
|
5161
|
+
<h2>Snapshot</h2>
|
|
5162
|
+
<table class="snapshot">
|
|
5163
|
+
<tr><th>Risk</th><td><strong>${riskScore}</strong> (heuristic)</td></tr>
|
|
5164
|
+
<tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
|
|
5165
|
+
<tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
|
|
5166
|
+
${prBlock}
|
|
5167
|
+
</table>
|
|
5168
|
+
|
|
5169
|
+
<h2>Check results</h2>
|
|
5170
|
+
<table class="results">
|
|
5171
|
+
<thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
|
|
5172
|
+
<tbody>${checkRows}</tbody>
|
|
5173
|
+
</table>
|
|
5174
|
+
|
|
5175
|
+
<details class="open-default" open>
|
|
5176
|
+
<summary>Blocking (${blocks})</summary>
|
|
5177
|
+
<div class="details-body">${blockingHtml}</div>
|
|
5178
|
+
</details>
|
|
5179
|
+
|
|
5180
|
+
<details class="open-default" open>
|
|
5181
|
+
<summary>Warnings (${warns})</summary>
|
|
5182
|
+
<div class="details-body">${warningsHtml}</div>
|
|
5183
|
+
</details>
|
|
5184
|
+
|
|
5185
|
+
<details>
|
|
5186
|
+
<summary>Info (${infos})</summary>
|
|
5187
|
+
<div class="details-body">${infoHtml}</div>
|
|
5188
|
+
</details>
|
|
5189
|
+
|
|
5190
|
+
${appendix}
|
|
5191
|
+
|
|
5192
|
+
<footer>
|
|
5193
|
+
<p>Self-contained HTML report \u2014 open locally or from CI artifacts. Badges load from <a href="https://shields.io" style="color: var(--info);">shields.io</a>.</p>
|
|
5194
|
+
</footer>
|
|
5195
|
+
</body>
|
|
5196
|
+
</html>`;
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
// src/report/builder.ts
|
|
4844
5200
|
function buildReport(stack, pr, results, options) {
|
|
4845
5201
|
const mode = options?.mode ?? "warn";
|
|
4846
5202
|
const cwd = options?.cwd ?? process.cwd();
|
|
@@ -4875,17 +5231,20 @@ function buildReport(stack, pr, results, options) {
|
|
|
4875
5231
|
infos,
|
|
4876
5232
|
blocks
|
|
4877
5233
|
});
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
5234
|
+
const html = options?.emitHtml === true ? buildHtmlReport({
|
|
5235
|
+
cwd,
|
|
5236
|
+
riskScore,
|
|
5237
|
+
mode,
|
|
5238
|
+
stack,
|
|
5239
|
+
pr,
|
|
5240
|
+
results,
|
|
5241
|
+
warns,
|
|
5242
|
+
infos,
|
|
5243
|
+
blocks,
|
|
5244
|
+
lines,
|
|
5245
|
+
llmAppendix: options?.llmAppendix ?? null
|
|
5246
|
+
}) : null;
|
|
5247
|
+
return { riskScore, stack, pr, results, markdown, consoleText, html };
|
|
4889
5248
|
}
|
|
4890
5249
|
function scoreRisk(blocks, warns, lines, files) {
|
|
4891
5250
|
let score = 0;
|
|
@@ -4933,7 +5292,7 @@ function countShieldColor(kind, n3) {
|
|
|
4933
5292
|
if (n3 <= 10) return "yellow";
|
|
4934
5293
|
return "orange";
|
|
4935
5294
|
}
|
|
4936
|
-
function
|
|
5295
|
+
function formatDuration2(ms) {
|
|
4937
5296
|
if (ms < 1e3) return `${ms} ms`;
|
|
4938
5297
|
const s3 = Math.round(ms / 1e3);
|
|
4939
5298
|
if (s3 < 60) return `${s3}s`;
|
|
@@ -4948,9 +5307,6 @@ function healthEmojiForCheck(r4) {
|
|
|
4948
5307
|
if (hasBlock) return "\u{1F534}";
|
|
4949
5308
|
return "\u{1F7E1}";
|
|
4950
5309
|
}
|
|
4951
|
-
function escapeHtml(s3) {
|
|
4952
|
-
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4953
|
-
}
|
|
4954
5310
|
function normalizeFindingDisplay(cwd, f4) {
|
|
4955
5311
|
const file = toRepoRelativePath(cwd, f4.file);
|
|
4956
5312
|
const message = stripRepoAbsolutePaths(cwd, f4.message);
|
|
@@ -4960,12 +5316,12 @@ function normalizeFindingDisplay(cwd, f4) {
|
|
|
4960
5316
|
function sanitizeFence(cwd, s3) {
|
|
4961
5317
|
return stripRepoAbsolutePaths(cwd, s3).replace(/\r\n/g, "\n").replace(/```/g, "`\u200B``");
|
|
4962
5318
|
}
|
|
4963
|
-
function
|
|
4964
|
-
const msg = message.length >
|
|
4965
|
-
const
|
|
4966
|
-
return
|
|
5319
|
+
function findingTitleLine(file, message) {
|
|
5320
|
+
const msg = message.length > 200 ? `${message.slice(0, 197).trimEnd()}\u2026` : message;
|
|
5321
|
+
const loc = file ? `\`${file}\`` : "_no file_";
|
|
5322
|
+
return `${loc} \u2014 ${msg}`;
|
|
4967
5323
|
}
|
|
4968
|
-
function
|
|
5324
|
+
function parseLineHint2(detail) {
|
|
4969
5325
|
if (!detail) return 0;
|
|
4970
5326
|
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
4971
5327
|
return m3 ? Number(m3[1]) : 0;
|
|
@@ -4986,18 +5342,18 @@ function appendDetailAfterTable(sb, cwd, detail) {
|
|
|
4986
5342
|
sb.push(sanitizeFence(cwd, d3));
|
|
4987
5343
|
sb.push("```");
|
|
4988
5344
|
}
|
|
4989
|
-
function
|
|
4990
|
-
if (!
|
|
4991
|
-
|
|
4992
|
-
|
|
5345
|
+
function appendSuggestedFix(sb, cwd, f4) {
|
|
5346
|
+
if (!f4.suggestedFix) return;
|
|
5347
|
+
sb.push("");
|
|
5348
|
+
sb.push("_Suggested fix (LLM \u2014 non-binding):_");
|
|
5349
|
+
sb.push("");
|
|
5350
|
+
sb.push(f4.suggestedFix.summary);
|
|
5351
|
+
if (f4.suggestedFix.code) {
|
|
4993
5352
|
sb.push("");
|
|
4994
|
-
sb.push(
|
|
4995
|
-
|
|
5353
|
+
sb.push("```text");
|
|
5354
|
+
sb.push(sanitizeFence(cwd, f4.suggestedFix.code));
|
|
5355
|
+
sb.push("```");
|
|
4996
5356
|
}
|
|
4997
|
-
sb.push("");
|
|
4998
|
-
sb.push("```text");
|
|
4999
|
-
sb.push(sanitizeFence(cwd, d3));
|
|
5000
|
-
sb.push("```");
|
|
5001
5357
|
}
|
|
5002
5358
|
function formatMarkdown(p2) {
|
|
5003
5359
|
const {
|
|
@@ -5018,8 +5374,8 @@ function formatMarkdown(p2) {
|
|
|
5018
5374
|
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
5019
5375
|
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
5020
5376
|
if (af !== bf) return af.localeCompare(bf);
|
|
5021
|
-
const lineA =
|
|
5022
|
-
const lineB =
|
|
5377
|
+
const lineA = parseLineHint2(a3.f.detail);
|
|
5378
|
+
const lineB = parseLineHint2(b3.f.detail);
|
|
5023
5379
|
if (lineA !== lineB) return lineA - lineB;
|
|
5024
5380
|
return a3.f.message.localeCompare(b3.f.message);
|
|
5025
5381
|
});
|
|
@@ -5068,7 +5424,7 @@ function formatMarkdown(p2) {
|
|
|
5068
5424
|
sb.push("> | \u23ED\uFE0F | Check skipped (see reason in table) |");
|
|
5069
5425
|
sb.push(">");
|
|
5070
5426
|
sb.push(
|
|
5071
|
-
"> Paths in findings are **relative to the repo root**.
|
|
5427
|
+
"> Paths in findings are **relative to the repo root**. Each issue below has a small field table and optional detail."
|
|
5072
5428
|
);
|
|
5073
5429
|
sb.push("");
|
|
5074
5430
|
sb.push("---");
|
|
@@ -5079,11 +5435,19 @@ function formatMarkdown(p2) {
|
|
|
5079
5435
|
sb.push("|:--:|:--|:--|:-:|--:|");
|
|
5080
5436
|
for (const r4 of results) {
|
|
5081
5437
|
const he2 = healthEmojiForCheck(r4);
|
|
5082
|
-
|
|
5438
|
+
let status;
|
|
5439
|
+
if (r4.skipped) {
|
|
5440
|
+
const why = r4.skipped.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
5441
|
+
const short = why.length > 120 ? `${why.slice(0, 117)}\u2026` : why;
|
|
5442
|
+
status = `\u23ED\uFE0F **Skipped** \u2014 ${short}`;
|
|
5443
|
+
} else if (r4.findings.length === 0) {
|
|
5444
|
+
status = "\u2705 **Clean**";
|
|
5445
|
+
} else {
|
|
5446
|
+
status = "\u26A0\uFE0F **Issues**";
|
|
5447
|
+
}
|
|
5083
5448
|
const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
|
|
5084
|
-
const note = r4.skipped ? `<br><sub>\u{1F4AC} ${escapeHtml(r4.skipped)}</sub>` : "";
|
|
5085
5449
|
sb.push(
|
|
5086
|
-
`| ${he2} | **${r4.checkId}** | ${status}
|
|
5450
|
+
`| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
|
|
5087
5451
|
);
|
|
5088
5452
|
}
|
|
5089
5453
|
sb.push("");
|
|
@@ -5092,21 +5456,16 @@ function formatMarkdown(p2) {
|
|
|
5092
5456
|
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5093
5457
|
)
|
|
5094
5458
|
);
|
|
5095
|
-
sb.push("
|
|
5096
|
-
sb.push(
|
|
5097
|
-
`<summary><strong>\u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}</strong></summary>`
|
|
5098
|
-
);
|
|
5459
|
+
sb.push(`### \u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}`);
|
|
5099
5460
|
sb.push("");
|
|
5100
5461
|
if (blockFindings.length === 0) {
|
|
5101
5462
|
sb.push("*\u2705 No blocking findings \u2014 nothing mapped to severity `block`.*");
|
|
5102
5463
|
} else {
|
|
5103
|
-
sb.push("");
|
|
5104
5464
|
for (const { r: r4, f: f4 } of blockFindings) {
|
|
5105
5465
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5106
|
-
sb.push("
|
|
5107
|
-
sb.push(
|
|
5108
|
-
|
|
5109
|
-
);
|
|
5466
|
+
sb.push("---");
|
|
5467
|
+
sb.push("");
|
|
5468
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5110
5469
|
sb.push("");
|
|
5111
5470
|
sb.push(`| Field | Value |`);
|
|
5112
5471
|
sb.push(`|:--|:--|`);
|
|
@@ -5114,22 +5473,18 @@ function formatMarkdown(p2) {
|
|
|
5114
5473
|
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5115
5474
|
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5116
5475
|
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5117
|
-
sb
|
|
5118
|
-
sb.push("</details>");
|
|
5476
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5119
5477
|
sb.push("");
|
|
5120
5478
|
}
|
|
5121
5479
|
}
|
|
5122
5480
|
sb.push("");
|
|
5123
|
-
sb.push("</details>");
|
|
5124
|
-
sb.push("");
|
|
5125
5481
|
const warnFindings = sortWithCwd(
|
|
5126
5482
|
results.flatMap(
|
|
5127
5483
|
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5128
5484
|
)
|
|
5129
5485
|
);
|
|
5130
|
-
sb.push("<details open>");
|
|
5131
5486
|
sb.push(
|
|
5132
|
-
|
|
5487
|
+
`### \u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"} (by check)`
|
|
5133
5488
|
);
|
|
5134
5489
|
sb.push("");
|
|
5135
5490
|
if (warnFindings.length === 0) {
|
|
@@ -5148,10 +5503,9 @@ function formatMarkdown(p2) {
|
|
|
5148
5503
|
sb.push("");
|
|
5149
5504
|
for (const { r: r4, f: f4 } of group) {
|
|
5150
5505
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5151
|
-
sb.push("
|
|
5152
|
-
sb.push(
|
|
5153
|
-
|
|
5154
|
-
);
|
|
5506
|
+
sb.push("---");
|
|
5507
|
+
sb.push("");
|
|
5508
|
+
sb.push(`##### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5155
5509
|
sb.push("");
|
|
5156
5510
|
sb.push(`| Field | Value |`);
|
|
5157
5511
|
sb.push(`|:--|:--|`);
|
|
@@ -5159,42 +5513,39 @@ function formatMarkdown(p2) {
|
|
|
5159
5513
|
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5160
5514
|
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5161
5515
|
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5162
|
-
sb
|
|
5163
|
-
sb.push("</details>");
|
|
5516
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5164
5517
|
sb.push("");
|
|
5165
5518
|
}
|
|
5166
5519
|
}
|
|
5167
5520
|
}
|
|
5168
|
-
sb.push("</details>");
|
|
5169
5521
|
sb.push("");
|
|
5170
5522
|
const infoFindings = sortWithCwd(
|
|
5171
5523
|
results.flatMap(
|
|
5172
5524
|
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5173
5525
|
)
|
|
5174
5526
|
);
|
|
5175
|
-
sb.push("
|
|
5176
|
-
sb.push(
|
|
5177
|
-
`<summary><strong>\u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}</strong></summary>`
|
|
5178
|
-
);
|
|
5527
|
+
sb.push(`### \u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}`);
|
|
5179
5528
|
sb.push("");
|
|
5180
5529
|
if (infoFindings.length === 0) {
|
|
5181
5530
|
sb.push("*No info-level notes.*");
|
|
5182
5531
|
} else {
|
|
5183
5532
|
for (const { r: r4, f: f4 } of infoFindings) {
|
|
5184
5533
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5185
|
-
sb.push("
|
|
5186
|
-
sb.push(`<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`);
|
|
5534
|
+
sb.push("---");
|
|
5187
5535
|
sb.push("");
|
|
5188
|
-
sb.push(
|
|
5189
|
-
appendDetailFree(sb, cwd, d3.detail);
|
|
5536
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5190
5537
|
sb.push("");
|
|
5191
|
-
sb.push(
|
|
5538
|
+
sb.push(`| Field | Value |`);
|
|
5539
|
+
sb.push(`|:--|:--|`);
|
|
5540
|
+
sb.push(`| **Check** | \`${r4.checkId}\` |`);
|
|
5541
|
+
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5542
|
+
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5543
|
+
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5544
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5192
5545
|
sb.push("");
|
|
5193
5546
|
}
|
|
5194
5547
|
}
|
|
5195
5548
|
sb.push("");
|
|
5196
|
-
sb.push("</details>");
|
|
5197
|
-
sb.push("");
|
|
5198
5549
|
if (llmAppendix?.trim()) {
|
|
5199
5550
|
sb.push("### \u{1F916} AI / manual appendix");
|
|
5200
5551
|
sb.push("");
|
|
@@ -5212,7 +5563,9 @@ function formatMarkdown(p2) {
|
|
|
5212
5563
|
)
|
|
5213
5564
|
);
|
|
5214
5565
|
sb.push("");
|
|
5215
|
-
sb.push(
|
|
5566
|
+
sb.push(
|
|
5567
|
+
"_Configure checks in `frontguard.config.js` \xB7 [Shields.io](https://shields.io) badge images load in Bitbucket PR comments (HTML tags like `<details>` are not supported there)._"
|
|
5568
|
+
);
|
|
5216
5569
|
return sb.join("\n");
|
|
5217
5570
|
}
|
|
5218
5571
|
function formatConsole(p2) {
|
|
@@ -5239,6 +5592,185 @@ function formatConsole(p2) {
|
|
|
5239
5592
|
return lines.join("\n");
|
|
5240
5593
|
}
|
|
5241
5594
|
|
|
5595
|
+
// src/llm/ollama.ts
|
|
5596
|
+
async function callOllamaChat(opts) {
|
|
5597
|
+
const fetch = getFetch();
|
|
5598
|
+
const base = opts.baseUrl.replace(/\/$/, "");
|
|
5599
|
+
const controller = new AbortController();
|
|
5600
|
+
const t3 = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
5601
|
+
try {
|
|
5602
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
5603
|
+
method: "POST",
|
|
5604
|
+
signal: controller.signal,
|
|
5605
|
+
headers: { "Content-Type": "application/json" },
|
|
5606
|
+
body: JSON.stringify({
|
|
5607
|
+
model: opts.model,
|
|
5608
|
+
messages: [{ role: "user", content: opts.prompt }],
|
|
5609
|
+
stream: false,
|
|
5610
|
+
options: { temperature: 0.2 }
|
|
5611
|
+
})
|
|
5612
|
+
});
|
|
5613
|
+
if (!res.ok) {
|
|
5614
|
+
const body = await res.text();
|
|
5615
|
+
throw new Error(`Ollama HTTP ${res.status}: ${body.slice(0, 400)}`);
|
|
5616
|
+
}
|
|
5617
|
+
const data = await res.json();
|
|
5618
|
+
const text = data.message?.content?.trim();
|
|
5619
|
+
if (!text) throw new Error("Ollama returned empty content");
|
|
5620
|
+
return text;
|
|
5621
|
+
} finally {
|
|
5622
|
+
clearTimeout(t3);
|
|
5623
|
+
}
|
|
5624
|
+
}
|
|
5625
|
+
|
|
5626
|
+
// src/llm/finding-fixes.ts
|
|
5627
|
+
async function safeReadRepoFile(cwd, rel, maxChars) {
|
|
5628
|
+
const root = path4.resolve(cwd);
|
|
5629
|
+
const abs = path4.resolve(root, rel);
|
|
5630
|
+
const relToRoot = path4.relative(root, abs);
|
|
5631
|
+
if (relToRoot.startsWith("..") || path4.isAbsolute(relToRoot)) return null;
|
|
5632
|
+
try {
|
|
5633
|
+
let t3 = await fs.readFile(abs, "utf8");
|
|
5634
|
+
if (t3.length > maxChars) {
|
|
5635
|
+
t3 = t3.slice(0, maxChars) + `
|
|
5636
|
+
|
|
5637
|
+
/* \u2026 truncated after ${maxChars} chars (FrontGuard context limit) \u2026 */
|
|
5638
|
+
`;
|
|
5639
|
+
}
|
|
5640
|
+
return t3;
|
|
5641
|
+
} catch {
|
|
5642
|
+
return null;
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
function parseFixResponse(raw) {
|
|
5646
|
+
const codeMatch = /```(?:\w+)?\n([\s\S]*?)```/m.exec(raw);
|
|
5647
|
+
const code = codeMatch?.[1]?.trim();
|
|
5648
|
+
let summary = codeMatch ? raw.replace(codeMatch[0], "").trim() : raw.trim();
|
|
5649
|
+
summary = summary.replace(/^#{1,6}\s+Fix\s*$/m, "").trim();
|
|
5650
|
+
return { summary: summary || raw.trim(), code };
|
|
5651
|
+
}
|
|
5652
|
+
async function enrichFindingsWithOllamaFixes(opts) {
|
|
5653
|
+
const { cwd, config, stack, results } = opts;
|
|
5654
|
+
const cfg = config.checks.llm;
|
|
5655
|
+
if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
|
|
5656
|
+
return results;
|
|
5657
|
+
}
|
|
5658
|
+
let pkgSnippet = "";
|
|
5659
|
+
try {
|
|
5660
|
+
const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
|
|
5661
|
+
pkgSnippet = pj.slice(0, 4e3);
|
|
5662
|
+
} catch {
|
|
5663
|
+
pkgSnippet = "";
|
|
5664
|
+
}
|
|
5665
|
+
const stackLabel = formatStackOneLiner(stack);
|
|
5666
|
+
const out = results.map((r4) => ({
|
|
5667
|
+
...r4,
|
|
5668
|
+
findings: r4.findings.map((f4) => ({ ...f4 }))
|
|
5669
|
+
}));
|
|
5670
|
+
let budget = cfg.maxFixSuggestions;
|
|
5671
|
+
for (let ri = 0; ri < out.length && budget > 0; ri++) {
|
|
5672
|
+
const r4 = out[ri];
|
|
5673
|
+
for (let fi = 0; fi < r4.findings.length && budget > 0; fi++) {
|
|
5674
|
+
const f4 = r4.findings[fi];
|
|
5675
|
+
if (f4.severity !== "warn" && f4.severity !== "block") continue;
|
|
5676
|
+
if (!f4.file) continue;
|
|
5677
|
+
budget -= 1;
|
|
5678
|
+
const fileContent = await safeReadRepoFile(
|
|
5679
|
+
cwd,
|
|
5680
|
+
f4.file,
|
|
5681
|
+
cfg.maxFileContextChars
|
|
5682
|
+
);
|
|
5683
|
+
const prompt2 = [
|
|
5684
|
+
"You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
|
|
5685
|
+
"Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
|
|
5686
|
+
"If context is insufficient, say what is missing instead of guessing.",
|
|
5687
|
+
"",
|
|
5688
|
+
"Reply in Markdown with exactly these sections:",
|
|
5689
|
+
"### Why",
|
|
5690
|
+
"(1\u20133 short sentences: root cause and product/engineering risk.)",
|
|
5691
|
+
"### Fix",
|
|
5692
|
+
"(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)",
|
|
5693
|
+
"",
|
|
5694
|
+
`Repo stack: ${stackLabel}`,
|
|
5695
|
+
"",
|
|
5696
|
+
"package.json excerpt:",
|
|
5697
|
+
"```json",
|
|
5698
|
+
pkgSnippet || "{}",
|
|
5699
|
+
"```",
|
|
5700
|
+
"",
|
|
5701
|
+
`check: ${r4.checkId}`,
|
|
5702
|
+
`rule/id: ${f4.id}`,
|
|
5703
|
+
`severity: ${f4.severity}`,
|
|
5704
|
+
`message: ${f4.message}`,
|
|
5705
|
+
f4.detail ? `detail: ${f4.detail}` : "",
|
|
5706
|
+
"",
|
|
5707
|
+
f4.file ? `file (repo-relative): ${f4.file}` : "",
|
|
5708
|
+
"",
|
|
5709
|
+
fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
|
|
5710
|
+
].filter(Boolean).join("\n");
|
|
5711
|
+
try {
|
|
5712
|
+
const raw = await callOllamaChat({
|
|
5713
|
+
baseUrl: cfg.ollamaUrl,
|
|
5714
|
+
model: cfg.model,
|
|
5715
|
+
prompt: prompt2,
|
|
5716
|
+
timeoutMs: Math.min(cfg.timeoutMs, 12e4)
|
|
5717
|
+
});
|
|
5718
|
+
const parsed = parseFixResponse(raw);
|
|
5719
|
+
r4.findings[fi] = {
|
|
5720
|
+
...f4,
|
|
5721
|
+
suggestedFix: {
|
|
5722
|
+
summary: parsed.summary,
|
|
5723
|
+
...parsed.code ? { code: parsed.code } : {}
|
|
5724
|
+
}
|
|
5725
|
+
};
|
|
5726
|
+
} catch {
|
|
5727
|
+
r4.findings[fi] = {
|
|
5728
|
+
...f4,
|
|
5729
|
+
suggestedFix: {
|
|
5730
|
+
summary: "_Could not reach Ollama or the model timed out. Is `ollama serve` running and `checks.llm.model` installed?_"
|
|
5731
|
+
}
|
|
5732
|
+
};
|
|
5733
|
+
}
|
|
5734
|
+
}
|
|
5735
|
+
}
|
|
5736
|
+
return out;
|
|
5737
|
+
}
|
|
5738
|
+
var MAX_CHARS = 2e5;
|
|
5739
|
+
async function loadManualAppendix(opts) {
|
|
5740
|
+
const { cwd, filePath } = opts;
|
|
5741
|
+
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
5742
|
+
const resolvedPath = filePath?.trim() || envFile;
|
|
5743
|
+
if (resolvedPath) {
|
|
5744
|
+
const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
|
|
5745
|
+
try {
|
|
5746
|
+
let text = await fs.readFile(abs, "utf8");
|
|
5747
|
+
if (text.length > MAX_CHARS) {
|
|
5748
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5749
|
+
}
|
|
5750
|
+
const t3 = text.trim();
|
|
5751
|
+
if (t3) {
|
|
5752
|
+
return `### Contributed review notes
|
|
5753
|
+
|
|
5754
|
+
_Pasted or file-based (no CI API key)._
|
|
5755
|
+
|
|
5756
|
+
${t3}`;
|
|
5757
|
+
}
|
|
5758
|
+
} catch {
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
5762
|
+
if (inline) {
|
|
5763
|
+
let text = inline;
|
|
5764
|
+
if (text.length > MAX_CHARS) {
|
|
5765
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5766
|
+
}
|
|
5767
|
+
return `### Contributed review notes
|
|
5768
|
+
|
|
5769
|
+
${text.trim()}`;
|
|
5770
|
+
}
|
|
5771
|
+
return null;
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5242
5774
|
// src/llm/review.ts
|
|
5243
5775
|
function safeGetEnv(name) {
|
|
5244
5776
|
const v3 = process.env[name];
|
|
@@ -5248,21 +5780,23 @@ async function runLlmReview(opts) {
|
|
|
5248
5780
|
const { cwd, config, pr, results } = opts;
|
|
5249
5781
|
const cfg = config.checks.llm;
|
|
5250
5782
|
if (!cfg.enabled) return null;
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
if (
|
|
5254
|
-
|
|
5783
|
+
if (cfg.provider !== "ollama") {
|
|
5784
|
+
const apiKey2 = safeGetEnv(cfg.apiKeyEnv);
|
|
5785
|
+
if (!apiKey2) {
|
|
5786
|
+
if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
|
|
5787
|
+
return null;
|
|
5788
|
+
}
|
|
5789
|
+
return [
|
|
5790
|
+
"### AI review (automated CI)",
|
|
5791
|
+
"",
|
|
5792
|
+
"_No API key in this environment._ IDE credentials do not reach CI runners.",
|
|
5793
|
+
"",
|
|
5794
|
+
"**Options:**",
|
|
5795
|
+
"1. **Manual** \u2014 Paste notes into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
5796
|
+
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
5797
|
+
'3. **Local Ollama** \u2014 Set `checks.llm.provider` to `"ollama"` (no API key; see docs).'
|
|
5798
|
+
].join("\n");
|
|
5255
5799
|
}
|
|
5256
|
-
return [
|
|
5257
|
-
"### AI review (automated CI)",
|
|
5258
|
-
"",
|
|
5259
|
-
"_No API key in this environment._ IDE credentials do not reach GitHub Actions.",
|
|
5260
|
-
"",
|
|
5261
|
-
"**Options:**",
|
|
5262
|
-
"1. **Manual** \u2014 Paste notes from Cursor/ChatGPT/Claude into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
5263
|
-
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
5264
|
-
"3. **Docs in PR** \u2014 Rely on the PR template \u201CAI assistance\u201D section for reviewer context."
|
|
5265
|
-
].join("\n");
|
|
5266
5800
|
}
|
|
5267
5801
|
if (!await gitOk(cwd)) {
|
|
5268
5802
|
return "_LLM review skipped: not a git repository_";
|
|
@@ -5286,7 +5820,7 @@ async function runLlmReview(opts) {
|
|
|
5286
5820
|
"",
|
|
5287
5821
|
pr ? `PR title: ${pr.title}
|
|
5288
5822
|
PR body excerpt:
|
|
5289
|
-
${pr.body.slice(0, 2e3)}` : "No
|
|
5823
|
+
${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
|
|
5290
5824
|
"",
|
|
5291
5825
|
"Existing automated findings (may be incomplete):",
|
|
5292
5826
|
summaryLines || "(none)",
|
|
@@ -5296,6 +5830,23 @@ ${pr.body.slice(0, 2e3)}` : "No GitHub PR context (local run).",
|
|
|
5296
5830
|
diff,
|
|
5297
5831
|
"```"
|
|
5298
5832
|
].join("\n");
|
|
5833
|
+
if (cfg.provider === "ollama") {
|
|
5834
|
+
try {
|
|
5835
|
+
const text = await callOllamaChat({
|
|
5836
|
+
baseUrl: cfg.ollamaUrl,
|
|
5837
|
+
model: cfg.model,
|
|
5838
|
+
prompt: prompt2,
|
|
5839
|
+
timeoutMs: cfg.timeoutMs
|
|
5840
|
+
});
|
|
5841
|
+
return `### AI review (non-binding, Ollama)
|
|
5842
|
+
|
|
5843
|
+
${text}`;
|
|
5844
|
+
} catch (e3) {
|
|
5845
|
+
const msg = e3 instanceof Error ? e3.message : String(e3);
|
|
5846
|
+
return `_Ollama request failed: ${msg}_`;
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
const apiKey = safeGetEnv(cfg.apiKeyEnv);
|
|
5299
5850
|
const controller = new AbortController();
|
|
5300
5851
|
const t3 = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
5301
5852
|
try {
|
|
@@ -5372,41 +5923,6 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
|
|
|
5372
5923
|
if (!text) throw new Error("Anthropic returned empty content");
|
|
5373
5924
|
return text;
|
|
5374
5925
|
}
|
|
5375
|
-
var MAX_CHARS = 2e5;
|
|
5376
|
-
async function loadManualAppendix(opts) {
|
|
5377
|
-
const { cwd, filePath } = opts;
|
|
5378
|
-
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
5379
|
-
const resolvedPath = filePath?.trim() || envFile;
|
|
5380
|
-
if (resolvedPath) {
|
|
5381
|
-
const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
|
|
5382
|
-
try {
|
|
5383
|
-
let text = await fs.readFile(abs, "utf8");
|
|
5384
|
-
if (text.length > MAX_CHARS) {
|
|
5385
|
-
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5386
|
-
}
|
|
5387
|
-
const t3 = text.trim();
|
|
5388
|
-
if (t3) {
|
|
5389
|
-
return `### Contributed review notes
|
|
5390
|
-
|
|
5391
|
-
_Pasted or file-based (no CI API key)._
|
|
5392
|
-
|
|
5393
|
-
${t3}`;
|
|
5394
|
-
}
|
|
5395
|
-
} catch {
|
|
5396
|
-
}
|
|
5397
|
-
}
|
|
5398
|
-
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
5399
|
-
if (inline) {
|
|
5400
|
-
let text = inline;
|
|
5401
|
-
if (text.length > MAX_CHARS) {
|
|
5402
|
-
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5403
|
-
}
|
|
5404
|
-
return `### Contributed review notes
|
|
5405
|
-
|
|
5406
|
-
${text.trim()}`;
|
|
5407
|
-
}
|
|
5408
|
-
return null;
|
|
5409
|
-
}
|
|
5410
5926
|
|
|
5411
5927
|
// src/commands/run.ts
|
|
5412
5928
|
async function runFrontGuard(opts) {
|
|
@@ -5441,7 +5957,7 @@ async function runFrontGuard(opts) {
|
|
|
5441
5957
|
const bundle = await runBundle(opts.cwd, config, stack);
|
|
5442
5958
|
const prHygiene = runPrHygiene(config, pr);
|
|
5443
5959
|
const prSize = runPrSize(config, pr);
|
|
5444
|
-
|
|
5960
|
+
let results = [
|
|
5445
5961
|
eslint,
|
|
5446
5962
|
prettier,
|
|
5447
5963
|
typescript,
|
|
@@ -5457,6 +5973,12 @@ async function runFrontGuard(opts) {
|
|
|
5457
5973
|
prSize
|
|
5458
5974
|
];
|
|
5459
5975
|
applyAiAssistedEscalation(results, pr, config);
|
|
5976
|
+
results = await enrichFindingsWithOllamaFixes({
|
|
5977
|
+
cwd: opts.cwd,
|
|
5978
|
+
config,
|
|
5979
|
+
stack,
|
|
5980
|
+
results
|
|
5981
|
+
});
|
|
5460
5982
|
const manualAppendix = await loadManualAppendix({
|
|
5461
5983
|
cwd: opts.cwd,
|
|
5462
5984
|
filePath: opts.append ?? null
|
|
@@ -5471,8 +5993,25 @@ async function runFrontGuard(opts) {
|
|
|
5471
5993
|
const report = buildReport(stack, pr, results, {
|
|
5472
5994
|
mode,
|
|
5473
5995
|
llmAppendix,
|
|
5474
|
-
cwd: opts.cwd
|
|
5996
|
+
cwd: opts.cwd,
|
|
5997
|
+
emitHtml: Boolean(opts.htmlOut)
|
|
5475
5998
|
});
|
|
5999
|
+
if (opts.htmlOut && report.html) {
|
|
6000
|
+
await fs.writeFile(opts.htmlOut, report.html, "utf8");
|
|
6001
|
+
}
|
|
6002
|
+
if (opts.prCommentOut) {
|
|
6003
|
+
const snippet = formatBitbucketPrSnippet(report);
|
|
6004
|
+
const abs = path4.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path4.join(opts.cwd, opts.prCommentOut);
|
|
6005
|
+
await fs.writeFile(abs, snippet, "utf8");
|
|
6006
|
+
g.stderr.write(
|
|
6007
|
+
`
|
|
6008
|
+
FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
|
|
6009
|
+
Use ONLY this file in your POST \u2026/pullrequests/{id}/comments payload (content.raw).
|
|
6010
|
+
Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
|
|
6011
|
+
|
|
6012
|
+
`
|
|
6013
|
+
);
|
|
6014
|
+
}
|
|
5476
6015
|
if (opts.markdown) {
|
|
5477
6016
|
g.stdout.write(report.markdown + "\n");
|
|
5478
6017
|
} else {
|
|
@@ -5524,6 +6063,14 @@ var run = defineCommand({
|
|
|
5524
6063
|
append: {
|
|
5525
6064
|
type: "string",
|
|
5526
6065
|
description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
|
|
6066
|
+
},
|
|
6067
|
+
htmlOut: {
|
|
6068
|
+
type: "string",
|
|
6069
|
+
description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
|
|
6070
|
+
},
|
|
6071
|
+
prCommentOut: {
|
|
6072
|
+
type: "string",
|
|
6073
|
+
description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
|
|
5527
6074
|
}
|
|
5528
6075
|
},
|
|
5529
6076
|
run: async ({ args }) => {
|
|
@@ -5532,7 +6079,9 @@ var run = defineCommand({
|
|
|
5532
6079
|
ci: Boolean(args.ci),
|
|
5533
6080
|
markdown: Boolean(args.markdown),
|
|
5534
6081
|
enforce: Boolean(args.enforce),
|
|
5535
|
-
append: typeof args.append === "string" ? args.append : null
|
|
6082
|
+
append: typeof args.append === "string" ? args.append : null,
|
|
6083
|
+
htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
|
|
6084
|
+
prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
|
|
5536
6085
|
});
|
|
5537
6086
|
}
|
|
5538
6087
|
});
|