@cleartrip/frontguard 0.1.4 → 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
@@ -4,7 +4,7 @@ import g, { stdin, stdout, cwd } from 'process';
4
4
  import f from 'readline';
5
5
  import * as tty from 'tty';
6
6
  import { WriteStream } from 'tty';
7
- import path, { sep, normalize, delimiter, resolve, dirname } from 'path';
7
+ import path4, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
8
  import fs from 'fs/promises';
9
9
  import { createRequire } from 'module';
10
10
  import fs4 from 'fs';
@@ -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
  `;
@@ -2497,19 +2500,19 @@ async function ensureDir(dir) {
2497
2500
  await fs.mkdir(dir, { recursive: true });
2498
2501
  }
2499
2502
  async function initFrontGuard(cwd) {
2500
- const gh = path.join(cwd, ".github", "workflows");
2503
+ const gh = path4.join(cwd, ".github", "workflows");
2501
2504
  await ensureDir(gh);
2502
- const wfPath = path.join(gh, "frontguard.yml");
2505
+ const wfPath = path4.join(gh, "frontguard.yml");
2503
2506
  await fs.writeFile(wfPath, WORKFLOW, "utf8");
2504
- const cfgPath = path.join(cwd, "frontguard.config.js");
2507
+ const cfgPath = path4.join(cwd, "frontguard.config.js");
2505
2508
  try {
2506
2509
  await fs.access(cfgPath);
2507
2510
  } catch {
2508
2511
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2509
2512
  }
2510
- const tplRoot = path.join(cwd, ".github");
2513
+ const tplRoot = path4.join(cwd, ".github");
2511
2514
  await ensureDir(tplRoot);
2512
- const tplPath = path.join(tplRoot, "pull_request_template.md");
2515
+ const tplPath = path4.join(tplRoot, "pull_request_template.md");
2513
2516
  try {
2514
2517
  await fs.access(tplPath);
2515
2518
  } catch {
@@ -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 path14 = process.env.GITHUB_EVENT_PATH;
2618
- if (!path14) 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(path14, "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
  };
@@ -2824,7 +2901,7 @@ function stripExtends(c4) {
2824
2901
  }
2825
2902
  async function loadExtendsLayer(cwd, spec) {
2826
2903
  if (!spec) return {};
2827
- const req = createRequire(path.join(cwd, "package.json"));
2904
+ const req = createRequire(path4.join(cwd, "package.json"));
2828
2905
  const specs = Array.isArray(spec) ? spec : [spec];
2829
2906
  let merged = {};
2830
2907
  for (const s3 of specs) {
@@ -2843,7 +2920,7 @@ async function loadExtendsLayer(cwd, spec) {
2843
2920
  async function loadConfig(cwd) {
2844
2921
  let userFile = null;
2845
2922
  for (const name of CONFIG_NAMES) {
2846
- const full = path.join(cwd, name);
2923
+ const full = path4.join(cwd, name);
2847
2924
  if (!fs4.existsSync(full)) continue;
2848
2925
  try {
2849
2926
  const mod = await importConfig(full);
@@ -2873,7 +2950,7 @@ function hasDep(deps, name) {
2873
2950
  async function detectStack(cwd) {
2874
2951
  let pkg = {};
2875
2952
  try {
2876
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
2953
+ const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
2877
2954
  pkg = JSON.parse(raw);
2878
2955
  } catch {
2879
2956
  return {
@@ -2893,7 +2970,7 @@ async function detectStack(cwd) {
2893
2970
  const isMonorepo = Boolean(pkg.workspaces);
2894
2971
  let tsStrict = null;
2895
2972
  try {
2896
- const tsconfigPath = path.join(cwd, "tsconfig.json");
2973
+ const tsconfigPath = path4.join(cwd, "tsconfig.json");
2897
2974
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2898
2975
  const ts = JSON.parse(tsRaw);
2899
2976
  if (typeof ts.compilerOptions?.strict === "boolean") {
@@ -2903,15 +2980,15 @@ async function detectStack(cwd) {
2903
2980
  }
2904
2981
  let pm = "unknown";
2905
2982
  try {
2906
- await fs.access(path.join(cwd, "pnpm-lock.yaml"));
2983
+ await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
2907
2984
  pm = "pnpm";
2908
2985
  } catch {
2909
2986
  try {
2910
- await fs.access(path.join(cwd, "yarn.lock"));
2987
+ await fs.access(path4.join(cwd, "yarn.lock"));
2911
2988
  pm = "yarn";
2912
2989
  } catch {
2913
2990
  try {
2914
- await fs.access(path.join(cwd, "package-lock.json"));
2991
+ await fs.access(path4.join(cwd, "package-lock.json"));
2915
2992
  pm = "npm";
2916
2993
  } catch {
2917
2994
  pm = "npm";
@@ -2931,6 +3008,61 @@ 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
+ }
3021
+ function stripFileUrl(p2) {
3022
+ let s3 = p2.trim();
3023
+ if (!/^file:/i.test(s3)) return s3;
3024
+ s3 = s3.replace(/^file:\/\//i, "");
3025
+ if (process.platform === "win32" && s3.startsWith("/")) {
3026
+ const m3 = s3.match(/^\/([A-Za-z]:\/.*)$/);
3027
+ if (m3) return m3[1];
3028
+ }
3029
+ return s3;
3030
+ }
3031
+ function isUnderDir(parent, child) {
3032
+ const rel = path4.relative(parent, child);
3033
+ return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
3034
+ }
3035
+ function toRepoRelativePath(cwd, filePath) {
3036
+ if (!filePath?.trim()) return void 0;
3037
+ const raw = stripFileUrl(filePath);
3038
+ const resolvedCwd = path4.resolve(cwd);
3039
+ const absFile = path4.isAbsolute(raw) ? path4.resolve(raw) : path4.resolve(resolvedCwd, raw);
3040
+ if (!isUnderDir(resolvedCwd, absFile)) {
3041
+ return raw.split(/[/\\]/g).join("/");
3042
+ }
3043
+ let rel = path4.relative(resolvedCwd, absFile);
3044
+ if (!rel || rel === ".") {
3045
+ return path4.basename(absFile);
3046
+ }
3047
+ return rel.split(path4.sep).join("/");
3048
+ }
3049
+ function stripRepoAbsolutePaths(cwd, text) {
3050
+ if (!text || !cwd.trim()) return text;
3051
+ const resolvedCwd = path4.resolve(cwd);
3052
+ const asPosix = (s3) => s3.replace(/\\/g, "/");
3053
+ const cwdPosix = asPosix(resolvedCwd);
3054
+ let out = asPosix(text);
3055
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
3056
+ (p2) => p2.length > 1
3057
+ );
3058
+ for (const prefix of prefixes) {
3059
+ const norm = prefix.replace(/\\/g, "/");
3060
+ if (out.includes(norm)) {
3061
+ out = out.split(norm).join("");
3062
+ }
3063
+ }
3064
+ return out;
3065
+ }
2934
3066
  var d2 = (e3, t3) => () => (t3 || e3((t3 = { exports: {} }).exports, t3), t3.exports);
2935
3067
  var f3 = /* @__PURE__ */ createRequire(import.meta.url);
2936
3068
  var p = /^path$/i;
@@ -3546,7 +3678,7 @@ async function pathExists(file) {
3546
3678
  }
3547
3679
  }
3548
3680
  async function resolveBin(cwd, name) {
3549
- const local = path.join(cwd, "node_modules", ".bin", name);
3681
+ const local = path4.join(cwd, "node_modules", ".bin", name);
3550
3682
  if (await pathExists(local)) return local;
3551
3683
  const win = local + ".cmd";
3552
3684
  if (await pathExists(win)) return win;
@@ -3602,7 +3734,7 @@ async function runNpx(cwd, args) {
3602
3734
  // src/checks/eslint.ts
3603
3735
  async function hasEslintDependency(cwd) {
3604
3736
  try {
3605
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
3737
+ const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
3606
3738
  const p2 = JSON.parse(raw);
3607
3739
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3608
3740
  } catch {
@@ -3621,7 +3753,7 @@ async function hasEslintConfig(cwd) {
3621
3753
  ".eslintrc.yml"
3622
3754
  ];
3623
3755
  for (const c4 of candidates) {
3624
- if (await pathExists(path.join(cwd, c4))) return true;
3756
+ if (await pathExists(path4.join(cwd, c4))) return true;
3625
3757
  }
3626
3758
  return false;
3627
3759
  }
@@ -3677,20 +3809,19 @@ async function runEslint(cwd, config, _stack) {
3677
3809
  }
3678
3810
  try {
3679
3811
  const rows = JSON.parse(stdout2);
3680
- let n3 = 0;
3681
3812
  for (const row of rows) {
3813
+ const relFile = toRepoRelativePath(cwd, row.filePath);
3682
3814
  for (const m3 of row.messages) {
3683
- if (n3++ >= 40) break;
3684
3815
  findings.push({
3685
3816
  id: `eslint-${m3.ruleId ?? "unknown"}`,
3686
3817
  severity: "warn",
3687
3818
  message: m3.message,
3688
- file: row.filePath,
3819
+ file: relFile,
3689
3820
  detail: m3.line ? `line ${m3.line}` : void 0
3690
3821
  });
3691
3822
  }
3692
3823
  }
3693
- if (n3 === 0) {
3824
+ if (findings.length === 0) {
3694
3825
  findings.push({
3695
3826
  id: "eslint-failed",
3696
3827
  severity: "warn",
@@ -3778,7 +3909,7 @@ async function runTypeScript(cwd, config, stack) {
3778
3909
  skipped: "disabled in config"
3779
3910
  };
3780
3911
  }
3781
- const hasTs = stack.hasTypeScript || await pathExists(path.join(cwd, "tsconfig.json"));
3912
+ const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
3782
3913
  if (!hasTs) {
3783
3914
  return {
3784
3915
  checkId: "typescript",
@@ -3891,7 +4022,7 @@ async function runSecrets(cwd, config, pr) {
3891
4022
  });
3892
4023
  break;
3893
4024
  }
3894
- const full = path.join(cwd, rel);
4025
+ const full = path4.join(cwd, rel);
3895
4026
  let content;
3896
4027
  try {
3897
4028
  content = await fs.readFile(full, "utf8");
@@ -3917,7 +4048,7 @@ async function runSecrets(cwd, config, pr) {
3917
4048
  };
3918
4049
  }
3919
4050
  function isProbablyTextFile(rel) {
3920
- const ext = path.extname(rel).toLowerCase();
4051
+ const ext = path4.extname(rel).toLowerCase();
3921
4052
  return TEXT_EXT.has(ext);
3922
4053
  }
3923
4054
 
@@ -4210,12 +4341,12 @@ async function runCycles(cwd, config, stack) {
4210
4341
  }
4211
4342
  let entry = cfg.entries[0] ?? "src";
4212
4343
  for (const e3 of cfg.entries) {
4213
- if (await pathExists(path.join(cwd, e3))) {
4344
+ if (await pathExists(path4.join(cwd, e3))) {
4214
4345
  entry = e3;
4215
4346
  break;
4216
4347
  }
4217
4348
  }
4218
- if (!await pathExists(path.join(cwd, entry))) {
4349
+ if (!await pathExists(path4.join(cwd, entry))) {
4219
4350
  return {
4220
4351
  checkId: "cycles",
4221
4352
  findings: [],
@@ -4339,7 +4470,7 @@ async function sumGlobBytes(cwd, patterns) {
4339
4470
  });
4340
4471
  for (const rel of files) {
4341
4472
  try {
4342
- const st = await fs.stat(path.join(cwd, rel));
4473
+ const st = await fs.stat(path4.join(cwd, rel));
4343
4474
  total += st.size;
4344
4475
  } catch {
4345
4476
  }
@@ -4348,7 +4479,7 @@ async function sumGlobBytes(cwd, patterns) {
4348
4479
  return total;
4349
4480
  }
4350
4481
  async function readBaseline(cwd, relPath, baseRef) {
4351
- const disk = path.join(cwd, relPath);
4482
+ const disk = path4.join(cwd, relPath);
4352
4483
  try {
4353
4484
  const raw = await fs.readFile(disk, "utf8");
4354
4485
  return JSON.parse(raw);
@@ -4523,7 +4654,7 @@ async function runCwv(cwd, config, stack, pr) {
4523
4654
  const findings = [];
4524
4655
  const sev2 = gateSeverity5(cfg.gate);
4525
4656
  for (const rel of toScan.slice(0, 400)) {
4526
- const full = path.join(cwd, rel);
4657
+ const full = path4.join(cwd, rel);
4527
4658
  let text;
4528
4659
  try {
4529
4660
  text = await fs.readFile(full, "utf8");
@@ -4608,7 +4739,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4608
4739
  });
4609
4740
  break;
4610
4741
  }
4611
- const full = path.join(cwd, rel);
4742
+ const full = path4.join(cwd, rel);
4612
4743
  let content;
4613
4744
  try {
4614
4745
  content = await fs.readFile(full, "utf8");
@@ -4739,7 +4870,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4739
4870
  const gate = cfg.gate;
4740
4871
  const findings = [];
4741
4872
  for (const rel of files) {
4742
- const full = path.join(cwd, rel);
4873
+ const full = path4.join(cwd, rel);
4743
4874
  let content;
4744
4875
  try {
4745
4876
  content = await fs.readFile(full, "utf8");
@@ -4797,8 +4928,268 @@ function applyAiAssistedEscalation(results, pr, config) {
4797
4928
 
4798
4929
  // src/report/builder.ts
4799
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
4800
5190
  function buildReport(stack, pr, results, options) {
4801
5191
  const mode = options?.mode ?? "warn";
5192
+ const cwd = options?.cwd ?? process.cwd();
4802
5193
  const allFindings = results.flatMap(
4803
5194
  (r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
4804
5195
  );
@@ -4808,6 +5199,7 @@ function buildReport(stack, pr, results, options) {
4808
5199
  const lines = pr != null ? pr.additions + pr.deletions : null;
4809
5200
  const riskScore = scoreRisk(blocks, warns, lines, pr?.changedFiles ?? 0);
4810
5201
  const markdown = formatMarkdown({
5202
+ cwd,
4811
5203
  riskScore,
4812
5204
  mode,
4813
5205
  stack,
@@ -4829,17 +5221,20 @@ function buildReport(stack, pr, results, options) {
4829
5221
  infos,
4830
5222
  blocks
4831
5223
  });
4832
- return { riskScore, stack, pr, results, markdown, consoleText };
4833
- }
4834
- function formatStackOneLiner(s3) {
4835
- const bits = [];
4836
- if (s3.hasNext) bits.push("Next.js");
4837
- if (s3.hasReactNative) bits.push("React Native");
4838
- else if (s3.hasReact) bits.push("React");
4839
- if (s3.hasTypeScript) bits.push("TypeScript");
4840
- if (s3.tsStrict === true) bits.push("strict TS");
4841
- bits.push(`pkg: ${s3.packageManager}`);
4842
- 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 };
4843
5238
  }
4844
5239
  function scoreRisk(blocks, warns, lines, files) {
4845
5240
  let score = 0;
@@ -4855,8 +5250,104 @@ function scoreRisk(blocks, warns, lines, files) {
4855
5250
  if (score >= 2) return "MEDIUM";
4856
5251
  return "LOW";
4857
5252
  }
5253
+ function shieldImageUrl(label, message, color) {
5254
+ const q2 = new URLSearchParams({
5255
+ label,
5256
+ message,
5257
+ color,
5258
+ style: "for-the-badge"
5259
+ });
5260
+ return `https://img.shields.io/static/v1?${q2}`;
5261
+ }
5262
+ function mdShield(alt, label, message, color) {
5263
+ return `![${alt}](${shieldImageUrl(label, message, color)})`;
5264
+ }
5265
+ function riskEmoji(risk) {
5266
+ if (risk === "LOW") return "\u{1F7E2}";
5267
+ if (risk === "MEDIUM") return "\u{1F7E0}";
5268
+ return "\u{1F534}";
5269
+ }
5270
+ function riskShieldColor(risk) {
5271
+ if (risk === "LOW") return "brightgreen";
5272
+ if (risk === "MEDIUM") return "orange";
5273
+ return "red";
5274
+ }
5275
+ function modeShieldColor(mode) {
5276
+ return mode === "enforce" ? "critical" : "blue";
5277
+ }
5278
+ function countShieldColor(kind, n3) {
5279
+ if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
5280
+ if (kind === "info") return n3 === 0 ? "inactive" : "informational";
5281
+ if (n3 === 0) return "brightgreen";
5282
+ if (n3 <= 10) return "yellow";
5283
+ return "orange";
5284
+ }
5285
+ function formatDuration2(ms) {
5286
+ if (ms < 1e3) return `${ms} ms`;
5287
+ const s3 = Math.round(ms / 1e3);
5288
+ if (s3 < 60) return `${s3}s`;
5289
+ const m3 = Math.floor(s3 / 60);
5290
+ const r4 = s3 % 60;
5291
+ return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5292
+ }
5293
+ function healthEmojiForCheck(r4) {
5294
+ if (r4.skipped) return "\u23ED\uFE0F";
5295
+ if (r4.findings.length === 0) return "\u{1F7E2}";
5296
+ const hasBlock = r4.findings.some((f4) => f4.severity === "block");
5297
+ if (hasBlock) return "\u{1F534}";
5298
+ return "\u{1F7E1}";
5299
+ }
5300
+ function normalizeFindingDisplay(cwd, f4) {
5301
+ const file = toRepoRelativePath(cwd, f4.file);
5302
+ const message = stripRepoAbsolutePaths(cwd, f4.message);
5303
+ const detail = f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0;
5304
+ return { file, message, detail };
5305
+ }
5306
+ function sanitizeFence(cwd, s3) {
5307
+ return stripRepoAbsolutePaths(cwd, s3).replace(/\r\n/g, "\n").replace(/```/g, "`\u200B``");
5308
+ }
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}`;
5313
+ }
5314
+ function parseLineHint2(detail) {
5315
+ if (!detail) return 0;
5316
+ const m3 = /^line\s+(\d+)/i.exec(detail.trim());
5317
+ return m3 ? Number(m3[1]) : 0;
5318
+ }
5319
+ function appendDetailAfterTable(sb, cwd, detail) {
5320
+ if (!detail?.trim()) return;
5321
+ const d3 = detail.trim();
5322
+ const oneLine = !d3.includes("\n");
5323
+ const safeForTable = oneLine && d3.length <= 200 && !d3.includes("|") && !d3.includes("`");
5324
+ if (safeForTable) {
5325
+ sb.push(`| **Hint** | ${d3} |`);
5326
+ return;
5327
+ }
5328
+ sb.push("");
5329
+ sb.push("*Detail*");
5330
+ sb.push("");
5331
+ sb.push("```text");
5332
+ sb.push(sanitizeFence(cwd, d3));
5333
+ sb.push("```");
5334
+ }
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) {
5342
+ sb.push("");
5343
+ sb.push("```text");
5344
+ sb.push(sanitizeFence(cwd, f4.suggestedFix.code));
5345
+ sb.push("```");
5346
+ }
5347
+ }
4858
5348
  function formatMarkdown(p2) {
4859
5349
  const {
5350
+ cwd,
4860
5351
  riskScore,
4861
5352
  mode,
4862
5353
  stack,
@@ -4869,99 +5360,202 @@ function formatMarkdown(p2) {
4869
5360
  llmAppendix
4870
5361
  } = p2;
4871
5362
  const sb = [];
4872
- sb.push("## FrontGuard review brief");
5363
+ const sortWithCwd = (items) => [...items].sort((a3, b3) => {
5364
+ const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
5365
+ const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
5366
+ if (af !== bf) return af.localeCompare(bf);
5367
+ const lineA = parseLineHint2(a3.f.detail);
5368
+ const lineB = parseLineHint2(b3.f.detail);
5369
+ if (lineA !== lineB) return lineA - lineB;
5370
+ return a3.f.message.localeCompare(b3.f.message);
5371
+ });
5372
+ sb.push("## \u2728 FrontGuard review brief");
4873
5373
  sb.push("");
5374
+ const modeLabel = mode === "enforce" ? "enforce" : "warn only";
5375
+ const badgeLine = [
5376
+ mdShield("Risk level", "risk", riskScore, riskShieldColor(riskScore)),
5377
+ mdShield("CI mode", "mode", modeLabel, modeShieldColor(mode)),
5378
+ mdShield("Blocking", "blocking", String(blocks), countShieldColor("block", blocks)),
5379
+ mdShield("Warnings", "warnings", String(warns), countShieldColor("warn", warns)),
5380
+ mdShield("Info", "info notes", String(infos), countShieldColor("info", infos))
5381
+ ].join(" ");
5382
+ sb.push(badgeLine);
5383
+ sb.push("");
5384
+ sb.push("---");
5385
+ sb.push("");
5386
+ sb.push("### \u{1F4CC} Snapshot");
5387
+ sb.push("");
5388
+ sb.push("| | |");
5389
+ sb.push("|:--|:--|");
5390
+ sb.push(`| **Composite risk** | ${riskEmoji(riskScore)} **${riskScore}** \u2014 heuristic from blocks, warnings, and PR size. |`);
4874
5391
  sb.push(
4875
- `**Risk:** ${riskScore} \xB7 **Mode:** ${mode === "enforce" ? "enforce (CI may fail on `block`)" : "warn-only"}`
5392
+ `| **Gate mode** | ${mode === "enforce" ? "\u{1F512} **Enforce** \u2014 CI fails when a `block` finding is present." : "\u{1F6C8} **Warn only** \u2014 findings are advisory unless you use `--enforce`."} |`
4876
5393
  );
4877
- if (pr?.aiAssisted) {
4878
- sb.push("");
5394
+ sb.push(`| **Stack detected** | ${formatStackOneLiner(stack)} |`);
5395
+ if (pr && lines != null) {
4879
5396
  sb.push(
4880
- "**AI-assisted PR:** Stricter static pass is active (security / footgun patterns on changed files; secrets & `any` deltas may be escalated). **Human review** still required for correctness and product rules."
5397
+ `| **PR size** | \u{1F4CF} **${lines}** LOC ( +${pr.additions} / \u2212${pr.deletions} ) \xB7 **${pr.changedFiles}** files |`
4881
5398
  );
4882
5399
  }
4883
5400
  sb.push("");
4884
- sb.push(`**Stack:** ${formatStackOneLiner(stack)}`);
4885
- if (pr && lines != null) {
5401
+ if (pr?.aiAssisted) {
4886
5402
  sb.push(
4887
- `**Size:** ${lines} lines (+${pr.additions} / -${pr.deletions}) across ${pr.changedFiles} files`
5403
+ "> **\u{1F916} AI-assisted PR** \u2014 Stricter static checks run on changed files (security / footguns; secrets & `any` deltas may escalate). This does not replace human review for behavior or product rules."
4888
5404
  );
5405
+ sb.push("");
4889
5406
  }
4890
- sb.push("");
5407
+ sb.push("> **How to read this report**");
5408
+ sb.push(">");
5409
+ sb.push("> | Symbol | Meaning |");
5410
+ sb.push("> |:--|:--|");
5411
+ sb.push("> | \u{1F7E2} | Check passed or skipped cleanly |");
5412
+ sb.push("> | \u{1F7E1} | Warnings only \u2014 review recommended |");
5413
+ sb.push("> | \u{1F534} | Blocking (`block`) severity present |");
5414
+ sb.push("> | \u23ED\uFE0F | Check skipped (see reason in table) |");
5415
+ sb.push(">");
4891
5416
  sb.push(
4892
- blocks > 0 ? `**Blocking (\`block\`) findings:** ${blocks}` : "**Blocking (`block`) findings:** 0"
5417
+ "> Paths in findings are **relative to the repo root**. Each issue below has a small field table and optional detail."
4893
5418
  );
4894
- sb.push(`**Warnings:** ${warns} \xB7 **Info:** ${infos}`);
4895
5419
  sb.push("");
4896
- sb.push("### Check summary");
5420
+ sb.push("---");
5421
+ sb.push("");
5422
+ sb.push("### \u{1F4CB} Check results");
5423
+ sb.push("");
5424
+ sb.push("| | Check | Status | Findings | Duration |");
5425
+ sb.push("|:--:|:--|:--|:-:|--:|");
4897
5426
  for (const r4 of results) {
4898
- const status = r4.skipped ? `\u23ED\uFE0F skipped (${r4.skipped})` : r4.findings.length === 0 ? "\u2705 clean" : `\u26A0\uFE0F ${r4.findings.length} finding(s)`;
4899
- sb.push(`- **${r4.checkId}** \u2014 ${status} (${r4.durationMs}ms)`);
5427
+ const he2 = healthEmojiForCheck(r4);
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
+ }
5438
+ const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
5439
+ sb.push(
5440
+ `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
5441
+ );
4900
5442
  }
4901
5443
  sb.push("");
4902
- const blockFindings = results.flatMap(
4903
- (r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
5444
+ const blockFindings = sortWithCwd(
5445
+ results.flatMap(
5446
+ (r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
5447
+ )
4904
5448
  );
4905
- if (blockFindings.length) {
4906
- sb.push("### Blocking");
4907
- for (const { f: f4 } of blockFindings.slice(0, 40)) {
4908
- const loc = f4.file ? ` \`${f4.file}\`` : "";
4909
- sb.push(`- ${f4.message}${loc}`);
4910
- if (f4.detail) {
4911
- sb.push(
4912
- ` - <details><summary>detail</summary>
4913
-
4914
- \`\`\`text
4915
- ${f4.detail}
4916
- \`\`\`
4917
-
4918
- </details>`
4919
- );
4920
- }
5449
+ sb.push(`### \u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}`);
5450
+ sb.push("");
5451
+ if (blockFindings.length === 0) {
5452
+ sb.push("*\u2705 No blocking findings \u2014 nothing mapped to severity `block`.*");
5453
+ } else {
5454
+ for (const { r: r4, f: f4 } of blockFindings) {
5455
+ const d3 = normalizeFindingDisplay(cwd, f4);
5456
+ sb.push("---");
5457
+ sb.push("");
5458
+ sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
5459
+ sb.push("");
5460
+ sb.push(`| Field | Value |`);
5461
+ sb.push(`|:--|:--|`);
5462
+ sb.push(`| **Check** | \`${r4.checkId}\` |`);
5463
+ sb.push(`| **Rule / id** | \`${f4.id}\` |`);
5464
+ if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
5465
+ appendDetailAfterTable(sb, cwd, d3.detail);
5466
+ appendSuggestedFix(sb, cwd, f4);
5467
+ sb.push("");
4921
5468
  }
4922
- sb.push("");
4923
5469
  }
4924
- const warnFindings = results.flatMap(
4925
- (r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
5470
+ sb.push("");
5471
+ const warnFindings = sortWithCwd(
5472
+ results.flatMap(
5473
+ (r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
5474
+ )
4926
5475
  );
4927
- if (warnFindings.length) {
4928
- sb.push("### Warnings");
4929
- for (const { f: f4 } of warnFindings.slice(0, 30)) {
4930
- const loc = f4.file ? ` \`${f4.file}\`` : "";
4931
- sb.push(`- ${f4.message}${loc}`);
4932
- if (f4.detail) {
4933
- sb.push(
4934
- ` - <details><summary>detail</summary>
4935
-
4936
- \`\`\`text
4937
- ${f4.detail}
4938
- \`\`\`
4939
-
4940
- </details>`
4941
- );
5476
+ sb.push(
5477
+ `### \u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"} (by check)`
5478
+ );
5479
+ sb.push("");
5480
+ if (warnFindings.length === 0) {
5481
+ sb.push("*\u{1F389} No warnings \u2014 nice work.*");
5482
+ } else {
5483
+ const byCheck = /* @__PURE__ */ new Map();
5484
+ for (const item of warnFindings) {
5485
+ const list = byCheck.get(item.r.checkId) ?? [];
5486
+ list.push(item);
5487
+ byCheck.set(item.r.checkId, list);
5488
+ }
5489
+ const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
5490
+ for (const checkId of checkOrder) {
5491
+ const group = sortWithCwd(byCheck.get(checkId));
5492
+ sb.push(`#### \u{1F4C2} \`${checkId}\` \xB7 ${group.length} finding${group.length === 1 ? "" : "s"}`);
5493
+ sb.push("");
5494
+ for (const { r: r4, f: f4 } of group) {
5495
+ const d3 = normalizeFindingDisplay(cwd, f4);
5496
+ sb.push("---");
5497
+ sb.push("");
5498
+ sb.push(`##### ${findingTitleLine(d3.file, d3.message)}`);
5499
+ sb.push("");
5500
+ sb.push(`| Field | Value |`);
5501
+ sb.push(`|:--|:--|`);
5502
+ sb.push(`| **Check** | \`${r4.checkId}\` |`);
5503
+ sb.push(`| **Rule / id** | \`${f4.id}\` |`);
5504
+ if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
5505
+ appendDetailAfterTable(sb, cwd, d3.detail);
5506
+ appendSuggestedFix(sb, cwd, f4);
5507
+ sb.push("");
4942
5508
  }
4943
5509
  }
4944
- if (warnFindings.length > 30) {
4945
- sb.push(`- _\u2026and ${warnFindings.length - 30} more_`);
4946
- }
4947
- sb.push("");
4948
5510
  }
4949
- const infoFindings = results.flatMap(
4950
- (r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
5511
+ sb.push("");
5512
+ const infoFindings = sortWithCwd(
5513
+ results.flatMap(
5514
+ (r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
5515
+ )
4951
5516
  );
4952
- if (infoFindings.length) {
4953
- sb.push("### Notes");
4954
- for (const { f: f4 } of infoFindings.slice(0, 20)) {
4955
- sb.push(`- ${f4.message}`);
5517
+ sb.push(`### \u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}`);
5518
+ sb.push("");
5519
+ if (infoFindings.length === 0) {
5520
+ sb.push("*No info-level notes.*");
5521
+ } else {
5522
+ for (const { r: r4, f: f4 } of infoFindings) {
5523
+ const d3 = normalizeFindingDisplay(cwd, f4);
5524
+ sb.push("---");
5525
+ sb.push("");
5526
+ sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
5527
+ 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);
5535
+ sb.push("");
4956
5536
  }
4957
- sb.push("");
4958
5537
  }
5538
+ sb.push("");
4959
5539
  if (llmAppendix?.trim()) {
5540
+ sb.push("### \u{1F916} AI / manual appendix");
5541
+ sb.push("");
4960
5542
  sb.push(llmAppendix.trim());
4961
5543
  sb.push("");
4962
5544
  }
4963
5545
  sb.push("---");
4964
- sb.push("_Generated by FrontGuard \u2014 configure with `frontguard.config.js`_");
5546
+ sb.push("");
5547
+ sb.push(
5548
+ mdShield(
5549
+ "Generated by",
5550
+ "report",
5551
+ "FrontGuard",
5552
+ "blueviolet"
5553
+ )
5554
+ );
5555
+ 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
+ );
4965
5559
  return sb.join("\n");
4966
5560
  }
4967
5561
  function formatConsole(p2) {
@@ -4988,6 +5582,185 @@ function formatConsole(p2) {
4988
5582
  return lines.join("\n");
4989
5583
  }
4990
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
+
4991
5764
  // src/llm/review.ts
4992
5765
  function safeGetEnv(name) {
4993
5766
  const v3 = process.env[name];
@@ -4997,21 +5770,23 @@ async function runLlmReview(opts) {
4997
5770
  const { cwd, config, pr, results } = opts;
4998
5771
  const cfg = config.checks.llm;
4999
5772
  if (!cfg.enabled) return null;
5000
- const apiKey = safeGetEnv(cfg.apiKeyEnv);
5001
- if (!apiKey) {
5002
- if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
5003
- 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");
5004
5789
  }
5005
- return [
5006
- "### AI review (automated CI)",
5007
- "",
5008
- "_No API key in this environment._ IDE credentials do not reach GitHub Actions.",
5009
- "",
5010
- "**Options:**",
5011
- "1. **Manual** \u2014 Paste notes from Cursor/ChatGPT/Claude into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
5012
- `2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
5013
- "3. **Docs in PR** \u2014 Rely on the PR template \u201CAI assistance\u201D section for reviewer context."
5014
- ].join("\n");
5015
5790
  }
5016
5791
  if (!await gitOk(cwd)) {
5017
5792
  return "_LLM review skipped: not a git repository_";
@@ -5035,7 +5810,7 @@ async function runLlmReview(opts) {
5035
5810
  "",
5036
5811
  pr ? `PR title: ${pr.title}
5037
5812
  PR body excerpt:
5038
- ${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).",
5039
5814
  "",
5040
5815
  "Existing automated findings (may be incomplete):",
5041
5816
  summaryLines || "(none)",
@@ -5045,6 +5820,23 @@ ${pr.body.slice(0, 2e3)}` : "No GitHub PR context (local run).",
5045
5820
  diff,
5046
5821
  "```"
5047
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);
5048
5840
  const controller = new AbortController();
5049
5841
  const t3 = setTimeout(() => controller.abort(), cfg.timeoutMs);
5050
5842
  try {
@@ -5121,41 +5913,6 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
5121
5913
  if (!text) throw new Error("Anthropic returned empty content");
5122
5914
  return text;
5123
5915
  }
5124
- var MAX_CHARS = 2e5;
5125
- async function loadManualAppendix(opts) {
5126
- const { cwd, filePath } = opts;
5127
- const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
5128
- const resolvedPath = filePath?.trim() || envFile;
5129
- if (resolvedPath) {
5130
- const abs = path.isAbsolute(resolvedPath) ? resolvedPath : path.join(cwd, resolvedPath);
5131
- try {
5132
- let text = await fs.readFile(abs, "utf8");
5133
- if (text.length > MAX_CHARS) {
5134
- text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
5135
- }
5136
- const t3 = text.trim();
5137
- if (t3) {
5138
- return `### Contributed review notes
5139
-
5140
- _Pasted or file-based (no CI API key)._
5141
-
5142
- ${t3}`;
5143
- }
5144
- } catch {
5145
- }
5146
- }
5147
- const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
5148
- if (inline) {
5149
- let text = inline;
5150
- if (text.length > MAX_CHARS) {
5151
- text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
5152
- }
5153
- return `### Contributed review notes
5154
-
5155
- ${text.trim()}`;
5156
- }
5157
- return null;
5158
- }
5159
5916
 
5160
5917
  // src/commands/run.ts
5161
5918
  async function runFrontGuard(opts) {
@@ -5190,7 +5947,7 @@ async function runFrontGuard(opts) {
5190
5947
  const bundle = await runBundle(opts.cwd, config, stack);
5191
5948
  const prHygiene = runPrHygiene(config, pr);
5192
5949
  const prSize = runPrSize(config, pr);
5193
- const results = [
5950
+ let results = [
5194
5951
  eslint,
5195
5952
  prettier,
5196
5953
  typescript,
@@ -5206,6 +5963,12 @@ async function runFrontGuard(opts) {
5206
5963
  prSize
5207
5964
  ];
5208
5965
  applyAiAssistedEscalation(results, pr, config);
5966
+ results = await enrichFindingsWithOllamaFixes({
5967
+ cwd: opts.cwd,
5968
+ config,
5969
+ stack,
5970
+ results
5971
+ });
5209
5972
  const manualAppendix = await loadManualAppendix({
5210
5973
  cwd: opts.cwd,
5211
5974
  filePath: opts.append ?? null
@@ -5217,7 +5980,19 @@ async function runFrontGuard(opts) {
5217
5980
  results
5218
5981
  });
5219
5982
  const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
5220
- const report = buildReport(stack, pr, results, { mode, llmAppendix });
5983
+ const report = buildReport(stack, pr, results, {
5984
+ mode,
5985
+ llmAppendix,
5986
+ cwd: opts.cwd,
5987
+ emitHtml: Boolean(opts.htmlOut)
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
+ }
5221
5996
  if (opts.markdown) {
5222
5997
  g.stdout.write(report.markdown + "\n");
5223
5998
  } else {
@@ -5269,6 +6044,14 @@ var run = defineCommand({
5269
6044
  append: {
5270
6045
  type: "string",
5271
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)"
5272
6055
  }
5273
6056
  },
5274
6057
  run: async ({ args }) => {
@@ -5277,7 +6060,9 @@ var run = defineCommand({
5277
6060
  ci: Boolean(args.ci),
5278
6061
  markdown: Boolean(args.markdown),
5279
6062
  enforce: Boolean(args.enforce),
5280
- 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
5281
6066
  });
5282
6067
  }
5283
6068
  });