@cleartrip/frontguard 0.2.5 → 0.2.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
@@ -5,15 +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';
8
+ import fs, { writeFile } from 'fs/promises';
9
+ import { fileURLToPath, pathToFileURL } from 'url';
10
+ import fs2, { existsSync, statSync, readFileSync } from 'fs';
10
11
  import { execFileSync, spawn } from 'child_process';
11
12
  import { createRequire } from 'module';
12
- import fs2 from 'fs';
13
13
  import { pipeline } from 'stream/promises';
14
14
  import { PassThrough } from 'stream';
15
15
  import fg from 'fast-glob';
16
16
  import * as ts from 'typescript';
17
+ import { Resvg } from '@resvg/resvg-js';
17
18
 
18
19
  var __create = Object.create;
19
20
  var __defProp = Object.defineProperty;
@@ -2496,12 +2497,52 @@ async function initFrontGuard(cwd) {
2496
2497
  await fs.writeFile(tplPr, PR_TEMPLATE, "utf8");
2497
2498
  }
2498
2499
  }
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
+ }
2499
2536
 
2500
2537
  // src/ci/bitbucket-pr-snippet.ts
2501
2538
  function checksImageMarkdown() {
2539
+ const embedPath = process.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH?.trim();
2540
+ if (embedPath) {
2541
+ return tryPngFileToBitbucketImageMarkdownLine(embedPath);
2542
+ }
2502
2543
  const u4 = process.env.FRONTGUARD_CHECKS_IMAGE_URL?.trim();
2503
2544
  if (!u4) return null;
2504
- return `![FrontGuard \u2014 checks summary](${u4})`;
2545
+ return `![${BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT}](${u4})`;
2505
2546
  }
2506
2547
  function bitbucketPipelineResultsUrl() {
2507
2548
  const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
@@ -5532,6 +5573,132 @@ function applyAiAssistedEscalation(results, pr, config) {
5532
5573
  }
5533
5574
  }
