@cleartrip/frontguard 0.1.4 → 0.1.6

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';
@@ -2497,19 +2497,19 @@ async function ensureDir(dir) {
2497
2497
  await fs.mkdir(dir, { recursive: true });
2498
2498
  }
2499
2499
  async function initFrontGuard(cwd) {
2500
- const gh = path.join(cwd, ".github", "workflows");
2500
+ const gh = path4.join(cwd, ".github", "workflows");
2501
2501
  await ensureDir(gh);
2502
- const wfPath = path.join(gh, "frontguard.yml");
2502
+ const wfPath = path4.join(gh, "frontguard.yml");
2503
2503
  await fs.writeFile(wfPath, WORKFLOW, "utf8");
2504
- const cfgPath = path.join(cwd, "frontguard.config.js");
2504
+ const cfgPath = path4.join(cwd, "frontguard.config.js");
2505
2505
  try {
2506
2506
  await fs.access(cfgPath);
2507
2507
  } catch {
2508
2508
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2509
2509
  }
2510
- const tplRoot = path.join(cwd, ".github");
2510
+ const tplRoot = path4.join(cwd, ".github");
2511
2511
  await ensureDir(tplRoot);
2512
- const tplPath = path.join(tplRoot, "pull_request_template.md");
2512
+ const tplPath = path4.join(tplRoot, "pull_request_template.md");
2513
2513
  try {
2514
2514
  await fs.access(tplPath);
2515
2515
  } catch {
@@ -2614,10 +2614,10 @@ async function resolvePrNumber() {
2614
2614
  const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
2615
2615
  const n3 = Number(raw);
2616
2616
  if (Number.isFinite(n3) && n3 > 0) return n3;
2617
- const path14 = process.env.GITHUB_EVENT_PATH;
2618
- if (!path14) return null;
2617
+ const path15 = process.env.GITHUB_EVENT_PATH;
2618
+ if (!path15) return null;
2619
2619
  try {
2620
- const payload = JSON.parse(await fs.readFile(path14, "utf8"));
2620
+ const payload = JSON.parse(await fs.readFile(path15, "utf8"));
2621
2621
  const num = payload.pull_request?.number;
2622
2622
  return typeof num === "number" && num > 0 ? num : null;
2623
2623
  } catch {
@@ -2824,7 +2824,7 @@ function stripExtends(c4) {
2824
2824
  }
2825
2825
  async function loadExtendsLayer(cwd, spec) {
2826
2826
  if (!spec) return {};
2827
- const req = createRequire(path.join(cwd, "package.json"));
2827
+ const req = createRequire(path4.join(cwd, "package.json"));
2828
2828
  const specs = Array.isArray(spec) ? spec : [spec];
2829
2829
  let merged = {};
2830
2830
  for (const s3 of specs) {
@@ -2843,7 +2843,7 @@ async function loadExtendsLayer(cwd, spec) {
2843
2843
  async function loadConfig(cwd) {
2844
2844
  let userFile = null;
2845
2845
  for (const name of CONFIG_NAMES) {
2846
- const full = path.join(cwd, name);
2846
+ const full = path4.join(cwd, name);
2847
2847
  if (!fs4.existsSync(full)) continue;
2848
2848
  try {
2849
2849
  const mod = await importConfig(full);
@@ -2873,7 +2873,7 @@ function hasDep(deps, name) {
2873
2873
  async function detectStack(cwd) {
2874
2874
  let pkg = {};
2875
2875
  try {
2876
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
2876
+ const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
2877
2877
  pkg = JSON.parse(raw);
2878
2878
  } catch {
2879
2879
  return {
@@ -2893,7 +2893,7 @@ async function detectStack(cwd) {
2893
2893
  const isMonorepo = Boolean(pkg.workspaces);
2894
2894
  let tsStrict = null;
2895
2895
  try {
2896
- const tsconfigPath = path.join(cwd, "tsconfig.json");
2896
+ const tsconfigPath = path4.join(cwd, "tsconfig.json");
2897
2897
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2898
2898
  const ts = JSON.parse(tsRaw);
2899
2899
  if (typeof ts.compilerOptions?.strict === "boolean") {
@@ -2903,15 +2903,15 @@ async function detectStack(cwd) {
2903
2903
  }
2904
2904
  let pm = "unknown";
2905
2905
  try {
2906
- await fs.access(path.join(cwd, "pnpm-lock.yaml"));
2906
+ await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
2907
2907
  pm = "pnpm";
2908
2908
  } catch {
2909
2909
  try {
2910
- await fs.access(path.join(cwd, "yarn.lock"));
2910
+ await fs.access(path4.join(cwd, "yarn.lock"));
2911
2911
  pm = "yarn";
2912
2912
  } catch {
2913
2913
  try {
2914
- await fs.access(path.join(cwd, "package-lock.json"));
2914
+ await fs.access(path4.join(cwd, "package-lock.json"));
2915
2915
  pm = "npm";
2916
2916
  } catch {
2917
2917
  pm = "npm";
@@ -2931,6 +2931,51 @@ async function detectStack(cwd) {
2931
2931
  tsStrict
2932
2932
  };
2933
2933
  }
2934
+ function stripFileUrl(p2) {
2935
+ let s3 = p2.trim();
2936
+ if (!/^file:/i.test(s3)) return s3;
2937
+ s3 = s3.replace(/^file:\/\//i, "");
2938
+ if (process.platform === "win32" && s3.startsWith("/")) {
2939
+ const m3 = s3.match(/^\/([A-Za-z]:\/.*)$/);
2940
+ if (m3) return m3[1];
2941
+ }
2942
+ return s3;
2943
+ }
2944
+ function isUnderDir(parent, child) {
2945
+ const rel = path4.relative(parent, child);
2946
+ return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
2947
+ }
2948
+ function toRepoRelativePath(cwd, filePath) {
2949
+ if (!filePath?.trim()) return void 0;
2950
+ const raw = stripFileUrl(filePath);
2951
+ const resolvedCwd = path4.resolve(cwd);
2952
+ const absFile = path4.isAbsolute(raw) ? path4.resolve(raw) : path4.resolve(resolvedCwd, raw);
2953
+ if (!isUnderDir(resolvedCwd, absFile)) {
2954
+ return raw.split(/[/\\]/g).join("/");
2955
+ }
2956
+ let rel = path4.relative(resolvedCwd, absFile);
2957
+ if (!rel || rel === ".") {
2958
+ return path4.basename(absFile);
2959
+ }
2960
+ return rel.split(path4.sep).join("/");
2961
+ }
2962
+ function stripRepoAbsolutePaths(cwd, text) {
2963
+ if (!text || !cwd.trim()) return text;
2964
+ const resolvedCwd = path4.resolve(cwd);
2965
+ const asPosix = (s3) => s3.replace(/\\/g, "/");
2966
+ const cwdPosix = asPosix(resolvedCwd);
2967
+ let out = asPosix(text);
2968
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
2969
+ (p2) => p2.length > 1
2970
+ );
2971
+ for (const prefix of prefixes) {
2972
+ const norm = prefix.replace(/\\/g, "/");
2973
+ if (out.includes(norm)) {
2974
+ out = out.split(norm).join("");
2975
+ }
2976
+ }
2977
+ return out;
2978
+ }
2934
2979
  var d2 = (e3, t3) => () => (t3 || e3((t3 = { exports: {} }).exports, t3), t3.exports);
2935
2980
  var f3 = /* @__PURE__ */ createRequire(import.meta.url);
2936
2981
  var p = /^path$/i;
@@ -3546,7 +3591,7 @@ async function pathExists(file) {
3546
3591
  }
3547
3592
  }
3548
3593
  async function resolveBin(cwd, name) {
3549
- const local = path.join(cwd, "node_modules", ".bin", name);
3594
+ const local = path4.join(cwd, "node_modules", ".bin", name);
3550
3595
  if (await pathExists(local)) return local;
3551
3596
  const win = local + ".cmd";
3552
3597
  if (await pathExists(win)) return win;
@@ -3602,7 +3647,7 @@ async function runNpx(cwd, args) {
3602
3647
  // src/checks/eslint.ts
3603
3648
  async function hasEslintDependency(cwd) {
3604
3649
  try {
3605
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
3650
+ const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
3606
3651
  const p2 = JSON.parse(raw);
3607
3652
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3608
3653
  } catch {
@@ -3621,7 +3666,7 @@ async function hasEslintConfig(cwd) {
3621
3666
  ".eslintrc.yml"
3622
3667
  ];
3623
3668
  for (const c4 of candidates) {
3624
- if (await pathExists(path.join(cwd, c4))) return true;
3669
+ if (await pathExists(path4.join(cwd, c4))) return true;
3625
3670
  }
3626
3671
  return false;
3627
3672
  }
@@ -3677,20 +3722,19 @@ async function runEslint(cwd, config, _stack) {
3677
3722
  }
3678
3723
  try {
3679
3724
  const rows = JSON.parse(stdout2);
3680
- let n3 = 0;
3681
3725
  for (const row of rows) {
3726
+ const relFile = toRepoRelativePath(cwd, row.filePath);
3682
3727
  for (const m3 of row.messages) {
3683
- if (n3++ >= 40) break;
3684
3728
  findings.push({
3685
3729
  id: `eslint-${m3.ruleId ?? "unknown"}`,
3686
3730
  severity: "warn",
3687
3731
  message: m3.message,
3688
- file: row.filePath,
3732
+ file: relFile,
3689
3733
  detail: m3.line ? `line ${m3.line}` : void 0
3690
3734
  });
3691
3735
  }
3692
3736
  }
3693
- if (n3 === 0) {
3737
+ if (findings.length === 0) {
3694
3738
  findings.push({
3695
3739
  id: "eslint-failed",
3696
3740
  severity: "warn",
@@ -3778,7 +3822,7 @@ async function runTypeScript(cwd, config, stack) {
3778
3822
  skipped: "disabled in config"
3779
3823
  };
3780
3824
  }
3781
- const hasTs = stack.hasTypeScript || await pathExists(path.join(cwd, "tsconfig.json"));
3825
+ const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
3782
3826
  if (!hasTs) {
3783
3827
  return {
3784
3828
  checkId: "typescript",
@@ -3891,7 +3935,7 @@ async function runSecrets(cwd, config, pr) {
3891
3935
  });
3892
3936
  break;
3893
3937
  }
3894
- const full = path.join(cwd, rel);
3938
+ const full = path4.join(cwd, rel);
3895
3939
  let content;
3896
3940
  try {
3897
3941
  content = await fs.readFile(full, "utf8");
@@ -3917,7 +3961,7 @@ async function runSecrets(cwd, config, pr) {
3917
3961
  };
3918
3962
  }
3919
3963
  function isProbablyTextFile(rel) {
3920
- const ext = path.extname(rel).toLowerCase();
3964
+ const ext = path4.extname(rel).toLowerCase();
3921
3965
  return TEXT_EXT.has(ext);
3922
3966
  }
3923
3967
 
@@ -4210,12 +4254,12 @@ async function runCycles(cwd, config, stack) {
4210
4254
  }
4211
4255
  let entry = cfg.entries[0] ?? "src";
4212
4256
  for (const e3 of cfg.entries) {
4213
- if (await pathExists(path.join(cwd, e3))) {
4257
+ if (await pathExists(path4.join(cwd, e3))) {
4214
4258
  entry = e3;
4215
4259
  break;
4216
4260
  }
4217
4261
  }
4218
- if (!await pathExists(path.join(cwd, entry))) {
4262
+ if (!await pathExists(path4.join(cwd, entry))) {
4219
4263
  return {
4220
4264
  checkId: "cycles",
4221
4265
  findings: [],
@@ -4339,7 +4383,7 @@ async function sumGlobBytes(cwd, patterns) {
4339
4383
  });
4340
4384
  for (const rel of files) {
4341
4385
  try {
4342
- const st = await fs.stat(path.join(cwd, rel));
4386
+ const st = await fs.stat(path4.join(cwd, rel));
4343
4387
  total += st.size;
4344
4388
  } catch {
4345
4389
  }
@@ -4348,7 +4392,7 @@ async function sumGlobBytes(cwd, patterns) {
4348
4392
  return total;
4349
4393
  }
4350
4394
  async function readBaseline(cwd, relPath, baseRef) {
4351
- const disk = path.join(cwd, relPath);
4395
+ const disk = path4.join(cwd, relPath);
4352
4396
  try {
4353
4397
  const raw = await fs.readFile(disk, "utf8");
4354
4398
  return JSON.parse(raw);
@@ -4523,7 +4567,7 @@ async function runCwv(cwd, config, stack, pr) {
4523
4567
  const findings = [];
4524
4568
  const sev2 = gateSeverity5(cfg.gate);
4525
4569
  for (const rel of toScan.slice(0, 400)) {
4526
- const full = path.join(cwd, rel);
4570
+ const full = path4.join(cwd, rel);
4527
4571
  let text;
4528
4572
  try {
4529
4573
  text = await fs.readFile(full, "utf8");
@@ -4608,7 +4652,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4608
4652
  });
4609
4653
  break;
4610
4654
  }
4611
- const full = path.join(cwd, rel);
4655
+ const full = path4.join(cwd, rel);
4612
4656
  let content;
4613
4657
  try {
4614
4658
  content = await fs.readFile(full, "utf8");
@@ -4739,7 +4783,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4739
4783
  const gate = cfg.gate;
4740
4784
  const findings = [];
4741
4785
  for (const rel of files) {
4742
- const full = path.join(cwd, rel);
4786
+ const full = path4.join(cwd, rel);
4743
4787
  let content;
4744
4788
  try {
4745
4789
  content = await fs.readFile(full, "utf8");
@@ -4799,6 +4843,7 @@ function applyAiAssistedEscalation(results, pr, config) {
4799
4843
  var import_picocolors = __toESM(require_picocolors());
4800
4844
  function buildReport(stack, pr, results, options) {
4801
4845
  const mode = options?.mode ?? "warn";
4846
+ const cwd = options?.cwd ?? process.cwd();
4802
4847
  const allFindings = results.flatMap(
4803
4848
  (r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
4804
4849
  );
@@ -4808,6 +4853,7 @@ function buildReport(stack, pr, results, options) {
4808
4853
  const lines = pr != null ? pr.additions + pr.deletions : null;
4809
4854
  const riskScore = scoreRisk(blocks, warns, lines, pr?.changedFiles ?? 0);
4810
4855
  const markdown = formatMarkdown({
4856
+ cwd,
4811
4857
  riskScore,
4812
4858
  mode,
4813
4859
  stack,
@@ -4855,8 +4901,107 @@ function scoreRisk(blocks, warns, lines, files) {
4855
4901
  if (score >= 2) return "MEDIUM";
4856
4902
  return "LOW";
4857
4903
  }
4904
+ function shieldImageUrl(label, message, color) {
4905
+ const q2 = new URLSearchParams({
4906
+ label,
4907
+ message,
4908
+ color,
4909
+ style: "for-the-badge"
4910
+ });
4911
+ return `https://img.shields.io/static/v1?${q2}`;
4912
+ }
4913
+ function mdShield(alt, label, message, color) {
4914
+ return `![${alt}](${shieldImageUrl(label, message, color)})`;
4915
+ }
4916
+ function riskEmoji(risk) {
4917
+ if (risk === "LOW") return "\u{1F7E2}";
4918
+ if (risk === "MEDIUM") return "\u{1F7E0}";
4919
+ return "\u{1F534}";
4920
+ }
4921
+ function riskShieldColor(risk) {
4922
+ if (risk === "LOW") return "brightgreen";
4923
+ if (risk === "MEDIUM") return "orange";
4924
+ return "red";
4925
+ }
4926
+ function modeShieldColor(mode) {
4927
+ return mode === "enforce" ? "critical" : "blue";
4928
+ }
4929
+ function countShieldColor(kind, n3) {
4930
+ if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
4931
+ if (kind === "info") return n3 === 0 ? "inactive" : "informational";
4932
+ if (n3 === 0) return "brightgreen";
4933
+ if (n3 <= 10) return "yellow";
4934
+ return "orange";
4935
+ }
4936
+ function formatDuration(ms) {
4937
+ if (ms < 1e3) return `${ms} ms`;
4938
+ const s3 = Math.round(ms / 1e3);
4939
+ if (s3 < 60) return `${s3}s`;
4940
+ const m3 = Math.floor(s3 / 60);
4941
+ const r4 = s3 % 60;
4942
+ return r4 ? `${m3}m ${r4}s` : `${m3}m`;
4943
+ }
4944
+ function healthEmojiForCheck(r4) {
4945
+ if (r4.skipped) return "\u23ED\uFE0F";
4946
+ if (r4.findings.length === 0) return "\u{1F7E2}";
4947
+ const hasBlock = r4.findings.some((f4) => f4.severity === "block");
4948
+ if (hasBlock) return "\u{1F534}";
4949
+ return "\u{1F7E1}";
4950
+ }
4951
+ function escapeHtml(s3) {
4952
+ return s3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4953
+ }
4954
+ function normalizeFindingDisplay(cwd, f4) {
4955
+ const file = toRepoRelativePath(cwd, f4.file);
4956
+ const message = stripRepoAbsolutePaths(cwd, f4.message);
4957
+ const detail = f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0;
4958
+ return { file, message, detail };
4959
+ }
4960
+ function sanitizeFence(cwd, s3) {
4961
+ return stripRepoAbsolutePaths(cwd, s3).replace(/\r\n/g, "\n").replace(/```/g, "`\u200B``");
4962
+ }
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);
4967
+ }
4968
+ function parseLineHint(detail) {
4969
+ if (!detail) return 0;
4970
+ const m3 = /^line\s+(\d+)/i.exec(detail.trim());
4971
+ return m3 ? Number(m3[1]) : 0;
4972
+ }
4973
+ function appendDetailAfterTable(sb, cwd, detail) {
4974
+ if (!detail?.trim()) return;
4975
+ const d3 = detail.trim();
4976
+ const oneLine = !d3.includes("\n");
4977
+ const safeForTable = oneLine && d3.length <= 200 && !d3.includes("|") && !d3.includes("`");
4978
+ if (safeForTable) {
4979
+ sb.push(`| **Hint** | ${d3} |`);
4980
+ return;
4981
+ }
4982
+ sb.push("");
4983
+ sb.push("*Detail*");
4984
+ sb.push("");
4985
+ sb.push("```text");
4986
+ sb.push(sanitizeFence(cwd, d3));
4987
+ sb.push("```");
4988
+ }
4989
+ function appendDetailFree(sb, cwd, detail) {
4990
+ if (!detail?.trim()) return;
4991
+ const d3 = detail.trim();
4992
+ if (!d3.includes("\n") && d3.length <= 300) {
4993
+ sb.push("");
4994
+ sb.push(`_${d3}_`);
4995
+ return;
4996
+ }
4997
+ sb.push("");
4998
+ sb.push("```text");
4999
+ sb.push(sanitizeFence(cwd, d3));
5000
+ sb.push("```");
5001
+ }
4858
5002
  function formatMarkdown(p2) {
4859
5003
  const {
5004
+ cwd,
4860
5005
  riskScore,
4861
5006
  mode,
4862
5007
  stack,
@@ -4869,99 +5014,205 @@ function formatMarkdown(p2) {
4869
5014
  llmAppendix
4870
5015
  } = p2;
4871
5016
  const sb = [];
4872
- sb.push("## FrontGuard review brief");
5017
+ const sortWithCwd = (items) => [...items].sort((a3, b3) => {
5018
+ const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
5019
+ const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
5020
+ if (af !== bf) return af.localeCompare(bf);
5021
+ const lineA = parseLineHint(a3.f.detail);
5022
+ const lineB = parseLineHint(b3.f.detail);
5023
+ if (lineA !== lineB) return lineA - lineB;
5024
+ return a3.f.message.localeCompare(b3.f.message);
5025
+ });
5026
+ sb.push("## \u2728 FrontGuard review brief");
5027
+ sb.push("");
5028
+ const modeLabel = mode === "enforce" ? "enforce" : "warn only";
5029
+ const badgeLine = [
5030
+ mdShield("Risk level", "risk", riskScore, riskShieldColor(riskScore)),
5031
+ mdShield("CI mode", "mode", modeLabel, modeShieldColor(mode)),
5032
+ mdShield("Blocking", "blocking", String(blocks), countShieldColor("block", blocks)),
5033
+ mdShield("Warnings", "warnings", String(warns), countShieldColor("warn", warns)),
5034
+ mdShield("Info", "info notes", String(infos), countShieldColor("info", infos))
5035
+ ].join(" ");
5036
+ sb.push(badgeLine);
5037
+ sb.push("");
5038
+ sb.push("---");
4873
5039
  sb.push("");
5040
+ sb.push("### \u{1F4CC} Snapshot");
5041
+ sb.push("");
5042
+ sb.push("| | |");
5043
+ sb.push("|:--|:--|");
5044
+ sb.push(`| **Composite risk** | ${riskEmoji(riskScore)} **${riskScore}** \u2014 heuristic from blocks, warnings, and PR size. |`);
4874
5045
  sb.push(
4875
- `**Risk:** ${riskScore} \xB7 **Mode:** ${mode === "enforce" ? "enforce (CI may fail on `block`)" : "warn-only"}`
5046
+ `| **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
5047
  );
4877
- if (pr?.aiAssisted) {
4878
- sb.push("");
5048
+ sb.push(`| **Stack detected** | ${formatStackOneLiner(stack)} |`);
5049
+ if (pr && lines != null) {
4879
5050
  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."
5051
+ `| **PR size** | \u{1F4CF} **${lines}** LOC ( +${pr.additions} / \u2212${pr.deletions} ) \xB7 **${pr.changedFiles}** files |`
4881
5052
  );
4882
5053
  }
4883
5054
  sb.push("");
4884
- sb.push(`**Stack:** ${formatStackOneLiner(stack)}`);
4885
- if (pr && lines != null) {
5055
+ if (pr?.aiAssisted) {
4886
5056
  sb.push(
4887
- `**Size:** ${lines} lines (+${pr.additions} / -${pr.deletions}) across ${pr.changedFiles} files`
5057
+ "> **\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
5058
  );
5059
+ sb.push("");
4889
5060
  }
4890
- sb.push("");
5061
+ sb.push("> **How to read this report**");
5062
+ sb.push(">");
5063
+ sb.push("> | Symbol | Meaning |");
5064
+ sb.push("> |:--|:--|");
5065
+ sb.push("> | \u{1F7E2} | Check passed or skipped cleanly |");
5066
+ sb.push("> | \u{1F7E1} | Warnings only \u2014 review recommended |");
5067
+ sb.push("> | \u{1F534} | Blocking (`block`) severity present |");
5068
+ sb.push("> | \u23ED\uFE0F | Check skipped (see reason in table) |");
5069
+ sb.push(">");
4891
5070
  sb.push(
4892
- blocks > 0 ? `**Blocking (\`block\`) findings:** ${blocks}` : "**Blocking (`block`) findings:** 0"
5071
+ "> Paths in findings are **relative to the repo root**. Expand nested sections for rule id, file, and tool output."
4893
5072
  );
4894
- sb.push(`**Warnings:** ${warns} \xB7 **Info:** ${infos}`);
4895
5073
  sb.push("");
4896
- sb.push("### Check summary");
5074
+ sb.push("---");
5075
+ sb.push("");
5076
+ sb.push("### \u{1F4CB} Check results");
5077
+ sb.push("");
5078
+ sb.push("| | Check | Status | Findings | Duration |");
5079
+ sb.push("|:--:|:--|:--|:-:|--:|");
4897
5080
  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)`);
5081
+ const he2 = healthEmojiForCheck(r4);
5082
+ const status = r4.skipped ? "\u23ED\uFE0F **Skipped**" : r4.findings.length === 0 ? "\u2705 **Clean**" : "\u26A0\uFE0F **Issues**";
5083
+ const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
5084
+ const note = r4.skipped ? `<br><sub>\u{1F4AC} ${escapeHtml(r4.skipped)}</sub>` : "";
5085
+ sb.push(
5086
+ `| ${he2} | **${r4.checkId}** | ${status}${note} | **${nFind}** | ${formatDuration(r4.durationMs)} |`
5087
+ );
4900
5088
  }
4901
5089
  sb.push("");
4902
- const blockFindings = results.flatMap(
4903
- (r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
5090
+ const blockFindings = sortWithCwd(
5091
+ results.flatMap(
5092
+ (r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
5093
+ )
4904
5094
  );
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
- }
4921
- }
5095
+ sb.push("<details open>");
5096
+ sb.push(
5097
+ `<summary><strong>\u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}</strong></summary>`
5098
+ );
5099
+ sb.push("");
5100
+ if (blockFindings.length === 0) {
5101
+ sb.push("*\u2705 No blocking findings \u2014 nothing mapped to severity `block`.*");
5102
+ } else {
4922
5103
  sb.push("");
5104
+ for (const { r: r4, f: f4 } of blockFindings) {
5105
+ const d3 = normalizeFindingDisplay(cwd, f4);
5106
+ sb.push("<details>");
5107
+ sb.push(
5108
+ `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
5109
+ );
5110
+ sb.push("");
5111
+ sb.push(`| Field | Value |`);
5112
+ sb.push(`|:--|:--|`);
5113
+ sb.push(`| **Check** | \`${r4.checkId}\` |`);
5114
+ sb.push(`| **Rule / id** | \`${f4.id}\` |`);
5115
+ if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
5116
+ appendDetailAfterTable(sb, cwd, d3.detail);
5117
+ sb.push("");
5118
+ sb.push("</details>");
5119
+ sb.push("");
5120
+ }
4923
5121
  }
4924
- const warnFindings = results.flatMap(
4925
- (r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
5122
+ sb.push("");
5123
+ sb.push("</details>");
5124
+ sb.push("");
5125
+ const warnFindings = sortWithCwd(
5126
+ results.flatMap(
5127
+ (r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
5128
+ )
4926
5129
  );
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) {
5130
+ sb.push("<details open>");
5131
+ sb.push(
5132
+ `<summary><strong>\u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"}</strong> \xB7 grouped by check</summary>`
5133
+ );
5134
+ sb.push("");
5135
+ if (warnFindings.length === 0) {
5136
+ sb.push("*\u{1F389} No warnings \u2014 nice work.*");
5137
+ } else {
5138
+ const byCheck = /* @__PURE__ */ new Map();
5139
+ for (const item of warnFindings) {
5140
+ const list = byCheck.get(item.r.checkId) ?? [];
5141
+ list.push(item);
5142
+ byCheck.set(item.r.checkId, list);
5143
+ }
5144
+ const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
5145
+ for (const checkId of checkOrder) {
5146
+ const group = sortWithCwd(byCheck.get(checkId));
5147
+ sb.push(`#### \u{1F4C2} \`${checkId}\` \xB7 ${group.length} finding${group.length === 1 ? "" : "s"}`);
5148
+ sb.push("");
5149
+ for (const { r: r4, f: f4 } of group) {
5150
+ const d3 = normalizeFindingDisplay(cwd, f4);
5151
+ sb.push("<details>");
4933
5152
  sb.push(
4934
- ` - <details><summary>detail</summary>
4935
-
4936
- \`\`\`text
4937
- ${f4.detail}
4938
- \`\`\`
4939
-
4940
- </details>`
5153
+ `<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`
4941
5154
  );
5155
+ sb.push("");
5156
+ sb.push(`| Field | Value |`);
5157
+ sb.push(`|:--|:--|`);
5158
+ sb.push(`| **Check** | \`${r4.checkId}\` |`);
5159
+ sb.push(`| **Rule / id** | \`${f4.id}\` |`);
5160
+ if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
5161
+ appendDetailAfterTable(sb, cwd, d3.detail);
5162
+ sb.push("");
5163
+ sb.push("</details>");
5164
+ sb.push("");
4942
5165
  }
4943
5166
  }
4944
- if (warnFindings.length > 30) {
4945
- sb.push(`- _\u2026and ${warnFindings.length - 30} more_`);
4946
- }
4947
- sb.push("");
4948
5167
  }
4949
- const infoFindings = results.flatMap(
4950
- (r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
5168
+ sb.push("</details>");
5169
+ sb.push("");
5170
+ const infoFindings = sortWithCwd(
5171
+ results.flatMap(
5172
+ (r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
5173
+ )
4951
5174
  );
4952
- if (infoFindings.length) {
4953
- sb.push("### Notes");
4954
- for (const { f: f4 } of infoFindings.slice(0, 20)) {
4955
- sb.push(`- ${f4.message}`);
5175
+ sb.push("<details>");
5176
+ sb.push(
5177
+ `<summary><strong>\u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}</strong></summary>`
5178
+ );
5179
+ sb.push("");
5180
+ if (infoFindings.length === 0) {
5181
+ sb.push("*No info-level notes.*");
5182
+ } else {
5183
+ for (const { r: r4, f: f4 } of infoFindings) {
5184
+ const d3 = normalizeFindingDisplay(cwd, f4);
5185
+ sb.push("<details>");
5186
+ sb.push(`<summary>${accordionSummaryHtml(d3.file, d3.message)}</summary>`);
5187
+ sb.push("");
5188
+ sb.push(`- **Check:** \`${r4.checkId}\` \xB7 **id:** \`${f4.id}\``);
5189
+ appendDetailFree(sb, cwd, d3.detail);
5190
+ sb.push("");
5191
+ sb.push("</details>");
5192
+ sb.push("");
4956
5193
  }
4957
- sb.push("");
4958
5194
  }
5195
+ sb.push("");
5196
+ sb.push("</details>");
5197
+ sb.push("");
4959
5198
  if (llmAppendix?.trim()) {
5199
+ sb.push("### \u{1F916} AI / manual appendix");
5200
+ sb.push("");
4960
5201
  sb.push(llmAppendix.trim());
4961
5202
  sb.push("");
4962
5203
  }
4963
5204
  sb.push("---");
4964
- sb.push("_Generated by FrontGuard \u2014 configure with `frontguard.config.js`_");
5205
+ sb.push("");
5206
+ sb.push(
5207
+ mdShield(
5208
+ "Generated by",
5209
+ "report",
5210
+ "FrontGuard",
5211
+ "blueviolet"
5212
+ )
5213
+ );
5214
+ sb.push("");
5215
+ sb.push("_Configure checks in `frontguard.config.js` \xB7 [Shields.io](https://shields.io) badges load at view time._");
4965
5216
  return sb.join("\n");
4966
5217
  }
4967
5218
  function formatConsole(p2) {
@@ -5127,7 +5378,7 @@ async function loadManualAppendix(opts) {
5127
5378
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
5128
5379
  const resolvedPath = filePath?.trim() || envFile;
5129
5380
  if (resolvedPath) {
5130
- const abs = path.isAbsolute(resolvedPath) ? resolvedPath : path.join(cwd, resolvedPath);
5381
+ const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
5131
5382
  try {
5132
5383
  let text = await fs.readFile(abs, "utf8");
5133
5384
  if (text.length > MAX_CHARS) {
@@ -5217,7 +5468,11 @@ async function runFrontGuard(opts) {
5217
5468
  results
5218
5469
  });
5219
5470
  const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
5220
- const report = buildReport(stack, pr, results, { mode, llmAppendix });
5471
+ const report = buildReport(stack, pr, results, {
5472
+ mode,
5473
+ llmAppendix,
5474
+ cwd: opts.cwd
5475
+ });
5221
5476
  if (opts.markdown) {
5222
5477
  g.stdout.write(report.markdown + "\n");
5223
5478
  } else {