@cleartrip/frontguard 0.2.2 → 0.2.4

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
@@ -6,13 +6,14 @@ import * as tty from 'tty';
6
6
  import { WriteStream } from 'tty';
7
7
  import path5, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
8
  import fs from 'fs/promises';
9
- import { fileURLToPath, pathToFileURL } from 'url';
9
+ import { pathToFileURL, fileURLToPath } from 'url';
10
10
  import { execFileSync, spawn } from 'child_process';
11
11
  import { createRequire } from 'module';
12
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
+ import * as ts from 'typescript';
16
17
 
17
18
  var __create = Object.create;
18
19
  var __defProp = Object.defineProperty;
@@ -2497,6 +2498,11 @@ async function initFrontGuard(cwd) {
2497
2498
  }
2498
2499
 
2499
2500
  // src/ci/bitbucket-pr-snippet.ts
2501
+ function checksImageMarkdown() {
2502
+ const u4 = process.env.FRONTGUARD_CHECKS_IMAGE_URL?.trim();
2503
+ if (!u4) return null;
2504
+ return `![FrontGuard \u2014 checks summary](${u4})`;
2505
+ }
2500
2506
  function bitbucketPipelineResultsUrl() {
2501
2507
  const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
2502
2508
  const bn = process.env.BITBUCKET_BUILD_NUMBER?.trim();
@@ -2521,8 +2527,12 @@ function bitbucketDownloadsPageUrl() {
2521
2527
  function formatBitbucketPrSnippet(report) {
2522
2528
  const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
2523
2529
  const linkOnly = process.env.FRONTGUARD_BITBUCKET_COMMENT_LINK_ONLY === "1";
2530
+ const imgMd = checksImageMarkdown();
2524
2531
  if (linkOnly && publicReport) {
2525
- return publicReport.endsWith("\n") ? publicReport : `${publicReport}
2532
+ const parts = [];
2533
+ if (imgMd) parts.push(imgMd, "");
2534
+ parts.push(publicReport.endsWith("\n") ? publicReport.slice(0, -1) : publicReport);
2535
+ return `${parts.join("\n")}
2526
2536
  `;
2527
2537
  }
2528
2538
  const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
@@ -2537,16 +2547,27 @@ function formatBitbucketPrSnippet(report) {
2537
2547
  (n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
2538
2548
  0
2539
2549
  );
2540
- const out = [
2550
+ const out = [];
2551
+ if (imgMd) {
2552
+ out.push(imgMd);
2553
+ out.push("");
2554
+ }
2555
+ out.push(
2541
2556
  "FrontGuard report (short summary)",
2542
2557
  "",
2543
2558
  `Risk: ${riskScore} | Blocking: ${blocks} | Warnings: ${warns}`,
2544
2559
  ""
2545
- ];
2560
+ );
2546
2561
  if (publicReport) {
2547
2562
  out.push("Full interactive report (open in browser):");
2548
2563
  out.push(publicReport);
2549
2564
  out.push("");
2565
+ if (imgMd) {
2566
+ out.push(
2567
+ "The image above is a quick checks overview. Use the link for file-level findings, hints, and suggested fixes."
2568
+ );
2569
+ out.push("");
2570
+ }
2550
2571
  } else if (downloadsName && downloadsPage) {
2551
2572
  out.push("HTML report is in Repository \u2192 Downloads. Open this page while logged in:");
2552
2573
  out.push(downloadsPage);
@@ -2953,9 +2974,9 @@ async function detectStack(cwd) {
2953
2974
  try {
2954
2975
  const tsconfigPath = path5.join(cwd, "tsconfig.json");
2955
2976
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2956
- const ts = JSON.parse(tsRaw);
2957
- if (typeof ts.compilerOptions?.strict === "boolean") {
2958
- tsStrict = ts.compilerOptions.strict;
2977
+ const ts2 = JSON.parse(tsRaw);
2978
+ if (typeof ts2.compilerOptions?.strict === "boolean") {
2979
+ tsStrict = ts2.compilerOptions.strict;
2959
2980
  }
2960
2981
  } catch {
2961
2982
  }
@@ -5013,6 +5034,178 @@ async function runCustomRules(cwd, config, restrictToFiles) {
5013
5034
  durationMs: Math.round(performance.now() - t0)
5014
5035
  };
5015
5036
  }
5037
+ var BIG_LITERAL_MIN = 9e3;
5038
+ function scanDiscardedExpensiveCalls(regionText, fileNameHint, regionStartLine) {
5039
+ const kind = /\.(tsx|jsx)$/i.test(fileNameHint) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
5040
+ const sf = ts.createSourceFile(
5041
+ fileNameHint,
5042
+ regionText,
5043
+ ts.ScriptTarget.Latest,
5044
+ true,
5045
+ kind
5046
+ );
5047
+ const parseDiags = sf.parseDiagnostics;
5048
+ if (parseDiags && parseDiags.length > 12) {
5049
+ return [];
5050
+ }
5051
+ const stack = [/* @__PURE__ */ new Map()];
5052
+ const lines = [];
5053
+ function current() {
5054
+ return stack[stack.length - 1];
5055
+ }
5056
+ function lookupExpensive(name) {
5057
+ for (let i3 = stack.length - 1; i3 >= 0; i3--) {
5058
+ const v3 = stack[i3].get(name);
5059
+ if (v3 !== void 0) return v3;
5060
+ }
5061
+ return false;
5062
+ }
5063
+ function lineOf(node) {
5064
+ const lc = ts.getLineAndCharacterOfPosition(sf, node.getStart(sf, false));
5065
+ return regionStartLine + lc.line;
5066
+ }
5067
+ function visitFunctionLike(fn) {
5068
+ stack.push(/* @__PURE__ */ new Map());
5069
+ for (const p2 of fn.parameters) {
5070
+ if (ts.isIdentifier(p2.name)) current().set(p2.name.text, false);
5071
+ }
5072
+ const body = fn.body;
5073
+ if (!body) {
5074
+ stack.pop();
5075
+ return;
5076
+ }
5077
+ if (ts.isBlock(body)) {
5078
+ for (const st of body.statements) visitStmt(st);
5079
+ } else if (ts.isExpression(body)) {
5080
+ visitStmt(ts.factory.createExpressionStatement(body));
5081
+ }
5082
+ stack.pop();
5083
+ }
5084
+ function visitBlock(block) {
5085
+ stack.push(/* @__PURE__ */ new Map());
5086
+ for (const st of block.statements) visitStmt(st);
5087
+ stack.pop();
5088
+ }
5089
+ function visitMaybeBlock(s3) {
5090
+ if (ts.isBlock(s3)) visitBlock(s3);
5091
+ else visitStmt(s3);
5092
+ }
5093
+ function visitStmt(st) {
5094
+ if (ts.isVariableStatement(st)) {
5095
+ for (const decl of st.declarationList.declarations) {
5096
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
5097
+ const name = decl.name.text;
5098
+ const init3 = decl.initializer;
5099
+ if (ts.isArrowFunction(init3) || ts.isFunctionExpression(init3)) {
5100
+ current().set(name, isBodyExpensive(init3.body));
5101
+ visitFunctionLike(init3);
5102
+ }
5103
+ }
5104
+ return;
5105
+ }
5106
+ if (ts.isFunctionDeclaration(st) && st.name && st.body) {
5107
+ current().set(st.name.text, isBodyExpensive(st.body));
5108
+ visitFunctionLike(st);
5109
+ return;
5110
+ }
5111
+ if (ts.isExpressionStatement(st)) {
5112
+ const ex = st.expression;
5113
+ if (ts.isCallExpression(ex) && !ex.questionDotToken && ts.isIdentifier(ex.expression) && lookupExpensive(ex.expression.text)) {
5114
+ lines.push(lineOf(ex));
5115
+ }
5116
+ return;
5117
+ }
5118
+ if (ts.isBlock(st)) {
5119
+ visitBlock(st);
5120
+ return;
5121
+ }
5122
+ if (ts.isIfStatement(st)) {
5123
+ visitMaybeBlock(st.thenStatement);
5124
+ if (st.elseStatement) visitMaybeBlock(st.elseStatement);
5125
+ return;
5126
+ }
5127
+ if (ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isForInStatement(st) || ts.isWhileStatement(st) || ts.isDoStatement(st)) {
5128
+ visitMaybeBlock(st.statement);
5129
+ return;
5130
+ }
5131
+ if (ts.isTryStatement(st)) {
5132
+ visitBlock(st.tryBlock);
5133
+ if (st.catchClause?.block) visitBlock(st.catchClause.block);
5134
+ if (st.finallyBlock) visitBlock(st.finallyBlock);
5135
+ return;
5136
+ }
5137
+ if (ts.isSwitchStatement(st)) {
5138
+ for (const clause of st.caseBlock.clauses) {
5139
+ for (const s3 of clause.statements) visitStmt(s3);
5140
+ }
5141
+ return;
5142
+ }
5143
+ if (ts.isClassDeclaration(st) && st.members) {
5144
+ for (const member of st.members) {
5145
+ if (ts.isMethodDeclaration(member) && member.body) {
5146
+ const nm = member.name;
5147
+ if (ts.isIdentifier(nm)) {
5148
+ current().set(nm.text, isBodyExpensive(member.body));
5149
+ }
5150
+ visitFunctionLike(member);
5151
+ }
5152
+ }
5153
+ }
5154
+ }
5155
+ for (const st of sf.statements) {
5156
+ if (ts.isImportDeclaration(st) || ts.isImportEqualsDeclaration(st)) continue;
5157
+ if (ts.isExportDeclaration(st)) continue;
5158
+ if (ts.isExportAssignment(st)) {
5159
+ const ex = st.expression;
5160
+ if (ts.isArrowFunction(ex) || ts.isFunctionExpression(ex)) {
5161
+ visitFunctionLike(ex);
5162
+ }
5163
+ continue;
5164
+ }
5165
+ visitStmt(st);
5166
+ }
5167
+ return dedupeSorted(lines);
5168
+ }
5169
+ function dedupeSorted(nums) {
5170
+ return [...new Set(nums)].sort((a3, b3) => a3 - b3);
5171
+ }
5172
+ function isBodyExpensive(body) {
5173
+ if (!body) return false;
5174
+ if (ts.isBlock(body)) return blockHasExpensiveLoop(body);
5175
+ return nodeHasExpensiveLoop(body);
5176
+ }
5177
+ function blockHasExpensiveLoop(block) {
5178
+ return nodeHasExpensiveLoop(block);
5179
+ }
5180
+ function nodeHasExpensiveLoop(node) {
5181
+ let found = false;
5182
+ const visit = (n3) => {
5183
+ if (found) return;
5184
+ if (ts.isForStatement(n3) || ts.isForOfStatement(n3) || ts.isForInStatement(n3) || ts.isWhileStatement(n3) || ts.isDoStatement(n3)) {
5185
+ if (subtreeHasBigNumeric(n3, BIG_LITERAL_MIN)) found = true;
5186
+ return;
5187
+ }
5188
+ ts.forEachChild(n3, visit);
5189
+ };
5190
+ visit(node);
5191
+ return found;
5192
+ }
5193
+ function subtreeHasBigNumeric(node, min) {
5194
+ let found = false;
5195
+ const visit = (n3) => {
5196
+ if (found) return;
5197
+ if (ts.isNumericLiteral(n3)) {
5198
+ const v3 = Number(n3.text.replace(/_/g, ""));
5199
+ if (Number.isFinite(v3) && v3 >= min) {
5200
+ found = true;
5201
+ return;
5202
+ }
5203
+ }
5204
+ ts.forEachChild(n3, visit);
5205
+ };
5206
+ visit(node);
5207
+ return found;
5208
+ }
5016
5209
 
5017
5210
  // src/lib/ai-decorators.ts
5018
5211
  var FILE_SCAN_HEAD_LINES = 40;
@@ -5183,6 +5376,21 @@ var PATTERNS2 = [
5183
5376
  id: "ai-sql-template",
5184
5377
  re: /(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$\{/i,
5185
5378
  message: "Possible dynamic SQL/string \u2014 ensure parameterization, not string concat."
5379
+ },
5380
+ /**
5381
+ * Classic C-style loop with `<= ...length` — often an off-by-one with `charAt(i)` / array indexing
5382
+ * (index `length` is out of range). Prefer `< length`, `for...of`, or `Array.from`.
5383
+ */
5384
+ {
5385
+ id: "ai-for-lte-length",
5386
+ re: /for\s*\(\s*(?:let|var|const)\s+\w+\s*=\s*\d+\s*;\s*\w+\s*<=\s*[^;)]+\.length\s*;/,
5387
+ message: "Loop condition uses `<= ...length` \u2014 often an extra iteration (e.g. `charAt(length)` is empty). Prefer `< ...length` or a safer iteration style."
5388
+ },
5389
+ /** AI often pastes broad eslint suppression without review. */
5390
+ {
5391
+ id: "ai-eslint-disable",
5392
+ re: /eslint-disable(?:-next-line|-line)?\b/i,
5393
+ message: "ESLint disable in AI-marked code \u2014 confirm the rule violation is understood and cannot be fixed properly."
5186
5394
  }
5187
5395
  ];
5188
5396
  function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
@@ -5198,6 +5406,16 @@ function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
5198
5406
  });
5199
5407
  }
5200
5408
  }
5409
+ const astLines = scanDiscardedExpensiveCalls(regionText, rel, regionStartLine);
5410
+ for (const line of astLines) {
5411
+ findings.push({
5412
+ id: "ai-discarded-expensive-call",
5413
+ severity: sev(gate),
5414
+ message: `${tag} Call discards the return value of a local function whose body includes a loop with a very large numeric bound \u2014 likely wasted CPU on every run (e.g. remove the call or move work to an effect / memo with a real dependency).`,
5415
+ file: rel,
5416
+ detail: `line ${line}`
5417
+ });
5418
+ }
5201
5419
  }
5202
5420
  async function runAiAssistedStrict(cwd, config, pr) {
5203
5421
  const t0 = performance.now();
@@ -5321,6 +5539,26 @@ function escapeHtml(s3) {
5321
5539
  return s3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5322
5540
  }
5323
5541
 
5542
+ // src/report/check-descriptions.ts
5543
+ var CHECK_DESCRIPTIONS = {
5544
+ eslint: "Runs ESLint using your project config. Flags style, correctness, and framework-specific issues in the repo or PR-scoped files.",
5545
+ prettier: "Checks that files match Prettier formatting. Catches unformatted edits so the codebase stays consistent.",
5546
+ typescript: "Runs the TypeScript compiler (tsc --noEmit) on the project. Surfaces type errors before merge.",
5547
+ secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
5548
+ cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
5549
+ "dead-code": "Runs ts-prune to find unused exports in the TypeScript project. Helps trim dead surface area.",
5550
+ bundle: "Measures total size of configured build artifacts (glob) and compares to a checked-in baseline. Flags large regressions in shipped JS/CSS.",
5551
+ "core-web-vitals": "Static hints in JSX/TSX related to Core Web Vitals (e.g. LCP-friendly images, main-thread hygiene). Not a substitute for real field metrics.",
5552
+ "ai-assisted-strict": "When the PR is AI-assisted or code is marked with @frontguard-ai decorators, scans those regions for risky patterns (eval, XSS sinks, etc.) and a few AST heuristics (e.g. discarded hot loops).",
5553
+ "pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
5554
+ "pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
5555
+ "ts-any-delta": "Diffs the branch against a base ref and counts newly added uses of the TypeScript any type. Helps stop gradual loss of type safety.",
5556
+ "custom-rules": "Runs optional file/content rules you define in FrontGuard config (regex or structured checks on paths). Skipped when no rules are configured."
5557
+ };
5558
+ function getCheckDescription(checkId) {
5559
+ return CHECK_DESCRIPTIONS[checkId] ?? `FrontGuard check "${checkId}". See your frontguard config and docs for behavior.`;
5560
+ }
5561
+
5324
5562
  // src/report/html-report.ts
5325
5563
  function parseLineHint(detail) {
5326
5564
  if (!detail) return 0;
@@ -5360,6 +5598,205 @@ function statusDot(r4) {
5360
5598
  return '<span class="dot dot-block" title="Blocking"></span>';
5361
5599
  return '<span class="dot dot-warn" title="Issues"></span>';
5362
5600
  }
5601
+ var CHECKS_TABLE_STYLES = `
5602
+ table.results {
5603
+ width: 100%;
5604
+ border-collapse: collapse;
5605
+ font-size: 0.875rem;
5606
+ background: var(--surface);
5607
+ border-radius: var(--radius);
5608
+ overflow: hidden;
5609
+ border: 1px solid var(--border);
5610
+ box-shadow: var(--shadow);
5611
+ }
5612
+ table.results th, table.results td {
5613
+ padding: 0.55rem 0.85rem;
5614
+ text-align: left;
5615
+ border-bottom: 1px solid var(--border);
5616
+ }
5617
+ table.results tr:last-child td { border-bottom: none; }
5618
+ table.results thead th {
5619
+ background: #f1f5f9;
5620
+ color: var(--muted);
5621
+ font-weight: 600;
5622
+ font-size: 0.72rem;
5623
+ text-transform: uppercase;
5624
+ letter-spacing: 0.04em;
5625
+ }
5626
+ .td-icon { width: 2rem; vertical-align: middle; }
5627
+ .td-check { vertical-align: middle; }
5628
+ .td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
5629
+ .check-title-cell {
5630
+ display: inline-flex;
5631
+ align-items: center;
5632
+ gap: 0.35rem;
5633
+ flex-wrap: nowrap;
5634
+ }
5635
+ .check-name { font-weight: 600; }
5636
+ .check-info-wrap {
5637
+ position: relative;
5638
+ display: inline-flex;
5639
+ align-items: center;
5640
+ flex-shrink: 0;
5641
+ }
5642
+ .check-info {
5643
+ display: inline-flex;
5644
+ align-items: center;
5645
+ justify-content: center;
5646
+ width: 1.125rem;
5647
+ height: 1.125rem;
5648
+ padding: 0;
5649
+ margin: 0;
5650
+ border: 1px solid var(--border);
5651
+ border-radius: 50%;
5652
+ background: #f1f5f9;
5653
+ color: var(--muted);
5654
+ font-size: 0.62rem;
5655
+ font-weight: 700;
5656
+ font-style: normal;
5657
+ line-height: 1;
5658
+ cursor: help;
5659
+ flex-shrink: 0;
5660
+ }
5661
+ .check-info:hover,
5662
+ .check-info:focus-visible {
5663
+ border-color: var(--accent);
5664
+ color: var(--accent);
5665
+ background: var(--accent-soft);
5666
+ outline: none;
5667
+ }
5668
+ .check-tooltip {
5669
+ position: absolute;
5670
+ left: 50%;
5671
+ bottom: calc(100% + 8px);
5672
+ transform: translateX(-50%);
5673
+ min-width: 12rem;
5674
+ max-width: min(22rem, 86vw);
5675
+ padding: 0.55rem 0.65rem;
5676
+ background: var(--text);
5677
+ color: #f8fafc;
5678
+ font-size: 0.78rem;
5679
+ font-weight: 400;
5680
+ line-height: 1.45;
5681
+ border-radius: 6px;
5682
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.18);
5683
+ z-index: 50;
5684
+ opacity: 0;
5685
+ visibility: hidden;
5686
+ pointer-events: none;
5687
+ transition: opacity 0.12s ease, visibility 0.12s ease;
5688
+ text-align: left;
5689
+ }
5690
+ .check-info-wrap:hover .check-tooltip,
5691
+ .check-info-wrap:focus-within .check-tooltip {
5692
+ opacity: 1;
5693
+ visibility: visible;
5694
+ }
5695
+ .check-tooltip::after {
5696
+ content: '';
5697
+ position: absolute;
5698
+ top: 100%;
5699
+ left: 50%;
5700
+ margin-left: -6px;
5701
+ border: 6px solid transparent;
5702
+ border-top-color: var(--text);
5703
+ }
5704
+ .dot {
5705
+ display: inline-block;
5706
+ width: 8px;
5707
+ height: 8px;
5708
+ border-radius: 50%;
5709
+ }
5710
+ .dot-ok { background: var(--ok); }
5711
+ .dot-warn { background: var(--warn); }
5712
+ .dot-block { background: var(--block); }
5713
+ .dot-skip { background: #cbd5e1; }
5714
+ `;
5715
+ function renderCheckTableRows(results) {
5716
+ return results.map((r4) => {
5717
+ const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5718
+ const help = escapeHtml(getCheckDescription(r4.checkId));
5719
+ const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
5720
+ 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>`;
5721
+ 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>`;
5722
+ }).join("\n");
5723
+ }
5724
+ function buildChecksSnapshotHtml(p2) {
5725
+ const { riskScore, mode, results, warns, infos, blocks } = p2;
5726
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5727
+ const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5728
+ const checkRows = renderCheckTableRows(results);
5729
+ return `<!DOCTYPE html>
5730
+ <html lang="en">
5731
+ <head>
5732
+ <meta charset="utf-8" />
5733
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5734
+ <title>FrontGuard \u2014 Checks</title>
5735
+ <style>
5736
+ :root {
5737
+ --bg: #f8fafc;
5738
+ --surface: #ffffff;
5739
+ --text: #0f172a;
5740
+ --muted: #64748b;
5741
+ --border: #e2e8f0;
5742
+ --accent: #4f46e5;
5743
+ --accent-soft: #eef2ff;
5744
+ --block: #dc2626;
5745
+ --warn: #d97706;
5746
+ --info: #0284c7;
5747
+ --ok: #16a34a;
5748
+ --radius: 10px;
5749
+ --shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
5750
+ }
5751
+ * { box-sizing: border-box; }
5752
+ body {
5753
+ margin: 0;
5754
+ padding: 1.25rem 1.5rem 1.5rem;
5755
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
5756
+ background: var(--bg);
5757
+ color: var(--text);
5758
+ line-height: 1.55;
5759
+ font-size: 15px;
5760
+ max-width: 920px;
5761
+ }
5762
+ .brand {
5763
+ font-size: 0.75rem;
5764
+ font-weight: 600;
5765
+ letter-spacing: 0.12em;
5766
+ text-transform: uppercase;
5767
+ color: var(--muted);
5768
+ margin-bottom: 0.35rem;
5769
+ }
5770
+ .h2 {
5771
+ font-size: 1rem;
5772
+ font-weight: 600;
5773
+ margin: 0 0 0.5rem;
5774
+ color: var(--text);
5775
+ letter-spacing: -0.02em;
5776
+ }
5777
+ .snap-meta {
5778
+ font-size: 0.8rem;
5779
+ color: var(--muted);
5780
+ margin: 0 0 0.85rem;
5781
+ }
5782
+ .snap-meta strong { color: var(--text); font-weight: 600; }
5783
+ .risk-low { color: var(--ok); }
5784
+ .risk-med { color: var(--warn); }
5785
+ .risk-high { color: var(--block); }
5786
+ ${CHECKS_TABLE_STYLES}
5787
+ </style>
5788
+ </head>
5789
+ <body>
5790
+ <div class="brand">FrontGuard</div>
5791
+ <h2 class="h2">Checks</h2>
5792
+ <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>
5793
+ <table class="results">
5794
+ <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5795
+ <tbody>${checkRows}</tbody>
5796
+ </table>
5797
+ </body>
5798
+ </html>`;
5799
+ }
5363
5800
  function renderFindingCard(cwd, r4, f4) {
5364
5801
  const d3 = normalizeFinding(cwd, f4);
5365
5802
  const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
@@ -5384,10 +5821,7 @@ function buildHtmlReport(p2) {
5384
5821
  } = p2;
5385
5822
  const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5386
5823
  const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5387
- const checkRows = results.map((r4) => {
5388
- const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5389
- return `<tr><td class="td-icon">${statusDot(r4)}</td><td><strong class="check-name">${escapeHtml(r4.checkId)}</strong></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>`;
5390
- }).join("\n");
5824
+ const checkRows = renderCheckTableRows(results);
5391
5825
  const blockItems = sortFindings(
5392
5826
  cwd,
5393
5827
  results.flatMap(
@@ -5531,43 +5965,7 @@ function buildHtmlReport(p2) {
5531
5965
  font-weight: 500;
5532
5966
  background: #f1f5f9;
5533
5967
  }
5534
- table.results {
5535
- width: 100%;
5536
- border-collapse: collapse;
5537
- font-size: 0.875rem;
5538
- background: var(--surface);
5539
- border-radius: var(--radius);
5540
- overflow: hidden;
5541
- border: 1px solid var(--border);
5542
- box-shadow: var(--shadow);
5543
- }
5544
- table.results th, table.results td {
5545
- padding: 0.55rem 0.85rem;
5546
- text-align: left;
5547
- border-bottom: 1px solid var(--border);
5548
- }
5549
- table.results tr:last-child td { border-bottom: none; }
5550
- table.results thead th {
5551
- background: #f1f5f9;
5552
- color: var(--muted);
5553
- font-weight: 600;
5554
- font-size: 0.72rem;
5555
- text-transform: uppercase;
5556
- letter-spacing: 0.04em;
5557
- }
5558
- .td-icon { width: 2rem; vertical-align: middle; }
5559
- .td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
5560
- .check-name { font-weight: 600; }
5561
- .dot {
5562
- display: inline-block;
5563
- width: 8px;
5564
- height: 8px;
5565
- border-radius: 50%;
5566
- }
5567
- .dot-ok { background: var(--ok); }
5568
- .dot-warn { background: var(--warn); }
5569
- .dot-block { background: var(--block); }
5570
- .dot-skip { background: #cbd5e1; }
5968
+ ${CHECKS_TABLE_STYLES}
5571
5969
  .panel {
5572
5970
  background: var(--surface);
5573
5971
  border: 1px solid var(--border);
@@ -5746,7 +6144,14 @@ function buildReport(stack, pr, results, options) {
5746
6144
  lines,
5747
6145
  llmAppendix: options?.llmAppendix ?? null
5748
6146
  }) : null;
5749
- return { riskScore, stack, pr, results, markdown, consoleText, html };
6147
+ const checksSnapshotHtml = options?.emitChecksSnapshot === true ? buildChecksSnapshotHtml({
6148
+ riskScore,
6149
+ mode,
6150
+ results,
6151
+ warns,
6152
+ infos,
6153
+ blocks}) : null;
6154
+ return { riskScore, stack, pr, results, markdown, consoleText, html, checksSnapshotHtml };
5750
6155
  }
5751
6156
  function scoreRisk(blocks, warns, lines, files) {
5752
6157
  let score = 0;
@@ -6507,11 +6912,25 @@ async function runFrontGuard(opts) {
6507
6912
  mode,
6508
6913
  llmAppendix,
6509
6914
  cwd: opts.cwd,
6510
- emitHtml: Boolean(opts.htmlOut)
6915
+ emitHtml: Boolean(opts.htmlOut),
6916
+ emitChecksSnapshot: Boolean(opts.checksSnapshotOut)
6511
6917
  });
6512
6918
  if (opts.htmlOut && report.html) {
6513
6919
  await fs.writeFile(opts.htmlOut, report.html, "utf8");
6514
6920
  }
6921
+ if (opts.checksSnapshotOut && report.checksSnapshotHtml) {
6922
+ const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
6923
+ await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
6924
+ const fileUrl = pathToFileURL(snapPath).href;
6925
+ g.stderr.write(
6926
+ `
6927
+ FrontGuard: wrote checks snapshot HTML to ${snapPath} (screenshot this file for PR comments).
6928
+ Example: npx playwright screenshot "${fileUrl}" frontguard-checks.png
6929
+ Host the PNG at an HTTPS URL, then set FRONTGUARD_CHECKS_IMAGE_URL before generating the PR comment.
6930
+
6931
+ `
6932
+ );
6933
+ }
6515
6934
  if (opts.prCommentOut) {
6516
6935
  const snippet = formatBitbucketPrSnippet(report);
6517
6936
  const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
@@ -6521,6 +6940,7 @@ async function runFrontGuard(opts) {
6521
6940
  FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6522
6941
  Use ONLY this file in your POST \u2026/pullrequests/{id}/comments payload (content.raw).
6523
6942
  Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
6943
+ Optional: set FRONTGUARD_CHECKS_IMAGE_URL so the comment includes a checks summary image.
6524
6944
 
6525
6945
  `
6526
6946
  );
@@ -6573,6 +6993,10 @@ var run = defineCommand({
6573
6993
  type: "string",
6574
6994
  description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
6575
6995
  },
6996
+ checksSnapshotOut: {
6997
+ type: "string",
6998
+ description: "Write HTML with only the Checks table (screenshot \u2192 PNG \u2192 FRONTGUARD_CHECKS_IMAGE_URL in PR comment)"
6999
+ },
6576
7000
  prCommentOut: {
6577
7001
  type: "string",
6578
7002
  description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
@@ -6585,6 +7009,7 @@ var run = defineCommand({
6585
7009
  enforce: Boolean(args.enforce),
6586
7010
  append: typeof args.append === "string" ? args.append : null,
6587
7011
  htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
7012
+ checksSnapshotOut: typeof args.checksSnapshotOut === "string" ? args.checksSnapshotOut : null,
6588
7013
  prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
6589
7014
  });
6590
7015
  }