5534
5575
  }
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 = 56;
5583
+ var COL_STATUS = 508;
5584
+ var COL_NUM = 748;
5585
+ var COL_TIME = 888;
5586
+ function escapeXml(s3) {
5587
+ return s3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5588
+ }
5589
+ function truncate5(s3, max) {
5590
+ const t3 = s3.replace(/\s+/g, " ").trim();
5591
+ if (t3.length <= max) return t3;
5592
+ return `${t3.slice(0, max - 1)}\u2026`;
5593
+ }
5594
+ function formatDuration(ms) {
5595
+ if (ms < 1e3) return `${ms} ms`;
5596
+ const s3 = Math.round(ms / 1e3);
5597
+ if (s3 < 60) return `${s3}s`;
5598
+ const m3 = Math.floor(s3 / 60);
5599
+ const r4 = s3 % 60;
5600
+ return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5601
+ }
5602
+ function statusText(r4) {
5603
+ if (r4.skipped) return `Skipped \u2014 ${truncate5(r4.skipped, 36)}`;
5604
+ if (r4.findings.length === 0) return "Pass";
5605
+ return `${r4.findings.length} issue(s)`;
5606
+ }
5607
+ function dotFill(r4) {
5608
+ if (r4.skipped) return "#cbd5e1";
5609
+ if (r4.findings.length === 0) return "#16a34a";
5610
+ if (r4.findings.some((x3) => x3.severity === "block")) return "#dc2626";
5611
+ return "#d97706";
5612
+ }
5613
+ function riskFill(risk) {
5614
+ if (risk === "LOW") return "#16a34a";
5615
+ if (risk === "MEDIUM") return "#d97706";
5616
+ return "#dc2626";
5617
+ }
5618
+ function buildChecksSnapshotSvg(p2) {
5619
+ const { riskScore, mode, results, warns, infos, blocks } = p2;
5620
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5621
+ const brandY = 20;
5622
+ const titleY = 42;
5623
+ const metaY = 66;
5624
+ const tableTop = 88;
5625
+ const bodyRows = results.length;
5626
+ const cardH = HEADER_H + bodyRows * ROW_H;
5627
+ const totalH = tableTop + cardH + 28;
5628
+ const headerMid = tableTop + HEADER_H / 2 + 5;
5629
+ const rowTextY = (i3) => tableTop + HEADER_H + i3 * ROW_H + ROW_H / 2 + 5;
5630
+ const headerCells = [
5631
+ { x: COL_CHECK, label: "CHECK", anchor: "start" },
5632
+ { x: COL_STATUS, label: "STATUS", anchor: "start" },
5633
+ { x: COL_NUM, label: "#", anchor: "end" },
5634
+ { x: COL_TIME, label: "TIME", anchor: "end" }
5635
+ ];
5636
+ const rowLines = [];
5637
+ for (let i3 = 0; i3 < bodyRows; i3++) {
5638
+ const y4 = tableTop + HEADER_H + i3 * ROW_H;
5639
+ const fill = i3 % 2 === 1 ? "#f8fafc" : "#ffffff";
5640
+ rowLines.push(
5641
+ `<rect x="${TABLE_X}" y="${y4}" width="${TABLE_W}" height="${ROW_H}" fill="${fill}" stroke="none"/>`
5642
+ );
5643
+ }
5644
+ const bodyCells = [];
5645
+ for (let i3 = 0; i3 < bodyRows; i3++) {
5646
+ const r4 = results[i3];
5647
+ const cy = rowTextY(i3);
5648
+ const cx = 36;
5649
+ bodyCells.push(
5650
+ `<circle cx="${cx}" cy="${cy - 4}" r="4.5" fill="${dotFill(r4)}"/>`,
5651
+ `<text x="${COL_CHECK}" y="${cy}" font-size="13" font-weight="600" fill="#0f172a" font-family="system-ui, -apple-system, Segoe UI, sans-serif">${escapeXml(truncate5(r4.checkId, 46))}</text>`,
5652
+ `<text x="${COL_STATUS}" y="${cy}" font-size="13" fill="#0f172a" font-family="system-ui, -apple-system, Segoe UI, sans-serif">${escapeXml(statusText(r4))}</text>`,
5653
+ `<text x="${COL_NUM}" y="${cy}" font-size="13" fill="#64748b" font-family="ui-monospace, monospace" text-anchor="end">${escapeXml(r4.skipped ? "\u2014" : String(r4.findings.length))}</text>`,
5654
+ `<text x="${COL_TIME}" y="${cy}" font-size="13" fill="#64748b" font-family="ui-monospace, monospace" text-anchor="end">${escapeXml(formatDuration(r4.durationMs))}</text>`
5655
+ );
5656
+ }
5657
+ const gridLines = [];
5658
+ for (let i3 = 0; i3 <= bodyRows; i3++) {
5659
+ const y4 = tableTop + HEADER_H + i3 * ROW_H;
5660
+ gridLines.push(
5661
+ `<line x1="${TABLE_X}" y1="${y4}" x2="${TABLE_X + TABLE_W}" y2="${y4}" stroke="#e2e8f0" stroke-width="1"/>`
5662
+ );
5663
+ }
5664
+ return `<?xml version="1.0" encoding="UTF-8"?>
5665
+ <svg xmlns="http://www.w3.org/2000/svg" width="${W3}" height="${totalH}" viewBox="0 0 ${W3} ${totalH}">
5666
+ <rect width="${W3}" height="${totalH}" fill="#f8fafc"/>
5667
+ <text x="${PAD_X}" y="${brandY}" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.12em" font-family="system-ui, -apple-system, Segoe UI, sans-serif">FRONTGUARD</text>
5668
+ <text x="${PAD_X}" y="${titleY}" font-size="17" font-weight="600" fill="#0f172a" font-family="system-ui, -apple-system, Segoe UI, sans-serif">Checks</text>
5669
+ <text x="${PAD_X}" y="${metaY}" font-size="13" fill="#64748b" font-family="system-ui, -apple-system, Segoe UI, sans-serif">
5670
+ <tspan>Risk </tspan><tspan fill="${riskFill(riskScore)}" font-weight="600">${escapeXml(riskScore)}</tspan>
5671
+ <tspan> \xB7 ${escapeXml(modeLabel)} \xB7 Blocking </tspan><tspan fill="#0f172a" font-weight="600">${blocks}</tspan>
5672
+ <tspan> \xB7 Warnings </tspan><tspan fill="#0f172a" font-weight="600">${warns}</tspan>
5673
+ <tspan> \xB7 Info </tspan><tspan fill="#0f172a" font-weight="600">${infos}</tspan>
5674
+ </text>
5675
+ <defs>
5676
+ <clipPath id="fg-card">
5677
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10"/>
5678
+ </clipPath>
5679
+ </defs>
5680
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="1"/>
5681
+ <g clip-path="url(#fg-card)">
5682
+ <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${HEADER_H}" fill="#f1f5f9"/>
5683
+ ${headerCells.map(
5684
+ (c4) => `<text x="${c4.x}" y="${headerMid}" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.04em" font-family="system-ui, -apple-system, Segoe UI, sans-serif" text-anchor="${c4.anchor}">${c4.label}</text>`
5685
+ ).join("\n ")}
5686
+ ${rowLines.join("\n ")}
5687
+ ${gridLines.join("\n ")}
5688
+ ${bodyCells.join("\n ")}
5689
+ </g>
5690
+ </svg>`;
5691
+ }
5692
+ async function renderChecksSnapshotPng(pngPath, input) {
5693
+ const svg = buildChecksSnapshotSvg(input);
5694
+ const resvg = new Resvg(svg, {
5695
+ background: "#f8fafc",
5696
+ fitTo: { mode: "width", value: W3 },
5697
+ font: { loadSystemFonts: true }
5698
+ });
5699
+ const img = resvg.render();
5700
+ await writeFile(pngPath, img.asPng());
5701
+ }
5535
5702
 
5536
5703
  // src/report/builder.ts
5537
5704
  var import_picocolors = __toESM(require_picocolors());
@@ -5585,7 +5752,7 @@ function sortFindings(cwd, items) {
5585
5752
  return a3.f.message.localeCompare(b3.f.message);
5586
5753
  });
5587
5754
  }
5588
- function formatDuration(ms) {
5755
+ function formatDuration2(ms) {
5589
5756
  if (ms < 1e3) return `${ms} ms`;
5590
5757
  const s3 = Math.round(ms / 1e3);
5591
5758
  if (s3 < 60) return `${s3}s`;
@@ -5720,7 +5887,7 @@ function renderCheckTableRows(results) {
5720
5887
  const help = escapeHtml(getCheckDescription(r4.checkId));
5721
5888
  const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
5722
5889
  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>`;
