@cleartrip/frontguard 0.1.6 → 0.1.7
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 +663 -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 +41 -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,76 @@ 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
|
+
let linkLine;
|
|
2551
|
+
if (publicReport) {
|
|
2552
|
+
linkLine = `**[Open full FrontGuard report (HTML)](${publicReport})** \u2014 interactive report in the browser (all findings).`;
|
|
2553
|
+
} else if (downloadsName && downloadsPage) {
|
|
2554
|
+
linkLine = `**[Repo Downloads](${downloadsPage})** \u2014 open \`${downloadsName}\` (uploaded by this pipeline). Download the file, then open it in a browser.`;
|
|
2555
|
+
} else if (pipeline) {
|
|
2556
|
+
linkLine = [
|
|
2557
|
+
`**[Open this pipeline run](${pipeline})** (Bitbucket login required).`,
|
|
2558
|
+
"On that page: open the **Artifacts** section \u2192 download **`frontguard-report.html`** \u2192 open the file in your browser to see the full interactive report."
|
|
2559
|
+
].join(" ");
|
|
2560
|
+
} else {
|
|
2561
|
+
linkLine = "_Run FrontGuard inside Bitbucket Pipelines so `BITBUCKET_REPO_FULL_NAME` and `BITBUCKET_BUILD_NUMBER` are set, or set `FRONTGUARD_PUBLIC_REPORT_URL` after uploading the HTML._";
|
|
2562
|
+
}
|
|
2563
|
+
const { riskScore, results } = report;
|
|
2564
|
+
const blocks = results.reduce(
|
|
2565
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "block").length,
|
|
2566
|
+
0
|
|
2567
|
+
);
|
|
2568
|
+
const warns = results.reduce(
|
|
2569
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
|
|
2570
|
+
0
|
|
2571
|
+
);
|
|
2572
|
+
const lines = [
|
|
2573
|
+
"## FrontGuard",
|
|
2574
|
+
"",
|
|
2575
|
+
`**Risk:** ${riskScore} \xB7 **Blocking:** ${blocks} \xB7 **Warnings:** ${warns}`,
|
|
2576
|
+
"",
|
|
2577
|
+
linkLine,
|
|
2578
|
+
"",
|
|
2579
|
+
"| Check | Status |",
|
|
2580
|
+
"|:--|:--|"
|
|
2581
|
+
];
|
|
2582
|
+
for (const r4 of results) {
|
|
2583
|
+
const status = r4.skipped ? `Skipped (${r4.skipped.slice(0, 80)}${r4.skipped.length > 80 ? "\u2026" : ""})` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
|
|
2584
|
+
lines.push(`| \`${r4.checkId}\` | ${status} |`);
|
|
2585
|
+
}
|
|
2586
|
+
lines.push("");
|
|
2587
|
+
lines.push(
|
|
2588
|
+
publicReport ? "_All findings and suggested fixes are in the linked HTML report._" : "_Summary only \u2014 use the link above for the full interactive report._"
|
|
2589
|
+
);
|
|
2590
|
+
return lines.join("\n");
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2520
2593
|
// src/ci/parse-ai-disclosure.ts
|
|
2521
2594
|
function extractAiSection(body) {
|
|
2522
2595
|
const lines = body.split(/\r?\n/);
|
|
@@ -2614,10 +2687,10 @@ async function resolvePrNumber() {
|
|
|
2614
2687
|
const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
|
|
2615
2688
|
const n3 = Number(raw);
|
|
2616
2689
|
if (Number.isFinite(n3) && n3 > 0) return n3;
|
|
2617
|
-
const
|
|
2618
|
-
if (!
|
|
2690
|
+
const path16 = process.env.GITHUB_EVENT_PATH;
|
|
2691
|
+
if (!path16) return null;
|
|
2619
2692
|
try {
|
|
2620
|
-
const payload = JSON.parse(await fs.readFile(
|
|
2693
|
+
const payload = JSON.parse(await fs.readFile(path16, "utf8"));
|
|
2621
2694
|
const num = payload.pull_request?.number;
|
|
2622
2695
|
return typeof num === "number" && num > 0 ? num : null;
|
|
2623
2696
|
} catch {
|
|
@@ -2797,7 +2870,11 @@ var defaultConfig = {
|
|
|
2797
2870
|
model: "gpt-4o-mini",
|
|
2798
2871
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
2799
2872
|
maxDiffChars: 48e3,
|
|
2800
|
-
timeoutMs: 6e4
|
|
2873
|
+
timeoutMs: 6e4,
|
|
2874
|
+
ollamaUrl: "http://127.0.0.1:11434",
|
|
2875
|
+
perFindingFixes: false,
|
|
2876
|
+
maxFixSuggestions: 12,
|
|
2877
|
+
maxFileContextChars: 24e3
|
|
2801
2878
|
}
|
|
2802
2879
|
}
|
|
2803
2880
|
};
|
|
@@ -2931,6 +3008,16 @@ async function detectStack(cwd) {
|
|
|
2931
3008
|
tsStrict
|
|
2932
3009
|
};
|
|
2933
3010
|
}
|
|
3011
|
+
function formatStackOneLiner(s3) {
|
|
3012
|
+
const bits = [];
|
|
3013
|
+
if (s3.hasNext) bits.push("Next.js");
|
|
3014
|
+
if (s3.hasReactNative) bits.push("React Native");
|
|
3015
|
+
else if (s3.hasReact) bits.push("React");
|
|
3016
|
+
if (s3.hasTypeScript) bits.push("TypeScript");
|
|
3017
|
+
if (s3.tsStrict === true) bits.push("strict TS");
|
|
3018
|
+
bits.push(`pkg: ${s3.packageManager}`);
|
|
3019
|
+
return bits.join(" \xB7 ") || "unknown";
|
|
3020
|
+
}
|
|
2934
3021
|
function stripFileUrl(p2) {
|
|
2935
3022
|
let s3 = p2.trim();
|
|
2936
3023
|
if (!/^file:/i.test(s3)) return s3;
|
|
@@ -4841,6 +4928,265 @@ function applyAiAssistedEscalation(results, pr, config) {
|
|
|
4841
4928
|
|
|
4842
4929
|
// src/report/builder.ts
|
|
4843
4930
|
var import_picocolors = __toESM(require_picocolors());
|
|
4931
|
+
|
|
4932
|
+
// src/lib/html-escape.ts
|
|
4933
|
+
function escapeHtml(s3) {
|
|
4934
|
+
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
// src/report/html-report.ts
|
|
4938
|
+
function shieldUrl(label, message, color) {
|
|
4939
|
+
const q2 = new URLSearchParams({ label, message, color, style: "for-the-badge" });
|
|
4940
|
+
return `https://img.shields.io/static/v1?${q2}`;
|
|
4941
|
+
}
|
|
4942
|
+
function riskColor(risk) {
|
|
4943
|
+
if (risk === "LOW") return "brightgreen";
|
|
4944
|
+
if (risk === "MEDIUM") return "orange";
|
|
4945
|
+
return "red";
|
|
4946
|
+
}
|
|
4947
|
+
function modeColor(mode) {
|
|
4948
|
+
return mode === "enforce" ? "critical" : "blue";
|
|
4949
|
+
}
|
|
4950
|
+
function countColor(kind, n3) {
|
|
4951
|
+
if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
|
|
4952
|
+
if (kind === "info") return n3 === 0 ? "inactive" : "informational";
|
|
4953
|
+
if (n3 === 0) return "brightgreen";
|
|
4954
|
+
if (n3 <= 10) return "yellow";
|
|
4955
|
+
return "orange";
|
|
4956
|
+
}
|
|
4957
|
+
function parseLineHint(detail) {
|
|
4958
|
+
if (!detail) return 0;
|
|
4959
|
+
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
4960
|
+
return m3 ? Number(m3[1]) : 0;
|
|
4961
|
+
}
|
|
4962
|
+
function normalizeFinding(cwd, f4) {
|
|
4963
|
+
return {
|
|
4964
|
+
file: toRepoRelativePath(cwd, f4.file),
|
|
4965
|
+
message: stripRepoAbsolutePaths(cwd, f4.message),
|
|
4966
|
+
detail: f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4969
|
+
function sortFindings(cwd, items) {
|
|
4970
|
+
return [...items].sort((a3, b3) => {
|
|
4971
|
+
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
4972
|
+
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
4973
|
+
if (af !== bf) return af.localeCompare(bf);
|
|
4974
|
+
const lineA = parseLineHint(a3.f.detail);
|
|
4975
|
+
const lineB = parseLineHint(b3.f.detail);
|
|
4976
|
+
if (lineA !== lineB) return lineA - lineB;
|
|
4977
|
+
return a3.f.message.localeCompare(b3.f.message);
|
|
4978
|
+
});
|
|
4979
|
+
}
|
|
4980
|
+
function formatDuration(ms) {
|
|
4981
|
+
if (ms < 1e3) return `${ms} ms`;
|
|
4982
|
+
const s3 = Math.round(ms / 1e3);
|
|
4983
|
+
if (s3 < 60) return `${s3}s`;
|
|
4984
|
+
const m3 = Math.floor(s3 / 60);
|
|
4985
|
+
const r4 = s3 % 60;
|
|
4986
|
+
return r4 ? `${m3}m ${r4}s` : `${m3}m`;
|
|
4987
|
+
}
|
|
4988
|
+
function renderFindingCard(cwd, r4, f4) {
|
|
4989
|
+
const d3 = normalizeFinding(cwd, f4);
|
|
4990
|
+
const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
|
|
4991
|
+
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>` : "";
|
|
4992
|
+
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>` : "";
|
|
4993
|
+
const detailFence = d3.detail && (d3.detail.includes("\n") || d3.detail.length > 220 || d3.detail.includes("|")) ? `<pre class="code"><code>${escapeHtml(d3.detail)}</code></pre>` : "";
|
|
4994
|
+
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>`;
|
|
4995
|
+
}
|
|
4996
|
+
function buildHtmlReport(p2) {
|
|
4997
|
+
const {
|
|
4998
|
+
cwd,
|
|
4999
|
+
riskScore,
|
|
5000
|
+
mode,
|
|
5001
|
+
stack,
|
|
5002
|
+
pr,
|
|
5003
|
+
results,
|
|
5004
|
+
warns,
|
|
5005
|
+
infos,
|
|
5006
|
+
blocks,
|
|
5007
|
+
lines,
|
|
5008
|
+
llmAppendix
|
|
5009
|
+
} = p2;
|
|
5010
|
+
const modeLabel = mode === "enforce" ? "enforce" : "warn only";
|
|
5011
|
+
const badges = [
|
|
5012
|
+
["risk", riskScore, riskColor(riskScore)],
|
|
5013
|
+
["mode", modeLabel, modeColor(mode)],
|
|
5014
|
+
["blocking", String(blocks), countColor("block", blocks)],
|
|
5015
|
+
["warnings", String(warns), countColor("warn", warns)],
|
|
5016
|
+
["info", String(infos), countColor("info", infos)]
|
|
5017
|
+
];
|
|
5018
|
+
const badgeImgs = badges.map(([l3, m3, c4]) => {
|
|
5019
|
+
const alt = `${l3}: ${m3}`;
|
|
5020
|
+
return `<img class="badge" src="${escapeHtml(shieldUrl(l3, m3, c4))}" alt="${escapeHtml(alt)}" loading="lazy" />`;
|
|
5021
|
+
}).join(" ");
|
|
5022
|
+
const checkRows = results.map((r4) => {
|
|
5023
|
+
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
|
|
5024
|
+
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>`;
|
|
5025
|
+
}).join("\n");
|
|
5026
|
+
const blockItems = sortFindings(
|
|
5027
|
+
cwd,
|
|
5028
|
+
results.flatMap(
|
|
5029
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5030
|
+
)
|
|
5031
|
+
);
|
|
5032
|
+
const warnItems = sortFindings(
|
|
5033
|
+
cwd,
|
|
5034
|
+
results.flatMap(
|
|
5035
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5036
|
+
)
|
|
5037
|
+
);
|
|
5038
|
+
const infoItems = sortFindings(
|
|
5039
|
+
cwd,
|
|
5040
|
+
results.flatMap(
|
|
5041
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5042
|
+
)
|
|
5043
|
+
);
|
|
5044
|
+
const byCheck = /* @__PURE__ */ new Map();
|
|
5045
|
+
for (const item of warnItems) {
|
|
5046
|
+
const list = byCheck.get(item.r.checkId) ?? [];
|
|
5047
|
+
list.push(item);
|
|
5048
|
+
byCheck.set(item.r.checkId, list);
|
|
5049
|
+
}
|
|
5050
|
+
const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
|
|
5051
|
+
const blockingHtml = blockItems.length === 0 ? '<p class="ok">No blocking findings.</p>' : blockItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5052
|
+
let warningsHtml = "";
|
|
5053
|
+
if (warnItems.length === 0) {
|
|
5054
|
+
warningsHtml = '<p class="ok">No warnings.</p>';
|
|
5055
|
+
} else {
|
|
5056
|
+
for (const cid of checkOrder) {
|
|
5057
|
+
const group = sortFindings(cwd, byCheck.get(cid));
|
|
5058
|
+
warningsHtml += `<h3 class="grp">${escapeHtml(cid)} <span class="count">(${group.length})</span></h3>`;
|
|
5059
|
+
warningsHtml += group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
const infoHtml = infoItems.length === 0 ? '<p class="muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5063
|
+
const prBlock = pr && lines != null ? `<tr><th>PR size</th><td>${lines} LOC (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
|
|
5064
|
+
const appendix = llmAppendix?.trim() ? `<section class="appendix"><h2>AI / manual appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
|
|
5065
|
+
return `<!DOCTYPE html>
|
|
5066
|
+
<html lang="en">
|
|
5067
|
+
<head>
|
|
5068
|
+
<meta charset="utf-8" />
|
|
5069
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5070
|
+
<title>FrontGuard report</title>
|
|
5071
|
+
<style>
|
|
5072
|
+
:root {
|
|
5073
|
+
--bg: #0f1419;
|
|
5074
|
+
--panel: #1a2332;
|
|
5075
|
+
--text: #e7ecf3;
|
|
5076
|
+
--muted: #8b9aab;
|
|
5077
|
+
--border: #2d3d52;
|
|
5078
|
+
--block: #f87171;
|
|
5079
|
+
--warn: #fbbf24;
|
|
5080
|
+
--info: #38bdf8;
|
|
5081
|
+
--accent: #a78bfa;
|
|
5082
|
+
--ok: #4ade80;
|
|
5083
|
+
}
|
|
5084
|
+
* { box-sizing: border-box; }
|
|
5085
|
+
body {
|
|
5086
|
+
margin: 0; font-family: ui-sans-serif, system-ui, sans-serif;
|
|
5087
|
+
background: var(--bg); color: var(--text); line-height: 1.5;
|
|
5088
|
+
padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem;
|
|
5089
|
+
max-width: 58rem; margin-left: auto; margin-right: auto;
|
|
5090
|
+
}
|
|
5091
|
+
h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
|
|
5092
|
+
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
|
|
5093
|
+
h3.grp { margin: 1.5rem 0 0.5rem; font-size: 1rem; color: var(--warn); }
|
|
5094
|
+
h3.grp .count { color: var(--muted); font-weight: normal; }
|
|
5095
|
+
h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
|
|
5096
|
+
h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
|
|
5097
|
+
.badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
|
5098
|
+
.badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
|
|
5099
|
+
details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
|
|
5100
|
+
details.open-default { border-color: #3d4f6a; }
|
|
5101
|
+
summary {
|
|
5102
|
+
cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
|
|
5103
|
+
list-style: none; display: flex; align-items: center; gap: 0.5rem;
|
|
5104
|
+
}
|
|
5105
|
+
summary::-webkit-details-marker { display: none; }
|
|
5106
|
+
details[open] > summary { border-bottom: 1px solid var(--border); }
|
|
5107
|
+
.details-body { padding: 0.75rem 1rem 1rem; }
|
|
5108
|
+
table.results { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0.5rem 0 1rem; }
|
|
5109
|
+
table.results th, table.results td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
|
|
5110
|
+
table.results th { background: #243044; color: var(--muted); font-weight: 600; }
|
|
5111
|
+
.snapshot { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 0.5rem 0; }
|
|
5112
|
+
.snapshot th, .snapshot td { border: 1px solid var(--border); padding: 0.5rem 0.65rem; vertical-align: top; }
|
|
5113
|
+
.snapshot th { width: 10rem; background: #243044; color: var(--muted); text-align: left; }
|
|
5114
|
+
.card {
|
|
5115
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem 1rem;
|
|
5116
|
+
margin-bottom: 0.65rem; background: #131c28;
|
|
5117
|
+
}
|
|
5118
|
+
.card.sev-block { border-left: 4px solid var(--block); }
|
|
5119
|
+
.card.sev-warn { border-left: 4px solid var(--warn); }
|
|
5120
|
+
.card.sev-info { border-left: 4px solid var(--info); }
|
|
5121
|
+
table.meta { width: 100%; font-size: 0.8rem; border-collapse: collapse; margin: 0.5rem 0; }
|
|
5122
|
+
table.meta th { text-align: left; color: var(--muted); width: 5.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
|
|
5123
|
+
table.meta td { padding: 0.2rem 0; }
|
|
5124
|
+
.muted { color: var(--muted); }
|
|
5125
|
+
.ok { color: var(--ok); }
|
|
5126
|
+
pre.code {
|
|
5127
|
+
margin: 0.5rem 0 0; padding: 0.65rem 0.75rem; background: #0a0e14; border-radius: 6px;
|
|
5128
|
+
overflow: auto; font-size: 0.78rem; border: 1px solid var(--border);
|
|
5129
|
+
}
|
|
5130
|
+
pre.code code { font-family: ui-monospace, monospace; white-space: pre; }
|
|
5131
|
+
.suggested-fix {
|
|
5132
|
+
margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
|
|
5133
|
+
}
|
|
5134
|
+
.fix-md { font-size: 0.85rem; white-space: pre-wrap; margin: 0.25rem 0 0.5rem; }
|
|
5135
|
+
.tag {
|
|
5136
|
+
font-size: 0.65rem; background: var(--accent); color: var(--bg);
|
|
5137
|
+
padding: 0.1rem 0.35rem; border-radius: 4px; vertical-align: middle;
|
|
5138
|
+
}
|
|
5139
|
+
.disclaimer { font-size: 0.72rem; color: var(--muted); margin: 0.5rem 0 0; }
|
|
5140
|
+
.appendix pre.md-raw {
|
|
5141
|
+
white-space: pre-wrap; font-size: 0.85rem; background: var(--panel);
|
|
5142
|
+
padding: 1rem; border-radius: 8px; border: 1px solid var(--border);
|
|
5143
|
+
}
|
|
5144
|
+
footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--muted); }
|
|
5145
|
+
</style>
|
|
5146
|
+
</head>
|
|
5147
|
+
<body>
|
|
5148
|
+
<h1>FrontGuard review</h1>
|
|
5149
|
+
<div class="badges">${badgeImgs}</div>
|
|
5150
|
+
|
|
5151
|
+
<h2>Snapshot</h2>
|
|
5152
|
+
<table class="snapshot">
|
|
5153
|
+
<tr><th>Risk</th><td><strong>${riskScore}</strong> (heuristic)</td></tr>
|
|
5154
|
+
<tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
|
|
5155
|
+
<tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
|
|
5156
|
+
${prBlock}
|
|
5157
|
+
</table>
|
|
5158
|
+
|
|
5159
|
+
<h2>Check results</h2>
|
|
5160
|
+
<table class="results">
|
|
5161
|
+
<thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
|
|
5162
|
+
<tbody>${checkRows}</tbody>
|
|
5163
|
+
</table>
|
|
5164
|
+
|
|
5165
|
+
<details class="open-default" open>
|
|
5166
|
+
<summary>Blocking (${blocks})</summary>
|
|
5167
|
+
<div class="details-body">${blockingHtml}</div>
|
|
5168
|
+
</details>
|
|
5169
|
+
|
|
5170
|
+
<details class="open-default" open>
|
|
5171
|
+
<summary>Warnings (${warns})</summary>
|
|
5172
|
+
<div class="details-body">${warningsHtml}</div>
|
|
5173
|
+
</details>
|
|
5174
|
+
|
|
5175
|
+
<details>
|
|
5176
|
+
<summary>Info (${infos})</summary>
|
|
5177
|
+
<div class="details-body">${infoHtml}</div>
|
|
5178
|
+
</details>
|
|
5179
|
+
|
|
5180
|
+
${appendix}
|
|
5181
|
+
|
|
5182
|
+
<footer>
|
|
5183
|
+
<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>
|
|
5184
|
+
</footer>
|
|
5185
|
+
</body>
|
|
5186
|
+
</html>`;
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
// src/report/builder.ts
|
|
4844
5190
|
function buildReport(stack, pr, results, options) {
|
|
4845
5191
|
const mode = options?.mode ?? "warn";
|
|
4846
5192
|
const cwd = options?.cwd ?? process.cwd();
|
|
@@ -4875,17 +5221,20 @@ function buildReport(stack, pr, results, options) {
|
|
|
4875
5221
|
infos,
|
|
4876
5222
|
blocks
|
|
4877
5223
|
});
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
5224
|
+
const html = options?.emitHtml === true ? buildHtmlReport({
|
|
5225
|
+
cwd,
|
|
5226
|
+
riskScore,
|
|
5227
|
+
mode,
|
|
5228
|
+
stack,
|
|
5229
|
+
pr,
|
|
5230
|
+
results,
|
|
5231
|
+
warns,
|
|
5232
|
+
infos,
|
|
5233
|
+
blocks,
|
|
5234
|
+
lines,
|
|
5235
|
+
llmAppendix: options?.llmAppendix ?? null
|
|
5236
|
+
}) : null;
|
|
5237
|
+
return { riskScore, stack, pr, results, markdown, consoleText, html };
|
|
4889
5238
|
}
|
|
4890
5239
|
function scoreRisk(blocks, warns, lines, files) {
|
|
4891
5240
|
let score = 0;
|
|
@@ -4933,7 +5282,7 @@ function countShieldColor(kind, n3) {
|
|
|
4933
5282
|
if (n3 <= 10) return "yellow";
|
|
4934
5283
|
return "orange";
|
|
4935
5284
|
}
|
|
4936
|
-
function
|
|
5285
|
+
function formatDuration2(ms) {
|
|
4937
5286
|
if (ms < 1e3) return `${ms} ms`;
|
|
4938
5287
|
const s3 = Math.round(ms / 1e3);
|
|
4939
5288
|
if (s3 < 60) return `${s3}s`;
|
|
@@ -4948,9 +5297,6 @@ function healthEmojiForCheck(r4) {
|
|
|
4948
5297
|
if (hasBlock) return "\u{1F534}";
|
|
4949
5298
|
return "\u{1F7E1}";
|
|
4950
5299
|
}
|
|
4951
|
-
function escapeHtml(s3) {
|
|
4952
|
-
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4953
|
-
}
|
|
4954
5300
|
function normalizeFindingDisplay(cwd, f4) {
|
|
4955
5301
|
const file = toRepoRelativePath(cwd, f4.file);
|
|
4956
5302
|
const message = stripRepoAbsolutePaths(cwd, f4.message);
|
|
@@ -4960,12 +5306,12 @@ function normalizeFindingDisplay(cwd, f4) {
|
|
|
4960
5306
|
function sanitizeFence(cwd, s3) {
|
|
4961
5307
|
return stripRepoAbsolutePaths(cwd, s3).replace(/\r\n/g, "\n").replace(/```/g, "`\u200B``");
|
|
4962
5308
|
}
|
|
4963
|
-
function
|
|
4964
|
-
const msg = message.length >
|
|
4965
|
-
const
|
|
4966
|
-
return
|
|
5309
|
+
function findingTitleLine(file, message) {
|
|
5310
|
+
const msg = message.length > 200 ? `${message.slice(0, 197).trimEnd()}\u2026` : message;
|
|
5311
|
+
const loc = file ? `\`${file}\`` : "_no file_";
|
|
5312
|
+
return `${loc} \u2014 ${msg}`;
|
|
4967
5313
|
}
|
|
4968
|
-
function
|
|
5314
|
+
function parseLineHint2(detail) {
|
|
4969
5315
|
if (!detail) return 0;
|
|
4970
5316
|
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
4971
5317
|
return m3 ? Number(m3[1]) : 0;
|
|
@@ -4986,18 +5332,18 @@ function appendDetailAfterTable(sb, cwd, detail) {
|
|
|
4986
5332
|
sb.push(sanitizeFence(cwd, d3));
|
|
4987
5333
|
sb.push("```");
|
|
4988
5334
|
}
|
|
4989
|
-
function
|
|
4990
|
-
if (!
|
|
4991
|
-
|
|
4992
|
-
|
|
5335
|
+
function appendSuggestedFix(sb, cwd, f4) {
|
|
5336
|
+
if (!f4.suggestedFix) return;
|
|
5337
|
+
sb.push("");
|
|
5338
|
+
sb.push("_Suggested fix (LLM \u2014 non-binding):_");
|
|
5339
|
+
sb.push("");
|
|
5340
|
+
sb.push(f4.suggestedFix.summary);
|
|
5341
|
+
if (f4.suggestedFix.code) {
|
|
4993
5342
|
sb.push("");
|
|
4994
|
-
sb.push(
|
|
4995
|
-
|
|
5343
|
+
sb.push("```text");
|
|
5344
|
+
sb.push(sanitizeFence(cwd, f4.suggestedFix.code));
|
|
5345
|
+
sb.push("```");
|
|
4996
5346
|
}
|
|
4997
|
-
sb.push("");
|
|
4998
|
-
sb.push("```text");
|
|
4999
|
-
sb.push(sanitizeFence(cwd, d3));
|
|
5000
|
-
sb.push("```");
|
|
5001
5347
|
}
|
|
5002
5348
|
function formatMarkdown(p2) {
|
|
5003
5349
|
const {
|
|
@@ -5018,8 +5364,8 @@ function formatMarkdown(p2) {
|
|
|
5018
5364
|
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
5019
5365
|
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
5020
5366
|
if (af !== bf) return af.localeCompare(bf);
|
|
5021
|
-
const lineA =
|
|
5022
|
-
const lineB =
|
|
5367
|
+
const lineA = parseLineHint2(a3.f.detail);
|
|
5368
|
+
const lineB = parseLineHint2(b3.f.detail);
|
|
5023
5369
|
if (lineA !== lineB) return lineA - lineB;
|
|
5024
5370
|
return a3.f.message.localeCompare(b3.f.message);
|
|
5025
5371
|
});
|
|
@@ -5068,7 +5414,7 @@ function formatMarkdown(p2) {
|
|
|
5068
5414
|
sb.push("> | \u23ED\uFE0F | Check skipped (see reason in table) |");
|
|
5069
5415
|
sb.push(">");
|
|
5070
5416
|
sb.push(
|
|
5071
|
-
"> Paths in findings are **relative to the repo root**.
|
|
5417
|
+
"> Paths in findings are **relative to the repo root**. Each issue below has a small field table and optional detail."
|
|
5072
5418
|
);
|
|
5073
5419
|
sb.push("");
|
|
5074
5420
|
sb.push("---");
|
|
@@ -5079,11 +5425,19 @@ function formatMarkdown(p2) {
|
|
|
5079
5425
|
sb.push("|:--:|:--|:--|:-:|--:|");
|
|
5080
5426
|
for (const r4 of results) {
|
|
5081
5427
|
const he2 = healthEmojiForCheck(r4);
|
|
5082
|
-
|
|
5428
|
+
let status;
|
|
5429
|
+
if (r4.skipped) {
|
|
5430
|
+
const why = r4.skipped.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
5431
|
+
const short = why.length > 120 ? `${why.slice(0, 117)}\u2026` : why;
|
|
5432
|
+
status = `\u23ED\uFE0F **Skipped** \u2014 ${short}`;
|
|
5433
|
+
} else if (r4.findings.length === 0) {
|
|
5434
|
+
status = "\u2705 **Clean**";
|
|
5435
|
+
} else {
|
|
5436
|
+
status = "\u26A0\uFE0F **Issues**";
|
|
5437
|
+
}
|
|
5083
5438
|
const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
|
|
5084
|
-
const note = r4.skipped ? `<br><sub>\u{1F4AC} ${escapeHtml(r4.skipped)}</sub>` : "";
|
|
5085
5439
|
sb.push(
|
|
5086
|
-
`| ${he2} | **${r4.checkId}** | ${status}
|
|
5440
|
+
`| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
|
|
5087
5441
|
);
|
|
5088
5442
|
}
|
|
5089
5443
|
sb.push("");
|
|
@@ -5092,21 +5446,16 @@ function formatMarkdown(p2) {
|
|
|
5092
5446
|
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5093
5447
|
)
|
|
5094
5448
|
);
|
|
5095
|
-
sb.push("
|
|
5096
|
-
sb.push(
|
|
5097
|
-
`<summary><strong>\u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}</strong></summary>`
|
|
5098
|
-
);
|
|
5449
|
+
sb.push(`### \u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}`);
|
|
5099
5450
|
sb.push("");
|
|
5100
5451
|
if (blockFindings.length === 0) {
|
|
5101
5452
|
sb.push("*\u2705 No blocking findings \u2014 nothing mapped to severity `block`.*");
|
|
5102
5453
|
} else {
|
|
5103
|
-
sb.push("");
|
|
5104
5454
|
for (const { r: r4, f: f4 } of blockFindings) {
|
|
5105
5455
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5106
|
-
sb.push("
|
|
5107
|
-
sb.push(
|
|
5108
|
-
|
|
5109
|
-
);
|
|
5456
|
+
sb.push("---");
|
|
5457
|
+
sb.push("");
|
|
5458
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5110
5459
|
sb.push("");
|
|
5111
5460
|
sb.push(`| Field | Value |`);
|
|
5112
5461
|
sb.push(`|:--|:--|`);
|
|
@@ -5114,22 +5463,18 @@ function formatMarkdown(p2) {
|
|
|
5114
5463
|
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5115
5464
|
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5116
5465
|
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5117
|
-
sb
|
|
5118
|
-
sb.push("</details>");
|
|
5466
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5119
5467
|
sb.push("");
|
|
5120
5468
|
}
|
|
5121
5469
|
}
|
|
5122
5470
|
sb.push("");
|
|
5123
|
-
sb.push("</details>");
|
|
5124
|
-
sb.push("");
|
|
5125
5471
|
const warnFindings = sortWithCwd(
|
|
5126
5472
|
results.flatMap(
|
|
5127
5473
|
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5128
5474
|
)
|
|
5129
5475
|
);
|
|
5130
|
-
sb.push("<details open>");
|
|
5131
5476
|
sb.push(
|
|
5132
|
-
|
|
5477
|
+
`### \u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"} (by check)`
|
|
5133
5478
|
);
|
|
5134
5479
|
sb.push("");
|
|
5135
5480
|
if (warnFindings.length === 0) {
|
|
@@ -5148,10 +5493,9 @@ function formatMarkdown(p2) {
|
|
|
5148
5493
|
sb.push("");
|
|
5149
5494
|
for (const { r: r4, f: f4 } of group) {
|
|
5150
5495
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5151
|
-
sb.push("
|
|
5152
|
-
sb.push(
|
|
5153
|
-
|
|
5154
|
-
);
|
|
5496
|
+
sb.push("---");
|
|
5497
|
+
sb.push("");
|
|
5498
|
+
sb.push(`##### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5155
5499
|
sb.push("");
|
|
5156
5500
|
sb.push(`| Field | Value |`);
|
|
5157
5501
|
sb.push(`|:--|:--|`);
|
|
@@ -5159,42 +5503,39 @@ function formatMarkdown(p2) {
|
|
|
5159
5503
|
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5160
5504
|
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5161
5505
|
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5162
|
-
sb
|
|
5163
|
-
sb.push("</details>");
|
|
5506
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5164
5507
|
sb.push("");
|
|
5165
5508
|
}
|
|
5166
5509
|
}
|
|
5167
5510
|
}
|
|
5168
|
-
sb.push("</details>");
|
|
5169
5511
|
sb.push("");
|
|
5170
5512
|
const infoFindings = sortWithCwd(
|
|
5171
5513
|
results.flatMap(
|
|
5172
5514
|
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5173
5515
|
)
|
|
5174
5516
|
);
|
|
5175
|
-
sb.push("
|
|
5176
|
-
sb.push(
|
|
5177
|
-
`<summary><strong>\u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}</strong></summary>`
|
|
5178
|
-
);
|
|
5517
|
+
sb.push(`### \u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}`);
|
|
5179
5518
|
sb.push("");
|
|
5180
5519
|
if (infoFindings.length === 0) {
|
|
5181
5520
|
sb.push("*No info-level notes.*");
|
|
5182
5521
|
} else {
|
|
5183
5522
|
for (const { r: r4, f: f4 } of infoFindings) {
|
|
5184
5523
|
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5185
|
-
sb.push("
|
|
5186
|
-
sb.push(`<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`);
|
|
5524
|
+
sb.push("---");
|
|
5187
5525
|
sb.push("");
|
|
5188
|
-
sb.push(
|
|
5189
|
-
appendDetailFree(sb, cwd, d3.detail);
|
|
5526
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5190
5527
|
sb.push("");
|
|
5191
|
-
sb.push(
|
|
5528
|
+
sb.push(`| Field | Value |`);
|
|
5529
|
+
sb.push(`|:--|:--|`);
|
|
5530
|
+
sb.push(`| **Check** | \`${r4.checkId}\` |`);
|
|
5531
|
+
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5532
|
+
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5533
|
+
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5534
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5192
5535
|
sb.push("");
|
|
5193
5536
|
}
|
|
5194
5537
|
}
|
|
5195
5538
|
sb.push("");
|
|
5196
|
-
sb.push("</details>");
|
|
5197
|
-
sb.push("");
|
|
5198
5539
|
if (llmAppendix?.trim()) {
|
|
5199
5540
|
sb.push("### \u{1F916} AI / manual appendix");
|
|
5200
5541
|
sb.push("");
|
|
@@ -5212,7 +5553,9 @@ function formatMarkdown(p2) {
|
|
|
5212
5553
|
)
|
|
5213
5554
|
);
|
|
5214
5555
|
sb.push("");
|
|
5215
|
-
sb.push(
|
|
5556
|
+
sb.push(
|
|
5557
|
+
"_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)._"
|
|
5558
|
+
);
|
|
5216
5559
|
return sb.join("\n");
|
|
5217
5560
|
}
|
|
5218
5561
|
function formatConsole(p2) {
|
|
@@ -5239,6 +5582,185 @@ function formatConsole(p2) {
|
|
|
5239
5582
|
return lines.join("\n");
|
|
5240
5583
|
}
|
|
5241
5584
|
|
|
5585
|
+
// src/llm/ollama.ts
|
|
5586
|
+
async function callOllamaChat(opts) {
|
|
5587
|
+
const fetch = getFetch();
|
|
5588
|
+
const base = opts.baseUrl.replace(/\/$/, "");
|
|
5589
|
+
const controller = new AbortController();
|
|
5590
|
+
const t3 = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
5591
|
+
try {
|
|
5592
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
5593
|
+
method: "POST",
|
|
5594
|
+
signal: controller.signal,
|
|
5595
|
+
headers: { "Content-Type": "application/json" },
|
|
5596
|
+
body: JSON.stringify({
|
|
5597
|
+
model: opts.model,
|
|
5598
|
+
messages: [{ role: "user", content: opts.prompt }],
|
|
5599
|
+
stream: false,
|
|
5600
|
+
options: { temperature: 0.2 }
|
|
5601
|
+
})
|
|
5602
|
+
});
|
|
5603
|
+
if (!res.ok) {
|
|
5604
|
+
const body = await res.text();
|
|
5605
|
+
throw new Error(`Ollama HTTP ${res.status}: ${body.slice(0, 400)}`);
|
|
5606
|
+
}
|
|
5607
|
+
const data = await res.json();
|
|
5608
|
+
const text = data.message?.content?.trim();
|
|
5609
|
+
if (!text) throw new Error("Ollama returned empty content");
|
|
5610
|
+
return text;
|
|
5611
|
+
} finally {
|
|
5612
|
+
clearTimeout(t3);
|
|
5613
|
+
}
|
|
5614
|
+
}
|
|
5615
|
+
|
|
5616
|
+
// src/llm/finding-fixes.ts
|
|
5617
|
+
async function safeReadRepoFile(cwd, rel, maxChars) {
|
|
5618
|
+
const root = path4.resolve(cwd);
|
|
5619
|
+
const abs = path4.resolve(root, rel);
|
|
5620
|
+
const relToRoot = path4.relative(root, abs);
|
|
5621
|
+
if (relToRoot.startsWith("..") || path4.isAbsolute(relToRoot)) return null;
|
|
5622
|
+
try {
|
|
5623
|
+
let t3 = await fs.readFile(abs, "utf8");
|
|
5624
|
+
if (t3.length > maxChars) {
|
|
5625
|
+
t3 = t3.slice(0, maxChars) + `
|
|
5626
|
+
|
|
5627
|
+
/* \u2026 truncated after ${maxChars} chars (FrontGuard context limit) \u2026 */
|
|
5628
|
+
`;
|
|
5629
|
+
}
|
|
5630
|
+
return t3;
|
|
5631
|
+
} catch {
|
|
5632
|
+
return null;
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
function parseFixResponse(raw) {
|
|
5636
|
+
const codeMatch = /```(?:\w+)?\n([\s\S]*?)```/m.exec(raw);
|
|
5637
|
+
const code = codeMatch?.[1]?.trim();
|
|
5638
|
+
let summary = codeMatch ? raw.replace(codeMatch[0], "").trim() : raw.trim();
|
|
5639
|
+
summary = summary.replace(/^#{1,6}\s+Fix\s*$/m, "").trim();
|
|
5640
|
+
return { summary: summary || raw.trim(), code };
|
|
5641
|
+
}
|
|
5642
|
+
async function enrichFindingsWithOllamaFixes(opts) {
|
|
5643
|
+
const { cwd, config, stack, results } = opts;
|
|
5644
|
+
const cfg = config.checks.llm;
|
|
5645
|
+
if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
|
|
5646
|
+
return results;
|
|
5647
|
+
}
|
|
5648
|
+
let pkgSnippet = "";
|
|
5649
|
+
try {
|
|
5650
|
+
const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
|
|
5651
|
+
pkgSnippet = pj.slice(0, 4e3);
|
|
5652
|
+
} catch {
|
|
5653
|
+
pkgSnippet = "";
|
|
5654
|
+
}
|
|
5655
|
+
const stackLabel = formatStackOneLiner(stack);
|
|
5656
|
+
const out = results.map((r4) => ({
|
|
5657
|
+
...r4,
|
|
5658
|
+
findings: r4.findings.map((f4) => ({ ...f4 }))
|
|
5659
|
+
}));
|
|
5660
|
+
let budget = cfg.maxFixSuggestions;
|
|
5661
|
+
for (let ri = 0; ri < out.length && budget > 0; ri++) {
|
|
5662
|
+
const r4 = out[ri];
|
|
5663
|
+
for (let fi = 0; fi < r4.findings.length && budget > 0; fi++) {
|
|
5664
|
+
const f4 = r4.findings[fi];
|
|
5665
|
+
if (f4.severity !== "warn" && f4.severity !== "block") continue;
|
|
5666
|
+
if (!f4.file) continue;
|
|
5667
|
+
budget -= 1;
|
|
5668
|
+
const fileContent = await safeReadRepoFile(
|
|
5669
|
+
cwd,
|
|
5670
|
+
f4.file,
|
|
5671
|
+
cfg.maxFileContextChars
|
|
5672
|
+
);
|
|
5673
|
+
const prompt2 = [
|
|
5674
|
+
"You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
|
|
5675
|
+
"Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
|
|
5676
|
+
"If context is insufficient, say what is missing instead of guessing.",
|
|
5677
|
+
"",
|
|
5678
|
+
"Reply in Markdown with exactly these sections:",
|
|
5679
|
+
"### Why",
|
|
5680
|
+
"(1\u20133 short sentences: root cause and product/engineering risk.)",
|
|
5681
|
+
"### Fix",
|
|
5682
|
+
"(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)",
|
|
5683
|
+
"",
|
|
5684
|
+
`Repo stack: ${stackLabel}`,
|
|
5685
|
+
"",
|
|
5686
|
+
"package.json excerpt:",
|
|
5687
|
+
"```json",
|
|
5688
|
+
pkgSnippet || "{}",
|
|
5689
|
+
"```",
|
|
5690
|
+
"",
|
|
5691
|
+
`check: ${r4.checkId}`,
|
|
5692
|
+
`rule/id: ${f4.id}`,
|
|
5693
|
+
`severity: ${f4.severity}`,
|
|
5694
|
+
`message: ${f4.message}`,
|
|
5695
|
+
f4.detail ? `detail: ${f4.detail}` : "",
|
|
5696
|
+
"",
|
|
5697
|
+
f4.file ? `file (repo-relative): ${f4.file}` : "",
|
|
5698
|
+
"",
|
|
5699
|
+
fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
|
|
5700
|
+
].filter(Boolean).join("\n");
|
|
5701
|
+
try {
|
|
5702
|
+
const raw = await callOllamaChat({
|
|
5703
|
+
baseUrl: cfg.ollamaUrl,
|
|
5704
|
+
model: cfg.model,
|
|
5705
|
+
prompt: prompt2,
|
|
5706
|
+
timeoutMs: Math.min(cfg.timeoutMs, 12e4)
|
|
5707
|
+
});
|
|
5708
|
+
const parsed = parseFixResponse(raw);
|
|
5709
|
+
r4.findings[fi] = {
|
|
5710
|
+
...f4,
|
|
5711
|
+
suggestedFix: {
|
|
5712
|
+
summary: parsed.summary,
|
|
5713
|
+
...parsed.code ? { code: parsed.code } : {}
|
|
5714
|
+
}
|
|
5715
|
+
};
|
|
5716
|
+
} catch {
|
|
5717
|
+
r4.findings[fi] = {
|
|
5718
|
+
...f4,
|
|
5719
|
+
suggestedFix: {
|
|
5720
|
+
summary: "_Could not reach Ollama or the model timed out. Is `ollama serve` running and `checks.llm.model` installed?_"
|
|
5721
|
+
}
|
|
5722
|
+
};
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
return out;
|
|
5727
|
+
}
|
|
5728
|
+
var MAX_CHARS = 2e5;
|
|
5729
|
+
async function loadManualAppendix(opts) {
|
|
5730
|
+
const { cwd, filePath } = opts;
|
|
5731
|
+
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
5732
|
+
const resolvedPath = filePath?.trim() || envFile;
|
|
5733
|
+
if (resolvedPath) {
|
|
5734
|
+
const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
|
|
5735
|
+
try {
|
|
5736
|
+
let text = await fs.readFile(abs, "utf8");
|
|
5737
|
+
if (text.length > MAX_CHARS) {
|
|
5738
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5739
|
+
}
|
|
5740
|
+
const t3 = text.trim();
|
|
5741
|
+
if (t3) {
|
|
5742
|
+
return `### Contributed review notes
|
|
5743
|
+
|
|
5744
|
+
_Pasted or file-based (no CI API key)._
|
|
5745
|
+
|
|
5746
|
+
${t3}`;
|
|
5747
|
+
}
|
|
5748
|
+
} catch {
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
5752
|
+
if (inline) {
|
|
5753
|
+
let text = inline;
|
|
5754
|
+
if (text.length > MAX_CHARS) {
|
|
5755
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5756
|
+
}
|
|
5757
|
+
return `### Contributed review notes
|
|
5758
|
+
|
|
5759
|
+
${text.trim()}`;
|
|
5760
|
+
}
|
|
5761
|
+
return null;
|
|
5762
|
+
}
|
|
5763
|
+
|
|
5242
5764
|
// src/llm/review.ts
|
|
5243
5765
|
function safeGetEnv(name) {
|
|
5244
5766
|
const v3 = process.env[name];
|
|
@@ -5248,21 +5770,23 @@ async function runLlmReview(opts) {
|
|
|
5248
5770
|
const { cwd, config, pr, results } = opts;
|
|
5249
5771
|
const cfg = config.checks.llm;
|
|
5250
5772
|
if (!cfg.enabled) return null;
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
if (
|
|
5254
|
-
|
|
5773
|
+
if (cfg.provider !== "ollama") {
|
|
5774
|
+
const apiKey2 = safeGetEnv(cfg.apiKeyEnv);
|
|
5775
|
+
if (!apiKey2) {
|
|
5776
|
+
if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
|
|
5777
|
+
return null;
|
|
5778
|
+
}
|
|
5779
|
+
return [
|
|
5780
|
+
"### AI review (automated CI)",
|
|
5781
|
+
"",
|
|
5782
|
+
"_No API key in this environment._ IDE credentials do not reach CI runners.",
|
|
5783
|
+
"",
|
|
5784
|
+
"**Options:**",
|
|
5785
|
+
"1. **Manual** \u2014 Paste notes into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
5786
|
+
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
5787
|
+
'3. **Local Ollama** \u2014 Set `checks.llm.provider` to `"ollama"` (no API key; see docs).'
|
|
5788
|
+
].join("\n");
|
|
5255
5789
|
}
|
|
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
5790
|
}
|
|
5267
5791
|
if (!await gitOk(cwd)) {
|
|
5268
5792
|
return "_LLM review skipped: not a git repository_";
|
|
@@ -5286,7 +5810,7 @@ async function runLlmReview(opts) {
|
|
|
5286
5810
|
"",
|
|
5287
5811
|
pr ? `PR title: ${pr.title}
|
|
5288
5812
|
PR body excerpt:
|
|
5289
|
-
${pr.body.slice(0, 2e3)}` : "No
|
|
5813
|
+
${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
|
|
5290
5814
|
"",
|
|
5291
5815
|
"Existing automated findings (may be incomplete):",
|
|
5292
5816
|
summaryLines || "(none)",
|
|
@@ -5296,6 +5820,23 @@ ${pr.body.slice(0, 2e3)}` : "No GitHub PR context (local run).",
|
|
|
5296
5820
|
diff,
|
|
5297
5821
|
"```"
|
|
5298
5822
|
].join("\n");
|
|
5823
|
+
if (cfg.provider === "ollama") {
|
|
5824
|
+
try {
|
|
5825
|
+
const text = await callOllamaChat({
|
|
5826
|
+
baseUrl: cfg.ollamaUrl,
|
|
5827
|
+
model: cfg.model,
|
|
5828
|
+
prompt: prompt2,
|
|
5829
|
+
timeoutMs: cfg.timeoutMs
|
|
5830
|
+
});
|
|
5831
|
+
return `### AI review (non-binding, Ollama)
|
|
5832
|
+
|
|
5833
|
+
${text}`;
|
|
5834
|
+
} catch (e3) {
|
|
5835
|
+
const msg = e3 instanceof Error ? e3.message : String(e3);
|
|
5836
|
+
return `_Ollama request failed: ${msg}_`;
|
|
5837
|
+
}
|
|
5838
|
+
}
|
|
5839
|
+
const apiKey = safeGetEnv(cfg.apiKeyEnv);
|
|
5299
5840
|
const controller = new AbortController();
|
|
5300
5841
|
const t3 = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
5301
5842
|
try {
|
|
@@ -5372,41 +5913,6 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
|
|
|
5372
5913
|
if (!text) throw new Error("Anthropic returned empty content");
|
|
5373
5914
|
return text;
|
|
5374
5915
|
}
|
|
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
5916
|
|
|
5411
5917
|
// src/commands/run.ts
|
|
5412
5918
|
async function runFrontGuard(opts) {
|
|
@@ -5441,7 +5947,7 @@ async function runFrontGuard(opts) {
|
|
|
5441
5947
|
const bundle = await runBundle(opts.cwd, config, stack);
|
|
5442
5948
|
const prHygiene = runPrHygiene(config, pr);
|
|
5443
5949
|
const prSize = runPrSize(config, pr);
|
|
5444
|
-
|
|
5950
|
+
let results = [
|
|
5445
5951
|
eslint,
|
|
5446
5952
|
prettier,
|
|
5447
5953
|
typescript,
|
|
@@ -5457,6 +5963,12 @@ async function runFrontGuard(opts) {
|
|
|
5457
5963
|
prSize
|
|
5458
5964
|
];
|
|
5459
5965
|
applyAiAssistedEscalation(results, pr, config);
|
|
5966
|
+
results = await enrichFindingsWithOllamaFixes({
|
|
5967
|
+
cwd: opts.cwd,
|
|
5968
|
+
config,
|
|
5969
|
+
stack,
|
|
5970
|
+
results
|
|
5971
|
+
});
|
|
5460
5972
|
const manualAppendix = await loadManualAppendix({
|
|
5461
5973
|
cwd: opts.cwd,
|
|
5462
5974
|
filePath: opts.append ?? null
|
|
@@ -5471,8 +5983,16 @@ async function runFrontGuard(opts) {
|
|
|
5471
5983
|
const report = buildReport(stack, pr, results, {
|
|
5472
5984
|
mode,
|
|
5473
5985
|
llmAppendix,
|
|
5474
|
-
cwd: opts.cwd
|
|
5986
|
+
cwd: opts.cwd,
|
|
5987
|
+
emitHtml: Boolean(opts.htmlOut)
|
|
5475
5988
|
});
|
|
5989
|
+
if (opts.htmlOut && report.html) {
|
|
5990
|
+
await fs.writeFile(opts.htmlOut, report.html, "utf8");
|
|
5991
|
+
}
|
|
5992
|
+
if (opts.prCommentOut) {
|
|
5993
|
+
const snippet = formatBitbucketPrSnippet(report);
|
|
5994
|
+
await fs.writeFile(opts.prCommentOut, snippet, "utf8");
|
|
5995
|
+
}
|
|
5476
5996
|
if (opts.markdown) {
|
|
5477
5997
|
g.stdout.write(report.markdown + "\n");
|
|
5478
5998
|
} else {
|
|
@@ -5524,6 +6044,14 @@ var run = defineCommand({
|
|
|
5524
6044
|
append: {
|
|
5525
6045
|
type: "string",
|
|
5526
6046
|
description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
|
|
6047
|
+
},
|
|
6048
|
+
htmlOut: {
|
|
6049
|
+
type: "string",
|
|
6050
|
+
description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
|
|
6051
|
+
},
|
|
6052
|
+
prCommentOut: {
|
|
6053
|
+
type: "string",
|
|
6054
|
+
description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
|
|
5527
6055
|
}
|
|
5528
6056
|
},
|
|
5529
6057
|
run: async ({ args }) => {
|
|
@@ -5532,7 +6060,9 @@ var run = defineCommand({
|
|
|
5532
6060
|
ci: Boolean(args.ci),
|
|
5533
6061
|
markdown: Boolean(args.markdown),
|
|
5534
6062
|
enforce: Boolean(args.enforce),
|
|
5535
|
-
append: typeof args.append === "string" ? args.append : null
|
|
6063
|
+
append: typeof args.append === "string" ? args.append : null,
|
|
6064
|
+
htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
|
|
6065
|
+
prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
|
|
5536
6066
|
});
|
|
5537
6067
|
}
|
|
5538
6068
|
});
|