@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 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 needs a key in the *runner* (GitHub secret). IDE keys (Cursor Enterprise)
2465
- // // are not available in Actions \u2014 use paste workflow instead:
2466
- // // frontguard run --append ./.frontguard/review-notes.md
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: 'openai',
2471
- // apiKeyEnv: 'OPENAI_API_KEY',
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 path15 = process.env.GITHUB_EVENT_PATH;
2618
- if (!path15) return null;
2700
+ const path17 = process.env.GITHUB_EVENT_PATH;
2701
+ if (!path17) return null;
2619
2702
  try {
2620
- const payload = JSON.parse(await fs.readFile(path15, "utf8"));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
- return { riskScore, stack, pr, results, markdown, consoleText };
4879
- }
4880
- function formatStackOneLiner(s3) {
4881
- const bits = [];
4882
- if (s3.hasNext) bits.push("Next.js");
4883
- if (s3.hasReactNative) bits.push("React Native");
4884
- else if (s3.hasReact) bits.push("React");
4885
- if (s3.hasTypeScript) bits.push("TypeScript");
4886
- if (s3.tsStrict === true) bits.push("strict TS");
4887
- bits.push(`pkg: ${s3.packageManager}`);
4888
- return bits.join(" \xB7 ") || "unknown";
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 formatDuration(ms) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 accordionSummaryHtml(file, message) {
4964
- const msg = message.length > 160 ? `${message.slice(0, 157).trimEnd()}\u2026` : message;
4965
- const filePart = file ? `<code>${escapeHtml(file)}</code> \xB7 ` : "";
4966
- return filePart + escapeHtml(msg);
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 parseLineHint(detail) {
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 appendDetailFree(sb, cwd, detail) {
4990
- if (!detail?.trim()) return;
4991
- const d3 = detail.trim();
4992
- if (!d3.includes("\n") && d3.length <= 300) {
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(`_${d3}_`);
4995
- return;
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 = parseLineHint(a3.f.detail);
5022
- const lineB = parseLineHint(b3.f.detail);
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**. Expand nested sections for rule id, file, and tool output."
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
- const status = r4.skipped ? "\u23ED\uFE0F **Skipped**" : r4.findings.length === 0 ? "\u2705 **Clean**" : "\u26A0\uFE0F **Issues**";
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}${note} | **${nFind}** | ${formatDuration(r4.durationMs)} |`
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("<details open>");
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("<details>");
5107
- sb.push(
5108
- `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
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.push("");
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
- `<summary><strong>\u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"}</strong> \xB7 grouped by check</summary>`
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("<details>");
5152
- sb.push(
5153
- `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
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.push("");
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("<details>");
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("<details>");
5186
- sb.push(`<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`);
5534
+ sb.push("---");
5187
5535
  sb.push("");
5188
- sb.push(`- **Check:** \`${r4.checkId}\` \xB7 **id:** \`${f4.id}\``);
5189
- appendDetailFree(sb, cwd, d3.detail);
5536
+ sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
5190
5537
  sb.push("");
5191
- sb.push("</details>");
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("_Configure checks in `frontguard.config.js` \xB7 [Shields.io](https://shields.io) badges load at view time._");
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
- const apiKey = safeGetEnv(cfg.apiKeyEnv);
5252
- if (!apiKey) {
5253
- if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
5254
- return null;
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 GitHub PR context (local run).",
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
- const results = [
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
  });