5723
- 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>`;
5890
+ 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>`;
5724
5891
  }).join("\n");
5725
5892
  }
5726
5893
  function buildChecksSnapshotHtml(p2) {
@@ -6146,14 +6313,32 @@ function buildReport(stack, pr, results, options) {
6146
6313
  lines,
6147
6314
  llmAppendix: options?.llmAppendix ?? null
6148
6315
  }) : null;
6149
- const checksSnapshotHtml = options?.emitChecksSnapshot === true ? buildChecksSnapshotHtml({
6316
+ const llmAppendix = options?.llmAppendix ?? null;
6317
+ const checksSnapshotInput = options?.emitChecksSnapshot === true ? {
6318
+ cwd,
6150
6319
  riskScore,
6151
6320
  mode,
6321
+ stack,
6322
+ pr,
6152
6323
  results,
6153
6324
  warns,
6154
6325
  infos,
6155
- blocks}) : null;
6156
- return { riskScore, stack, pr, results, markdown, consoleText, html, checksSnapshotHtml };
6326
+ blocks,
6327
+ lines,
6328
+ llmAppendix
6329
+ } : null;
6330
+ const checksSnapshotHtml = checksSnapshotInput != null ? buildChecksSnapshotHtml(checksSnapshotInput) : null;
6331
+ return {
6332
+ riskScore,
6333
+ stack,
6334
+ pr,
6335
+ results,
6336
+ markdown,
6337
+ consoleText,
6338
+ html,
6339
+ checksSnapshotHtml,
6340
+ checksSnapshotInput
6341
+ };
6157
6342
  }
