@cleartrip/frontguard 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,16 +5,16 @@ import f from 'readline';
5
5
  import * as tty from 'tty';
6
6
  import { WriteStream } from 'tty';
7
7
  import path5, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
- import fs from 'fs/promises';
9
- import { pathToFileURL, fileURLToPath } from 'url';
10
- import { tmpdir } from 'os';
8
+ import fs, { writeFile, readFile } from 'fs/promises';
9
+ import { fileURLToPath, pathToFileURL } from 'url';
11
10
  import fs2, { existsSync, statSync, readFileSync } from 'fs';
12
- import { spawn, execFileSync } from 'child_process';
11
+ import { execFileSync, spawn } from 'child_process';
13
12
  import { createRequire } from 'module';
14
13
  import { pipeline } from 'stream/promises';
15
14
  import { PassThrough } from 'stream';
16
15
  import fg from 'fast-glob';
17
16
  import * as ts from 'typescript';
17
+ import { Resvg } from '@resvg/resvg-js';
18
18
 
19
19
  var __create = Object.create;
20
20
  var __defProp = Object.defineProperty;
@@ -2497,26 +2497,52 @@ async function initFrontGuard(cwd) {
2497
2497
  await fs.writeFile(tplPr, PR_TEMPLATE, "utf8");
2498
2498
  }
2499
2499
  }
2500
- var MAX_PNG_BYTES_FOR_EMBED = 22e4;
2500
+ var BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT = "FrontGuard checks summary";
2501
+ var MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH = 3e5;
2502
+ var MAX_PNG_BYTES_BITBUCKET_INLINE = 21e4;
2503
+ var MARKDOWN_LINE_PREFIX = `![${BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT}](data:image/png;base64,`;
2504
+ function isPngBuffer(buf) {
2505
+ return buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71 && buf[4] === 13 && buf[5] === 10 && buf[6] === 26 && buf[7] === 10;
2506
+ }
2507
+ function pngBufferToBitbucketImageMarkdownLine(png) {
2508
+ if (!isPngBuffer(png)) {
2509
+ throw new TypeError("Buffer is not a PNG (missing IHDR signature)");
2510
+ }
2511
+ if (png.length > MAX_PNG_BYTES_BITBUCKET_INLINE) {
2512
+ throw new RangeError(
2513
+ `PNG too large for Bitbucket inline markdown (${png.length} bytes; max ${MAX_PNG_BYTES_BITBUCKET_INLINE}). Host the image and use an HTTPS URL instead.`
2514
+ );
2515
+ }
2516
+ const b64 = png.toString("base64");
2517
+ const line = `${MARKDOWN_LINE_PREFIX}${b64})`;
2518
+ if (line.length > MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH) {
2519
+ throw new RangeError(
2520
+ `Inline markdown line too long (${line.length} chars; max ${MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH})`
2521
+ );
2522
+ }
2523
+ return line;
2524
+ }
2525
+ function tryPngFileToBitbucketImageMarkdownLine(filePath) {
2526
+ try {
2527
+ if (!existsSync(filePath)) return null;
2528
+ const st = statSync(filePath);
2529
+ if (!st.isFile() || st.size > MAX_PNG_BYTES_BITBUCKET_INLINE) return null;
2530
+ const buf = readFileSync(filePath);
2531
+ return pngBufferToBitbucketImageMarkdownLine(buf);
2532
+ } catch {
2533
+ return null;
2534
+ }
2535
+ }
2536
+
2537
+ // src/ci/bitbucket-pr-snippet.ts
2501
2538
  function checksImageMarkdown() {
2502
2539
  const embedPath = process.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH?.trim();
2503
2540
  if (embedPath) {
2504
- try {
2505
- if (!existsSync(embedPath)) return null;
2506
- const st = statSync(embedPath);
2507
- if (!st.isFile() || st.size > MAX_PNG_BYTES_FOR_EMBED) return null;
2508
- const buf = readFileSync(embedPath);
2509
- const b64 = buf.toString("base64");
2510
- const md = `![FrontGuard \u2014 checks summary](data:image/png;base64,${b64})`;
2511
- if (md.length > 45e4) return null;
2512
- return md;
2513
- } catch {
2514
- return null;
2515
- }
2541
+ return tryPngFileToBitbucketImageMarkdownLine(embedPath);
2516
2542
  }
2517
2543
  const u4 = process.env.FRONTGUARD_CHECKS_IMAGE_URL?.trim();
2518
2544
  if (!u4) return null;
2519
- return `![FrontGuard \u2014 checks summary](${u4})`;
2545
+ return `![${BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT}](${u4})`;
2520
2546
  }
