@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 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,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 path15 = process.env.GITHUB_EVENT_PATH;
2618
- if (!path15) return null;
2690
+ const path16 = process.env.GITHUB_EVENT_PATH;
2691
+ if (!path16) return null;
2619
2692
  try {
2620
- const payload = JSON.parse(await fs.readFile(path15, "utf8"));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
- 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";
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 formatDuration(ms) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 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);
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 parseLineHint(detail) {
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 appendDetailFree(sb, cwd, detail) {
4990
- if (!detail?.trim()) return;
4991
- const d3 = detail.trim();
4992
- if (!d3.includes("\n") && d3.length <= 300) {
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(`_${d3}_`);
4995
- return;
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 = parseLineHint(a3.f.detail);
5022
- const lineB = parseLineHint(b3.f.detail);
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**. Expand nested sections for rule id, file, and tool output."
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
- const status = r4.skipped ? "\u23ED\uFE0F **Skipped**" : r4.findings.length === 0 ? "\u2705 **Clean**" : "\u26A0\uFE0F **Issues**";
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}${note} | **${nFind}** | ${formatDuration(r4.durationMs)} |`
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("<details open>");
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("<details>");
5107
- sb.push(
5108
- `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
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.push("");
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
- `<summary><strong>\u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"}</strong> \xB7 grouped by check</summary>`
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("<details>");
5152
- sb.push(
5153
- `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
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.push("");
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("<details>");
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("<details>");
5186
- sb.push(`<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`);
5524
+ sb.push("---");
5187
5525
  sb.push("");
5188
- sb.push(`- **Check:** \`${r4.checkId}\` \xB7 **id:** \`${f4.id}\``);
5189
- appendDetailFree(sb, cwd, d3.detail);
5526
+ sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
5190
5527
  sb.push("");
5191
- sb.push("</details>");
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("_Configure checks in `frontguard.config.js` \xB7 [Shields.io](https://shields.io) badges load at view time._");
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
- const apiKey = safeGetEnv(cfg.apiKeyEnv);
5252
- if (!apiKey) {
5253
- if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
5254
- return null;
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 GitHub PR context (local run).",
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
- const results = [
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
  });