6158
6343
  function scoreRisk(blocks, warns, lines, files) {
6159
6344
  let score = 0;
@@ -6201,7 +6386,7 @@ function countShieldColor(kind, n3) {
6201
6386
  if (n3 <= 10) return "yellow";
6202
6387
  return "orange";
6203
6388
  }
6204
- function formatDuration2(ms) {
6389
+ function formatDuration3(ms) {
6205
6390
  if (ms < 1e3) return `${ms} ms`;
6206
6391
  const s3 = Math.round(ms / 1e3);
6207
6392
  if (s3 < 60) return `${s3}s`;
@@ -6356,7 +6541,7 @@ function formatMarkdown(p2) {
6356
6541
  }
6357
6542
  const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
6358
6543
  sb.push(
6359
- `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
6544
+ `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration3(r4.durationMs)} |`
6360
6545
  );
6361
6546
  }
6362
6547
  sb.push("");
@@ -6915,26 +7100,41 @@ async function runFrontGuard(opts) {
6915
7100
  llmAppendix,
6916
7101
  cwd: opts.cwd,
6917
7102
  emitHtml: Boolean(opts.htmlOut),
6918
- emitChecksSnapshot: Boolean(opts.checksSnapshotOut)
7103
+ emitChecksSnapshot: Boolean(opts.checksSnapshotOut || opts.checksPngOut)
6919
7104
  });
6920
7105
  if (opts.htmlOut && report.html) {
6921
7106
  await fs.writeFile(opts.htmlOut, report.html, "utf8");
6922
7107
  }
6923
- if (opts.checksSnapshotOut && report.checksSnapshotHtml) {
6924
- const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
6925
- await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
6926
- const fileUrl = pathToFileURL(snapPath).href;
6927
- g.stderr.write(
6928
- `
6929
- FrontGuard: wrote checks snapshot HTML to ${snapPath} (screenshot this file for PR comments).
6930
- Example: npx playwright screenshot "${fileUrl}" frontguard-checks.png
6931
- Host the PNG at an HTTPS URL, then set FRONTGUARD_CHECKS_IMAGE_URL before generating the PR comment.
7108
+ let embedPngPath = null;
7109
+ if ((opts.checksSnapshotOut || opts.checksPngOut) && report.checksSnapshotHtml && report.checksSnapshotInput) {
7110
+ if (opts.checksSnapshotOut) {
7111
+ const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
7112
+ await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
7113
+ g.stderr.write(`
7114
+ FrontGuard: wrote checks snapshot HTML to ${snapPath}
7115
+ `);
7116
+ }
7117
+ if (opts.checksPngOut) {
7118
+ const pngAbs = path5.isAbsolute(opts.checksPngOut) ? opts.checksPngOut : path5.join(opts.cwd, opts.checksPngOut);
7119
+ await renderChecksSnapshotPng(pngAbs, report.checksSnapshotInput);
7120
+ embedPngPath = pngAbs;
7121
+ g.stderr.write(`FrontGuard: wrote checks PNG to ${pngAbs}.
7122
+
7123
+ `);
7124
+ } else if (opts.checksSnapshotOut) {
7125
+ g.stderr.write(
7126
+ ` Tip: add --checksPngOut checks.png to render the checks table to a PNG (no browser).
6932
7127
 
6933
7128
  `
6934
- );
7129
+ );
7130
+ }
6935
7131
  }
6936
7132
  if (opts.prCommentOut) {
7133
+ if (embedPngPath) {
7134
+ g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH = embedPngPath;
7135
+ }
6937
7136
  const snippet = formatBitbucketPrSnippet(report);
7137
+ delete g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH;
6938
7138
  const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
6939
7139
  await fs.writeFile(abs, snippet, "utf8");
6940
7140
  g.stderr.write(
@@ -6942,7 +7142,7 @@ FrontGuard: wrote checks snapshot HTML to ${snapPath} (screenshot this file for
6942
7142
  FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6943
7143
  Use ONLY this file in your POST \u2026/pullrequests/{id}/comments payload (content.raw).
6944
7144
  Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
6945
- Optional: set FRONTGUARD_CHECKS_IMAGE_URL so the comment includes a checks summary image.
7145
+ With --checksPngOut, the PNG is inlined (small files only) or set FRONTGUARD_CHECKS_IMAGE_URL.
6946
7146
 
6947
7147
  `
6948
7148
  );
@@ -6997,7 +7197,11 @@ var run = defineCommand({
6997
7197
  },
6998
7198
  checksSnapshotOut: {
6999
7199
  type: "string",
7000
- description: "Write HTML with only the Checks table (screenshot \u2192 PNG \u2192 FRONTGUARD_CHECKS_IMAGE_URL in PR comment)"
7200
+ description: "Write HTML with only the Checks table (for screenshots / PR comments)"
7201
+ },
7202
+ checksPngOut: {
7203
+ type: "string",
7204
+ description: "Write PNG of the checks table (SVG raster; @resvg/resvg-js, no browser). Pair with --checksSnapshotOut or use alone"
7001
7205
  },
7002
7206
  prCommentOut: {
7003
7207
  type: "string",
@@ -7012,6 +7216,7 @@ var run = defineCommand({
7012
7216
  append: typeof args.append === "string" ? args.append : null,
7013
7217
  htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
7014
7218
  checksSnapshotOut: typeof args.checksSnapshotOut === "string" ? args.checksSnapshotOut : null,
7219
+ checksPngOut: typeof args.checksPngOut === "string" ? args.checksPngOut : null,
7015
7220
  prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
7016
7221
  });
7017
7222
  }