2521
2547
  function bitbucketPipelineResultsUrl() {
2522
2548
  const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
@@ -5547,29 +5573,203 @@ function applyAiAssistedEscalation(results, pr, config) {
5547
5573
  }
5548
5574
  }
5549
5575
  }
5550
- var PLAYWRIGHT = "playwright@1.49.1";
5551
- function runCmd(command, args) {
5552
- return new Promise((resolve, reject) => {
5553
- const p2 = spawn(command, args, { stdio: "inherit", env: process.env });
5554
- p2.on("error", reject);
5555
- p2.on("close", (code) => {
5556
- if (code === 0) resolve();
5557
- else reject(new Error(`${command} exited with code ${code}`));
5558
- });
5559
- });
5576
+ var W3 = 920;
5577
+ var PAD_X = 24;
5578
+ var TABLE_X = 20;
5579
+ var TABLE_W = 880;
5580
+ var HEADER_H = 34;
5581
+ var ROW_H = 34;
5582
+ var COL_CHECK = 52;
5583
+ var ICON_X = 268;
5584
+ var COL_STATUS = 400;
5585
+ var COL_NUM = 700;
5586
+ var COL_TIME = 820;
5587
+ var OVER_LABEL_W = 132;
5588
+ var OVER_ROW_H = 36;
5589
+ var clipSeq = 0;
5590
+ function nextClipId() {
5591
+ clipSeq += 1;
5592
+ return `fgc${clipSeq}_${Math.random().toString(36).slice(2, 8)}`;
5593
+ }
5594
+ function escapeXml(s3) {
5595
+ return s3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5560
5596
  }
5561
- async function runChecksHtmlToPng(htmlPath, pngPath) {
5562
- const fileUrl = pathToFileURL(htmlPath).href;
5563
- const npx = process.platform === "win32" ? "npx.cmd" : "npx";
5564
- await runCmd(npx, ["-y", PLAYWRIGHT, "install", "chromium"]);
5565
- await runCmd(npx, [
5566
- "-y",
5567
- PLAYWRIGHT,
5568
- "screenshot",
5569
- fileUrl,
5570
- pngPath,
5571
- "--viewport-size=920,1000"
5572
- ]);
5597
+ function truncate5(s3, max) {
5598
+ const t3 = s3.replace(/\s+/g, " ").trim();
5599
+ if (t3.length <= max) return t3;
5600
+ return `${t3.slice(0, max - 1)}\u2026`;
5601
+ }
5602
+ function formatDuration(ms) {
5603
+ if (ms < 1e3) return `${ms} ms`;
5604
+ const s3 = Math.round(ms / 1e3);
5605
+ if (s3 < 60) return `${s3}s`;
5606
+ const m3 = Math.floor(s3 / 60);
5607
+ const r4 = s3 % 60;
5608
+ return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5609
+ }
5610
+ function statusText(r4) {
5611
+ if (r4.skipped) return `Skipped \u2014 ${truncate5(r4.skipped, 42)}`;
5612
+ if (r4.findings.length === 0) return "Pass";
5613
+ return `${r4.findings.length} issue(s)`;
5614
+ }
5615
+ function dotFill(r4) {
5616
+ if (r4.skipped) return "#cbd5e1";
5617
+ if (r4.findings.length === 0) return "#16a34a";
5618
+ if (r4.findings.some((x3) => x3.severity === "block")) return "#dc2626";
5619
+ return "#d97706";
5620
+ }
5621
+ function riskFill(risk) {
5622
+ if (risk === "LOW") return "#16a34a";
5623
+ if (risk === "MEDIUM") return "#d97706";
5624
+ return "#dc2626";
5625
+ }
5626
+ function resvgFontOptions() {
5627
+ const base = { loadSystemFonts: true };
5628
+ if (process.platform === "linux") {
5629
+ return {
5630
+ ...base,
5631
+ fontDirs: [
5632
+ "/usr/share/fonts/truetype/dejavu",
5633
+ "/usr/share/fonts/truetype/liberation",
5634
+ "/usr/share/fonts/opentype/noto",
5635
+ "/usr/share/fonts"
5636
+ ]
5637
+ };
5638
+ }
5639
+ return base;
5640
+ }
5641
+ function buildChecksSnapshotSvg(p2) {
5642
+ const { riskScore, mode, stack, pr, results, lines } = p2;
5643
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5644
+ const stackLine = truncate5(formatStackOneLiner(stack), 96);
5645
+ const prLine = pr && lines != null ? truncate5(
5646
+ `${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files`,
5647
+ 96
5648
+ ) : null;
5649
+ const nOverview = 3 + (prLine ? 1 : 0);
5650
+ const overviewH = nOverview * OVER_ROW_H;
5651
+ const overviewY0 = 44;
5652
+ const overviewClip = nextClipId();
5653
+ const checksTitleY = overviewY0 + overviewH + 22;
5654
+ const tableTop = checksTitleY + 22;
5655
+ const bodyRows = results.length;
5656
+ const cardH = HEADER_H + bodyRows * ROW_H;
5657
+ const totalH = tableTop + cardH + 36;
5658
+ const headerMid = tableTop + HEADER_H / 2 + 5;
5659
+ const rowTextY = (i3) => tableTop + HEADER_H + i3 * ROW_H + ROW_H / 2 + 5;
5660
+ const headerCells = [
5661
+ { x: COL_CHECK, label: "CHECK", anchor: "start" },
5662
+ { x: COL_STATUS, label: "STATUS", anchor: "start" },
5663
+ { x: COL_NUM, label: "#", anchor: "end" },
5664
+ { x: COL_TIME, label: "TIME", anchor: "end" }
5665
+ ];
5666
+ const overviewRows = [];
5667
+ const ov = [
5668
+ { label: "Risk score", kind: "risk" },
5669
+ { label: "Mode", kind: "text", text: modeLabel },
5670
+ { label: "Stack", kind: "text", text: stackLine }
5671
+ ];
5672
+ if (prLine) ov.push({ label: "Pull request", kind: "text", text: prLine });
5673
+ for (let i3 = 0; i3 < nOverview; i3++) {
5674
+ const row = ov[i3];
5675
+ const y4 = overviewY0 + i3 * OVER_ROW_H;
5676
+ const mid = y4 + OVER_ROW_H / 2 + 4;
5677
+ overviewRows.push(
5678
+ `<rect x="${TABLE_X}" y="${y4}" width="${OVER_LABEL_W}" height="${OVER_ROW_H}" fill="#f1f5f9" stroke="none"/>`,
5679
+ `<line x1="${TABLE_X}" y1="${y4 + OVER_ROW_H}" x2="${TABLE_X + TABLE_W}" y2="${y4 + OVER_ROW_H}" stroke="#e2e8f0" stroke-width="1"/>`,
5680
+ `<text x="${TABLE_X + 10}" y="${mid}" font-size="12" fill="#64748b" font-weight="500" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(row.label)}</text>`
5681
+ );
5682
+ if (row.kind === "risk") {
5683
+ const rs = riskScore;
5684
+ overviewRows.push(
5685
+ `<text x="${TABLE_X + OVER_LABEL_W + 10}" y="${mid}" font-size="13" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif"><tspan fill="${riskFill(riskScore)}" font-weight="600">${escapeXml(rs)}</tspan><tspan fill="#64748b"> \u2014 heuristic</tspan></text>`
5686
+ );
5687
+ } else {
5688
+ overviewRows.push(
5689
+ `<text x="${TABLE_X + OVER_LABEL_W + 10}" y="${mid}" font-size="13" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(row.text)}</text>`
5690
+ );
5691
+ }
5692
+ }
5693
+ const rowLines = [];
5694
+ for (let i3 = 0; i3 < bodyRows; i3++) {
5695
+ const y4 = tableTop + HEADER_H + i3 * ROW_H;
5696
+ const fill = i3 % 2 === 1 ? "#f8fafc" : "#ffffff";
5697
+ rowLines.push(
5698
+ `<rect x="${TABLE_X}" y="${y4}" width="${TABLE_W}" height="${ROW_H}" fill="${fill}" stroke="none"/>`
5699
+ );
5700
+ }
5701
+ const bodyCells = [];
5702
+ for (let i3 = 0; i3 < bodyRows; i3++) {
5703
+ const r4 = results[i3];
5704
+ const cy = rowTextY(i3);
5705
+ bodyCells.push(
5706
+ `<circle cx="34" cy="${cy - 4}" r="4.5" fill="${dotFill(r4)}"/>`,
5707
+ `<text x="${COL_CHECK}" y="${cy}" font-size="13" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(truncate5(r4.checkId, 22))}</text>`,
5708
+ `<circle cx="${ICON_X}" cy="${cy - 3}" r="8" fill="#f1f5f9" stroke="#e2e8f0" stroke-width="1"/>`,
5709
+ `<text x="${ICON_X}" y="${cy}" font-size="9" font-weight="700" fill="#64748b" text-anchor="middle" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">i</text>`,
5710
+ `<text x="${COL_STATUS}" y="${cy}" font-size="13" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(statusText(r4))}</text>`,
5711
+ `<text x="${COL_NUM}" y="${cy}" font-size="13" fill="#64748b" font-family="Liberation Mono, DejaVu Sans Mono, monospace" text-anchor="end">${escapeXml(r4.skipped ? "\u2014" : String(r4.findings.length))}</text>`,
5712
+ `<text x="${COL_TIME}" y="${cy}" font-size="13" fill="#64748b" font-family="Liberation Mono, DejaVu Sans Mono, monospace" text-anchor="end">${escapeXml(formatDuration(r4.durationMs))}</text>`
5713
+ );
5714
+ }
5715
+ const gridLines = [];
5716
+ for (let i3 = 0; i3 <= bodyRows; i3++) {
5717
+ const y4 = tableTop + HEADER_H + i3 * ROW_H;
5718
+ gridLines.push(
5719
+ `<line x1="${TABLE_X}" y1="${y4}" x2="${TABLE_X + TABLE_W}" y2="${y4}" stroke="#e2e8f0" stroke-width="1"/>`
5720
+ );
5721
+ }
5722
+ const checksClip = nextClipId();
5723
+ return `<?xml version="1.0" encoding="UTF-8"?>
5724
+ <svg xmlns="http://www.w3.org/2000/svg" width="${W3}" height="${totalH}" viewBox="0 0 ${W3} ${totalH}">
5725
+ <rect width="${W3}" height="${totalH}" fill="#f8fafc"/>
5726
+ <text x="${PAD_X}" y="18" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.12em" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">FRONTGUARD</text>
5727
+ <text x="${PAD_X}" y="36" font-size="16" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">Overview</text>
5728
+ <defs>
5729
+ <clipPath id="${overviewClip}">
5730
+ <rect x="${TABLE_X}" y="${overviewY0}" width="${TABLE_W}" height="${overviewH}" rx="10" ry="10"/>
5731
+ </clipPath>
5732
+ <clipPath id="${checksClip}">
5733
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10"/>
5734
+ </clipPath>
5735
+ </defs>
5736
+ <rect x="${TABLE_X}" y="${overviewY0}" width="${TABLE_W}" height="${overviewH}" rx="10" ry="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="1"/>
5737
+ <g clip-path="url(#${overviewClip})">
5738
+ ${overviewRows.join("\n ")}
5739
+ </g>
5740
+ <text x="${PAD_X}" y="${checksTitleY}" font-size="16" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">Checks</text>
5741
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="1"/>
5742
+ <g clip-path="url(#${checksClip})">
5743
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${HEADER_H}" fill="#f1f5f9"/>
5744
+ ${headerCells.map(
5745
+ (c4) => `<text x="${c4.x}" y="${headerMid}" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.04em" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif" text-anchor="${c4.anchor}">${c4.label}</text>`
5746
+ ).join("\n ")}
5747
+ ${rowLines.join("\n ")}
5748
+ ${gridLines.join("\n ")}
5749
+ ${bodyCells.join("\n ")}
5750
+ </g>
5751
+ </svg>`;
5752
+ }
5753
+ async function renderChecksSnapshotPng(pngPath, input) {
5754
+ const svg = buildChecksSnapshotSvg(input);
5755
+ const resvg = new Resvg(svg, {
5756
+ background: "#f8fafc",
5757
+ fitTo: { mode: "width", value: W3 },
5758
+ font: resvgFontOptions()
5759
+ });
5760
+ const img = resvg.render();
5761
+ const png = img.asPng();
5762
+ await writeFile(pngPath, png);
5763
+ const written = await readFile(pngPath);
5764
+ if (!isPngBuffer(written) || written.length < 200) {
5765
+ throw new Error(
5766
+ "FrontGuard: checks PNG is missing or invalid after render. Install fonts on the runner (e.g. apt install fonts-dejavu-core fonts-liberation) and ensure @resvg/resvg-js native binary loads."
5767
+ );
5768
+ }
5769
+ const { width, height } = img;
5770
+ if (width < 16 || height < 16) {
5771
+ throw new Error(`FrontGuard: checks PNG has invalid dimensions ${width}x${height}.`);
5772
+ }
5573
5773
  }
5574
5774
 
5575
5775
  // src/report/builder.ts
@@ -5624,7 +5824,7 @@ function sortFindings(cwd, items) {
5624
5824
  return a3.f.message.localeCompare(b3.f.message);
5625
5825
  });
5626
5826
  }
5627
- function formatDuration(ms) {
5827
+ function formatDuration2(ms) {
5628
5828
  if (ms < 1e3) return `${ms} ms`;
5629
5829
  const s3 = Math.round(ms / 1e3);
5630
5830
  if (s3 < 60) return `${s3}s`;
@@ -5753,26 +5953,53 @@ var CHECKS_TABLE_STYLES = `
5753
5953
  .dot-block { background: var(--block); }
5754
5954
  .dot-skip { background: #cbd5e1; }
5755
5955
  `;
5956
+ var SNAPSHOT_OVERVIEW_STYLES = `
5957
+ .snapshot {
5958
+ width: 100%;
5959
+ border-collapse: collapse;
5960
+ font-size: 0.9rem;
5961
+ background: var(--surface);
5962
+ border-radius: var(--radius);
5963
+ overflow: hidden;
5964
+ border: 1px solid var(--border);
5965
+ box-shadow: var(--shadow);
5966
+ margin: 0 0 1.25rem;
5967
+ }
5968
+ .snapshot th, .snapshot td {
5969
+ padding: 0.65rem 1rem;
5970
+ text-align: left;
5971
+ border-bottom: 1px solid var(--border);
5972
+ }
5973
+ .snapshot tr:last-child th, .snapshot tr:last-child td { border-bottom: none; }
5974
+ .snapshot th {
5975
+ width: 9rem;
5976
+ color: var(--muted);
5977
+ font-weight: 500;
5978
+ background: #f1f5f9;
5979
+ }
5980
+ .muted { color: var(--muted); }
5981
+ `;
5756
5982
  function renderCheckTableRows(results) {
5757
5983
  return results.map((r4) => {
5758
5984
  const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5759
5985
  const help = escapeHtml(getCheckDescription(r4.checkId));
5760
5986
  const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
5761
5987
  const checkTitle = `<span class="check-title-cell"><strong class="check-name">${escapeHtml(r4.checkId)}</strong><span class="check-info-wrap"><button type="button" class="check-info" title="${help}" aria-label="${ariaWhat}">i</button><span class="check-tooltip" role="tooltip">${help}</span></span></span>`;
5762
- return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
5988
+ return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration2(r4.durationMs)}</td></tr>`;
5763
5989
  }).join("\n");
5764
5990
  }
5765
5991
  function buildChecksSnapshotHtml(p2) {
5766
- const { riskScore, mode, results, warns, infos, blocks } = p2;
5992
+ const { riskScore, mode, stack, pr, results, lines } = p2;
5767
5993
  const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5768
5994
  const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5769
5995
  const checkRows = renderCheckTableRows(results);
5996
+ const prRow = pr && lines != null ? `<tr><th>Pull request</th><td>${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
5770
5997
  return `<!DOCTYPE html>
5771
5998
  <html lang="en">
5772
5999
  <head>
5773
6000
  <meta charset="utf-8" />
5774
6001
  <meta name="viewport" content="width=device-width, initial-scale=1" />
5775
- <title>FrontGuard \u2014 Checks</title>
6002
+ <title>FrontGuard \u2014 Snapshot</title>
5776
6003
  <style>
5777
6004
  :root {
5778
6005
  --bg: #f8fafc;
@@ -5811,26 +6038,27 @@ function buildChecksSnapshotHtml(p2) {
5811
6038
  .h2 {
5812
6039
  font-size: 1rem;
5813
6040
  font-weight: 600;
5814
- margin: 0 0 0.5rem;
6041
+ margin: 0 0 0.85rem;
5815
6042
  color: var(--text);
5816
6043
  letter-spacing: -0.02em;
5817
6044
  }
5818
- .snap-meta {
5819
- font-size: 0.8rem;
5820
- color: var(--muted);
5821
- margin: 0 0 0.85rem;
5822
- }
5823
- .snap-meta strong { color: var(--text); font-weight: 600; }
5824
6045
  .risk-low { color: var(--ok); }
5825
6046
  .risk-med { color: var(--warn); }
5826
6047
  .risk-high { color: var(--block); }
6048
+ ${SNAPSHOT_OVERVIEW_STYLES}
5827
6049
  ${CHECKS_TABLE_STYLES}
5828
6050
  </style>
5829
6051
  </head>
5830
6052
  <body>
5831
6053
  <div class="brand">FrontGuard</div>
6054
+ <h2 class="h2">Overview</h2>
6055
+ <table class="snapshot">
6056
+ <tr><th>Risk score</th><td><strong class="${riskClass}">${riskScore}</strong> <span class="muted">\u2014 heuristic</span></td></tr>
6057
+ <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
6058
+ <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
6059
+ ${prRow}
6060
+ </table>
5832
6061
  <h2 class="h2">Checks</h2>
5833
- <p class="snap-meta">Risk <strong class="${riskClass}">${riskScore}</strong> \xB7 ${escapeHtml(modeLabel)} \xB7 Blocking <strong>${blocks}</strong> \xB7 Warnings <strong>${warns}</strong> \xB7 Info <strong>${infos}</strong></p>
5834
6062
  <table class="results">
5835
6063
  <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5836
6064
  <tbody>${checkRows}</tbody>
@@ -6185,14 +6413,32 @@ function buildReport(stack, pr, results, options) {
6185
6413
  lines,
6186
6414
  llmAppendix: options?.llmAppendix ?? null
6187
6415
  }) : null;
6188
- const checksSnapshotHtml = options?.emitChecksSnapshot === true ? buildChecksSnapshotHtml({
6416
+ const llmAppendix = options?.llmAppendix ?? null;
6417
+ const checksSnapshotInput = options?.emitChecksSnapshot === true ? {
6418
+ cwd,
6189
6419
  riskScore,
6190
6420
  mode,
6421
+ stack,
6422
+ pr,
6191
6423
  results,
6192
6424
  warns,
6193
6425
  infos,
6194
- blocks}) : null;
6195
- return { riskScore, stack, pr, results, markdown, consoleText, html, checksSnapshotHtml };
6426
+ blocks,
6427
+ lines,
6428
+ llmAppendix
6429
+ } : null;
6430
+ const checksSnapshotHtml = checksSnapshotInput != null ? buildChecksSnapshotHtml(checksSnapshotInput) : null;
6431
+ return {
6432
+ riskScore,
6433
+ stack,
6434
+ pr,
6435
+ results,
6436
+ markdown,
6437
+ consoleText,
6438
+ html,
6439
+ checksSnapshotHtml,
6440
+ checksSnapshotInput
6441
+ };
6196
6442
  }
6197
6443
  function scoreRisk(blocks, warns, lines, files) {
6198
6444
  let score = 0;
@@ -6240,7 +6486,7 @@ function countShieldColor(kind, n3) {
6240
6486
  if (n3 <= 10) return "yellow";
6241
6487
  return "orange";
6242
6488
  }
6243
- function formatDuration2(ms) {
6489
+ function formatDuration3(ms) {
6244
6490
  if (ms < 1e3) return `${ms} ms`;
6245
6491
  const s3 = Math.round(ms / 1e3);
6246
6492
  if (s3 < 60) return `${s3}s`;
@@ -6395,7 +6641,7 @@ function formatMarkdown(p2) {
6395
6641
  }
6396
6642
  const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
6397
6643
  sb.push(
6398
- `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
6644
+ `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration3(r4.durationMs)} |`
6399
6645
  );
6400
6646
  }
6401
6647
  sb.push("");
@@ -6960,36 +7206,24 @@ async function runFrontGuard(opts) {
6960
7206
  await fs.writeFile(opts.htmlOut, report.html, "utf8");
6961
7207
  }
6962
7208
  let embedPngPath = null;
6963
- let tmpDir = null;
6964
- let htmlForPng;
6965
- if ((opts.checksSnapshotOut || opts.checksPngOut) && report.checksSnapshotHtml) {
7209
+ if ((opts.checksSnapshotOut || opts.checksPngOut) && report.checksSnapshotHtml && report.checksSnapshotInput) {
6966
7210
  if (opts.checksSnapshotOut) {
6967
7211
  const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
6968
7212
  await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
6969
- htmlForPng = snapPath;
6970
7213
  g.stderr.write(`
6971
7214
  FrontGuard: wrote checks snapshot HTML to ${snapPath}
6972
7215
  `);
6973
7216
  }
6974
7217
  if (opts.checksPngOut) {
6975
- if (!htmlForPng) {
6976
- tmpDir = await fs.mkdtemp(path5.join(tmpdir(), "fg-checks-"));
6977
- htmlForPng = path5.join(tmpDir, "checks.html");
6978
- await fs.writeFile(htmlForPng, report.checksSnapshotHtml, "utf8");
6979
- }
6980
7218
  const pngAbs = path5.isAbsolute(opts.checksPngOut) ? opts.checksPngOut : path5.join(opts.cwd, opts.checksPngOut);
6981
- await runChecksHtmlToPng(htmlForPng, pngAbs);
7219
+ await renderChecksSnapshotPng(pngAbs, report.checksSnapshotInput);
6982
7220
  embedPngPath = pngAbs;
6983
- g.stderr.write(`FrontGuard: wrote checks PNG to ${pngAbs} (Playwright via npx).
7221
+ g.stderr.write(`FrontGuard: wrote checks PNG to ${pngAbs}.
6984
7222
 
6985
7223
  `);
6986
- if (tmpDir) {
6987
- await fs.rm(tmpDir, { recursive: true, force: true });
6988
- tmpDir = null;
6989
- }
6990
- } else if (opts.checksSnapshotOut && htmlForPng) {
7224
+ } else if (opts.checksSnapshotOut) {
6991
7225
  g.stderr.write(
6992
- ` Tip: add --checksPngOut checks.png to render this HTML to PNG with Playwright (npx).
7226
+ ` Tip: add --checksPngOut checks.png to render the checks table to a PNG (no browser).
6993
7227
 
6994
7228
  `
6995
7229
  );
@@ -7067,7 +7301,7 @@ var run = defineCommand({
7067
7301
  },
7068
7302
  checksPngOut: {
7069
7303
  type: "string",
7070
- description: "Write PNG of the checks table via Playwright (npx). Pair with --checksSnapshotOut or use alone"
7304
+ description: "Write PNG of the checks table (SVG raster; @resvg/resvg-js, no browser). Pair with --checksSnapshotOut or use alone"
7071
7305
  },
7072
7306
  prCommentOut: {
7073
7307
  type: "string",