@cleartrip/frontguard 0.1.8 → 0.2.0

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,10 +6,10 @@ import * as tty from 'tty';
6
6
  import { WriteStream } from 'tty';
7
7
  import path4, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
8
  import fs from 'fs/promises';
9
+ import { fileURLToPath, pathToFileURL } from 'url';
10
+ import { execFileSync, spawn } from 'child_process';
9
11
  import { createRequire } from 'module';
10
- import fs4 from 'fs';
11
- import { pathToFileURL } from 'url';
12
- import { spawn } from 'child_process';
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';
@@ -2398,50 +2398,9 @@ async function runMain(cmd, opts = {}) {
2398
2398
  process.exit(1);
2399
2399
  }
2400
2400
  }
2401
- var WORKFLOW = `name: FrontGuard
2402
-
2403
- on:
2404
- pull_request:
2405
- types: [opened, synchronize, reopened]
2406
-
2407
- permissions:
2408
- contents: read
2409
- pull-requests: write
2410
-
2411
- concurrency:
2412
- group: frontguard-\${{ github.workflow }}-\${{ github.event.pull_request.number || github.ref }}
2413
- cancel-in-progress: true
2414
-
2415
- jobs:
2416
- review-brief:
2417
- runs-on: ubuntu-latest
2418
- steps:
2419
- - uses: actions/checkout@v4
2420
- with:
2421
- fetch-depth: 0
2422
-
2423
- - uses: actions/setup-node@v4
2424
- with:
2425
- node-version: 20
2426
-
2427
- - name: Install dependencies
2428
- run: |
2429
- if [ -f pnpm-lock.yaml ]; then
2430
- corepack enable
2431
- pnpm install --frozen-lockfile || pnpm install
2432
- elif [ -f yarn.lock ]; then
2433
- yarn install --frozen-lockfile || yarn install
2434
- elif [ -f package-lock.json ]; then
2435
- npm ci || npm install
2436
- else
2437
- npm install
2438
- fi
2439
-
2440
- - name: FrontGuard (Phase 1 \u2014 warn-only)
2441
- run: npx @cleartrip/frontguard run --ci
2442
- env:
2443
- GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2444
- `;
2401
+ function packageRoot() {
2402
+ return path4.resolve(path4.dirname(fileURLToPath(import.meta.url)), "..");
2403
+ }
2445
2404
  var CONFIG = `import { defineConfig } from '@cleartrip/frontguard'
2446
2405
 
2447
2406
  export default defineConfig({
@@ -2458,7 +2417,7 @@ export default defineConfig({
2458
2417
  // },
2459
2418
 
2460
2419
  // checks: {
2461
- // bundle: { enabled: true, maxDeltaBytes: 50_000, maxTotalBytes: null },
2420
+ // bundle: { baselineRef: 'main', maxDeltaBytes: 50_000 }, // ref for git baseline file
2462
2421
  // cycles: { enabled: true },
2463
2422
  // deadCode: { enabled: true, gate: 'info' },
2464
2423
  // // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
@@ -2496,27 +2455,35 @@ If **Yes**, list tools and what they touched (helps reviewers run a stricter fir
2496
2455
  ## AI assistance (optional detail)
2497
2456
  - [ ] I have reviewed every AI-suggested line for security, auth, and product correctness
2498
2457
  `;
2499
- async function ensureDir(dir) {
2500
- await fs.mkdir(dir, { recursive: true });
2501
- }
2502
2458
  async function initFrontGuard(cwd) {
2503
- const gh = path4.join(cwd, ".github", "workflows");
2504
- await ensureDir(gh);
2505
- const wfPath = path4.join(gh, "frontguard.yml");
2506
- await fs.writeFile(wfPath, WORKFLOW, "utf8");
2459
+ const root = packageRoot();
2460
+ const tplPath = path4.join(root, "templates", "bitbucket-pipelines.yml");
2461
+ const outPipeline = path4.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2462
+ try {
2463
+ await fs.access(outPipeline);
2464
+ } catch {
2465
+ try {
2466
+ const yml = await fs.readFile(tplPath, "utf8");
2467
+ await fs.writeFile(outPipeline, yml, "utf8");
2468
+ } catch {
2469
+ await fs.writeFile(
2470
+ outPipeline,
2471
+ "# Copy bitbucket-pipelines.yml from @cleartrip/frontguard/templates in node_modules\n",
2472
+ "utf8"
2473
+ );
2474
+ }
2475
+ }
2507
2476
  const cfgPath = path4.join(cwd, "frontguard.config.js");
2508
2477
  try {
2509
2478
  await fs.access(cfgPath);
2510
2479
  } catch {
2511
2480
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2512
2481
  }
2513
- const tplRoot = path4.join(cwd, ".github");
2514
- await ensureDir(tplRoot);
2515
- const tplPath = path4.join(tplRoot, "pull_request_template.md");
2482
+ const tplPr = path4.join(cwd, "pull_request_template.md");
2516
2483
  try {
2517
- await fs.access(tplPath);
2484
+ await fs.access(tplPr);
2518
2485
  } catch {
2519
- await fs.writeFile(tplPath, PR_TEMPLATE, "utf8");
2486
+ await fs.writeFile(tplPr, PR_TEMPLATE, "utf8");
2520
2487
  }
2521
2488
  }
2522
2489
 
@@ -2544,6 +2511,11 @@ function bitbucketDownloadsPageUrl() {
2544
2511
  }
2545
2512
  function formatBitbucketPrSnippet(report) {
2546
2513
  const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
2514
+ const linkOnly = process.env.FRONTGUARD_BITBUCKET_COMMENT_LINK_ONLY === "1";
2515
+ if (linkOnly && publicReport) {
2516
+ return publicReport.endsWith("\n") ? publicReport : `${publicReport}
2517
+ `;
2518
+ }
2547
2519
  const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
2548
2520
  const downloadsPage = bitbucketDownloadsPageUrl();
2549
2521
  const pipeline = bitbucketPipelineResultsUrl();
@@ -2649,110 +2621,86 @@ function parseAiDisclosure(body) {
2649
2621
  return { assisted, explicitNo, ambiguous };
2650
2622
  }
2651
2623
 
2652
- // src/ci/github.ts
2653
- async function readGithubEvent() {
2654
- const p2 = process.env.GITHUB_EVENT_PATH;
2655
- if (!p2) return null;
2656
- try {
2657
- const raw = await fs.readFile(p2, "utf8");
2658
- const payload = JSON.parse(raw);
2659
- const pr = payload.pull_request;
2660
- if (!pr) return null;
2661
- const files = (pr.files ?? []).map((f4) => f4.filename ?? "").filter(Boolean);
2662
- const body = pr.body ?? "";
2663
- const ai = parseAiDisclosure(body);
2664
- return {
2665
- number: pr.number ?? 0,
2666
- title: pr.title ?? "",
2667
- body,
2668
- baseRef: pr.base?.ref ?? "main",
2669
- headRef: pr.head?.ref ?? "",
2670
- additions: pr.additions ?? 0,
2671
- deletions: pr.deletions ?? 0,
2672
- changedFiles: pr.changed_files ?? files.length,
2673
- files,
2674
- aiAssisted: ai.assisted,
2675
- aiExplicitNo: ai.explicitNo,
2676
- aiDisclosureAmbiguous: ai.ambiguous
2677
- };
2678
- } catch {
2679
- return null;
2680
- }
2681
- }
2682
-
2683
- // src/lib/http-fetch.ts
2684
- function getFetch() {
2685
- const f4 = globalThis.fetch;
2686
- if (typeof f4 !== "function") {
2687
- throw new Error(
2688
- "FrontGuard needs Node.js 18+ (global fetch). Use NODE_VERSION / image >= 18 in CI."
2689
- );
2690
- }
2691
- return f4;
2692
- }
2693
-
2694
- // src/ci/pr-comment.ts
2695
- var MARKER = "<!-- frontguard:brief -->";
2696
- async function resolvePrNumber() {
2697
- const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
2698
- const n3 = Number(raw);
2699
- if (Number.isFinite(n3) && n3 > 0) return n3;
2700
- const path17 = process.env.GITHUB_EVENT_PATH;
2701
- if (!path17) return null;
2702
- try {
2703
- const payload = JSON.parse(await fs.readFile(path17, "utf8"));
2704
- const num = payload.pull_request?.number;
2705
- return typeof num === "number" && num > 0 ? num : null;
2706
- } catch {
2707
- return null;
2624
+ // src/ci/pr-context.ts
2625
+ function gitTrimmed(cwd, args) {
2626
+ return execFileSync("git", ["-C", cwd, ...args], {
2627
+ encoding: "utf8",
2628
+ maxBuffer: 20 * 1024 * 1024
2629
+ }).trimEnd();
2630
+ }
2631
+ function resolveCompareRef(cwd, destBranch) {
2632
+ const safe = destBranch.trim();
2633
+ if (!safe || safe.startsWith("-") || safe.includes("..")) return null;
2634
+ const candidates = [safe, `origin/${safe}`];
2635
+ for (const c4 of candidates) {
2636
+ try {
2637
+ gitTrimmed(cwd, ["rev-parse", "--verify", `${c4}^{commit}`]);
2638
+ return c4;
2639
+ } catch {
2640
+ }
2708
2641
  }
2642
+ return null;
2709
2643
  }
2710
- async function upsertBriefComment(body) {
2711
- const token = process.env.GITHUB_TOKEN;
2712
- const repo = process.env.GITHUB_REPOSITORY;
2713
- if (!token || !repo) {
2714
- return;
2715
- }
2716
- const [owner, name] = repo.split("/");
2717
- if (!owner || !name) return;
2718
- const prNumber = await resolvePrNumber();
2719
- if (!prNumber) {
2720
- return;
2644
+ function parseNumstat(output) {
2645
+ let additions = 0;
2646
+ let deletions = 0;
2647
+ const files = [];
2648
+ for (const line of output.split("\n")) {
2649
+ const t3 = line.trim();
2650
+ if (!t3) continue;
2651
+ const tab = t3.indexOf(" ");
2652
+ if (tab < 0) continue;
2653
+ const tab2 = t3.indexOf(" ", tab + 1);
2654
+ if (tab2 < 0) continue;
2655
+ const aStr = t3.slice(0, tab);
2656
+ const dStr = t3.slice(tab + 1, tab2);
2657
+ const path17 = t3.slice(tab2 + 1);
2658
+ const a3 = aStr === "-" ? 0 : Number(aStr);
2659
+ const d3 = dStr === "-" ? 0 : Number(dStr);
2660
+ if (!Number.isFinite(a3) || !Number.isFinite(d3)) continue;
2661
+ additions += a3;
2662
+ deletions += d3;
2663
+ if (path17) files.push(path17);
2664
+ }
2665
+ return { additions, deletions, files };
2666
+ }
2667
+ function buildBitbucketPrContext(cwd) {
2668
+ const prId = process.env.BITBUCKET_PR_ID?.trim();
2669
+ if (!prId) return null;
2670
+ const dest = process.env.BITBUCKET_PR_DESTINATION_BRANCH?.trim() || "main";
2671
+ const body = process.env.FRONTGUARD_PR_BODY?.trim() ?? "";
2672
+ const headRef = process.env.BITBUCKET_PR_SOURCE_BRANCH?.trim() ?? "";
2673
+ const title = process.env.BITBUCKET_PR_TITLE?.trim() ?? "";
2674
+ let additions = 0;
2675
+ let deletions = 0;
2676
+ let files = [];
2677
+ const destRef = resolveCompareRef(cwd, dest);
2678
+ if (destRef) {
2679
+ try {
2680
+ const raw = gitTrimmed(cwd, ["diff", `${destRef}...HEAD`, "--numstat"]);
2681
+ const stat = parseNumstat(raw);
2682
+ additions = stat.additions;
2683
+ deletions = stat.deletions;
2684
+ files = stat.files;
2685
+ } catch {
2686
+ }
2721
2687
  }
2722
- const apiBase = process.env.GITHUB_API_URL ?? "https://api.github.com";
2723
- const headers = {
2724
- authorization: `Bearer ${token}`,
2725
- accept: "application/vnd.github+json",
2726
- "x-github-api-version": "2022-11-28",
2727
- "content-type": "application/json"
2688
+ const ai = parseAiDisclosure(body);
2689
+ return {
2690
+ number: Number.parseInt(prId, 10) || 0,
2691
+ title,
2692
+ body,
2693
+ baseRef: dest,
2694
+ headRef,
2695
+ additions,
2696
+ deletions,
2697
+ changedFiles: files.length,
2698
+ files,
2699
+ ...ai
2728
2700
  };
2729
- const prefixed = `${MARKER}
2730
- ${body}`;
2731
- const listUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments?per_page=100`;
2732
- const fetch = getFetch();
2733
- const listRes = await fetch(listUrl, { headers });
2734
- if (!listRes.ok) {
2735
- return;
2736
- }
2737
- const comments = await listRes.json();
2738
- const existing = comments.find(
2739
- (c4) => typeof c4.body === "string" && c4.body.includes(MARKER)
2740
- );
2741
- if (existing) {
2742
- const patchUrl = `${apiBase}/repos/${owner}/${name}/issues/comments/${existing.id}`;
2743
- await fetch(patchUrl, {
2744
- method: "PATCH",
2745
- headers,
2746
- body: JSON.stringify({ body: prefixed })
2747
- });
2748
- return;
2749
- }
2750
- const postUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments`;
2751
- await fetch(postUrl, {
2752
- method: "POST",
2753
- headers,
2754
- body: JSON.stringify({ body: prefixed })
2755
- });
2701
+ }
2702
+ function readPrContext(cwd) {
2703
+ return buildBitbucketPrContext(cwd);
2756
2704
  }
2757
2705
 
2758
2706
  // node_modules/defu/dist/defu.mjs
@@ -2839,7 +2787,7 @@ var defaultConfig = {
2839
2787
  tsAnyDeltaToBlock: true
2840
2788
  }
2841
2789
  },
2842
- prSize: { warnLines: 400, softBlockLines: 800 },
2790
+ prSize: { enabled: true, warnLines: 400, softBlockLines: 800 },
2843
2791
  tsAnyDelta: {
2844
2792
  enabled: true,
2845
2793
  gate: "warn",
@@ -2847,24 +2795,25 @@ var defaultConfig = {
2847
2795
  maxAdded: 0
2848
2796
  },
2849
2797
  cycles: {
2850
- enabled: false,
2798
+ enabled: true,
2851
2799
  gate: "warn",
2852
2800
  entries: ["src"],
2853
2801
  extraArgs: []
2854
2802
  },
2855
2803
  deadCode: {
2856
- enabled: false,
2804
+ enabled: true,
2857
2805
  gate: "info",
2858
2806
  extraArgs: [],
2859
2807
  maxReportLines: 80
2860
2808
  },
2861
2809
  bundle: {
2862
- enabled: false,
2810
+ enabled: true,
2863
2811
  gate: "warn",
2864
2812
  runBuild: true,
2865
2813
  buildCommand: "npm run build",
2866
2814
  measureGlobs: ["dist/**/*", "build/static/**/*", ".next/static/**/*"],
2867
2815
  baselinePath: ".frontguard/bundle-baseline.json",
2816
+ baselineRef: "main",
2868
2817
  maxDeltaBytes: null,
2869
2818
  maxTotalBytes: null
2870
2819
  },
@@ -2931,7 +2880,7 @@ async function loadConfig(cwd) {
2931
2880
  let userFile = null;
2932
2881
  for (const name of CONFIG_NAMES) {
2933
2882
  const full = path4.join(cwd, name);
2934
- if (!fs4.existsSync(full)) continue;
2883
+ if (!fs2.existsSync(full)) continue;
2935
2884
  try {
2936
2885
  const mod = await importConfig(full);
2937
2886
  userFile = normalizeExport(mod);
@@ -4078,7 +4027,7 @@ function runPrHygiene(config, pr) {
4078
4027
  checkId: "pr-hygiene",
4079
4028
  findings: [],
4080
4029
  durationMs: Math.round(performance.now() - t0),
4081
- skipped: "not running in GitHub pull_request context"
4030
+ skipped: "no PR context (run in a Bitbucket PR pipeline with BITBUCKET_PR_ID, or set FRONTGUARD_PR_BODY for description checks)"
4082
4031
  };
4083
4032
  }
4084
4033
  const findings = [];
@@ -4159,12 +4108,20 @@ function sectionMentioned(body, hint) {
4159
4108
  // src/checks/pr-size.ts
4160
4109
  function runPrSize(config, pr) {
4161
4110
  const t0 = performance.now();
4111
+ if (!config.checks.prSize.enabled) {
4112
+ return {
4113
+ checkId: "pr-size",
4114
+ findings: [],
4115
+ durationMs: 0,
4116
+ skipped: "disabled in config"
4117
+ };
4118
+ }
4162
4119
  if (!pr) {
4163
4120
  return {
4164
4121
  checkId: "pr-size",
4165
4122
  findings: [],
4166
4123
  durationMs: Math.round(performance.now() - t0),
4167
- skipped: "not running in GitHub pull_request context"
4124
+ skipped: "no PR context (run in a Bitbucket pull-request pipeline so BITBUCKET_PR_* and git diff are available)"
4168
4125
  };
4169
4126
  }
4170
4127
  const findings = [];
@@ -4234,13 +4191,18 @@ async function gitDiffForReview(cwd, baseRef, maxChars) {
4234
4191
  }
4235
4192
  }
4236
4193
  async function resolveDiffBaseRef(cwd, fallback) {
4237
- const gh = process.env.GITHUB_BASE_REF;
4238
- if (gh) {
4239
- const origin = `origin/${gh}`;
4194
+ const bb = process.env.BITBUCKET_PR_DESTINATION_BRANCH?.trim();
4195
+ if (bb) {
4196
+ const origin = `origin/${bb}`;
4240
4197
  try {
4241
4198
  await W2("git", ["rev-parse", "--verify", origin], { nodeOptions: { cwd } });
4242
4199
  return origin;
4243
4200
  } catch {
4201
+ try {
4202
+ await W2("git", ["rev-parse", "--verify", bb], { nodeOptions: { cwd } });
4203
+ return bb;
4204
+ } catch {
4205
+ }
4244
4206
  }
4245
4207
  }
4246
4208
  try {
@@ -4519,6 +4481,39 @@ async function gitOkQuick(cwd) {
4519
4481
  function tokenizeCommand(cmd) {
4520
4482
  return cmd.trim().split(/\s+/).map((t3) => t3.trim()).filter(Boolean);
4521
4483
  }
4484
+ function npmScriptFromBuildCommand(cmd) {
4485
+ const t3 = cmd.trim();
4486
+ if (/^yarn\s+build\b/i.test(t3)) return "build";
4487
+ const np = /^(?:npm|pnpm)\s+run\s+(\S+)/i.exec(t3);
4488
+ if (np?.[1]) return np[1];
4489
+ const yr = /^yarn\s+run\s+(\S+)/i.exec(t3);
4490
+ if (yr?.[1]) return yr[1];
4491
+ return null;
4492
+ }
4493
+ async function bundleBuildPrecheck(cwd, buildCommand) {
4494
+ const script = npmScriptFromBuildCommand(buildCommand);
4495
+ if (!script) return { run: true };
4496
+ let scripts;
4497
+ try {
4498
+ const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
4499
+ const pkg = JSON.parse(raw);
4500
+ scripts = pkg.scripts;
4501
+ } catch {
4502
+ return {
4503
+ run: false,
4504
+ message: "Skipped bundle build \u2014 no readable package.json",
4505
+ detail: "Set checks.bundle.buildCommand to your real build (e.g. `vite build`), set checks.bundle.runBuild to false, or add a package.json with the matching script."
4506
+ };
4507
+ }
4508
+ if (!scripts?.[script]) {
4509
+ return {
4510
+ run: false,
4511
+ message: `Skipped bundle build \u2014 no scripts.${script} in package.json`,
4512
+ detail: "The bundle check runs a production build, then sums file sizes under checks.bundle.measureGlobs (dist/, build/static/, .next/static/, etc.). Libraries and non-web repos often have no `build` script \u2014 set checks.bundle.runBuild to false and optionally tune measureGlobs, or set checks.bundle.buildCommand to whatever produces your artifacts (e.g. `pnpm run compile`, `npx vite build`)."
4513
+ };
4514
+ }
4515
+ return { run: true };
4516
+ }
4522
4517
  async function runBundle(cwd, config, stack) {
4523
4518
  const t0 = performance.now();
4524
4519
  const cfg = config.checks.bundle;
@@ -4538,6 +4533,7 @@ async function runBundle(cwd, config, stack) {
4538
4533
  skipped: "skipped for React Native (configure web artifacts if needed)"
4539
4534
  };
4540
4535
  }
4536
+ const preFindings = [];
4541
4537
  if (cfg.runBuild) {
4542
4538
  const parts = tokenizeCommand(cfg.buildCommand);
4543
4539
  if (parts.length === 0) {
@@ -4553,21 +4549,31 @@ async function runBundle(cwd, config, stack) {
4553
4549
  durationMs: Math.round(performance.now() - t0)
4554
4550
  };
4555
4551
  }
4556
- const [bin, ...args] = parts;
4557
- const res = await W2(bin, args, { nodeOptions: { cwd } });
4558
- if ((res.exitCode ?? 0) !== 0) {
4559
- return {
4560
- checkId: "bundle",
4561
- findings: [
4562
- {
4563
- id: "bundle-build",
4564
- severity: gateSeverity4(cfg.gate),
4565
- message: "Build command failed \u2014 cannot measure bundle",
4566
- detail: [res.stdout, res.stderr].filter(Boolean).join("\n").slice(0, 8e3)
4567
- }
4568
- ],
4569
- durationMs: Math.round(performance.now() - t0)
4570
- };
4552
+ const pre = await bundleBuildPrecheck(cwd, cfg.buildCommand);
4553
+ if (!pre.run) {
4554
+ preFindings.push({
4555
+ id: "bundle-build-skipped",
4556
+ severity: "info",
4557
+ message: pre.message,
4558
+ detail: pre.detail
4559
+ });
4560
+ } else {
4561
+ const [bin, ...args] = parts;
4562
+ const res = await W2(bin, args, { nodeOptions: { cwd } });
4563
+ if ((res.exitCode ?? 0) !== 0) {
4564
+ return {
4565
+ checkId: "bundle",
4566
+ findings: [
4567
+ {
4568
+ id: "bundle-build",
4569
+ severity: gateSeverity4(cfg.gate),
4570
+ message: "Build command failed \u2014 cannot measure bundle",
4571
+ detail: [res.stdout, res.stderr].filter(Boolean).join("\n").slice(0, 8e3)
4572
+ }
4573
+ ],
4574
+ durationMs: Math.round(performance.now() - t0)
4575
+ };
4576
+ }
4571
4577
  }
4572
4578
  }
4573
4579
  const total = await sumGlobBytes(cwd, cfg.measureGlobs);
@@ -4575,6 +4581,7 @@ async function runBundle(cwd, config, stack) {
4575
4581
  return {
4576
4582
  checkId: "bundle",
4577
4583
  findings: [
4584
+ ...preFindings,
4578
4585
  {
4579
4586
  id: "bundle-empty",
4580
4587
  severity: "info",
@@ -4584,9 +4591,9 @@ async function runBundle(cwd, config, stack) {
4584
4591
  durationMs: Math.round(performance.now() - t0)
4585
4592
  };
4586
4593
  }
4587
- const baseRef = await gitOkQuick(cwd) ? await resolveDiffBaseRef(cwd, config.checks.tsAnyDelta.baseRef) : null;
4594
+ const baseRef = await gitOkQuick(cwd) ? await resolveDiffBaseRef(cwd, cfg.baselineRef) : null;
4588
4595
  const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
4589
- const findings = [];
4596
+ const findings = [...preFindings];
4590
4597
  const infoLines = [
4591
4598
  `Measured ${total} bytes (${(total / 1024 / 1024).toFixed(2)} MiB)`,
4592
4599
  baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
@@ -5065,8 +5072,8 @@ function buildHtmlReport(p2) {
5065
5072
  } else {
5066
5073
  for (const cid of checkOrder) {
5067
5074
  const group = sortFindings(cwd, byCheck.get(cid));
5068
- warningsHtml += `<h3 class="grp">${escapeHtml(cid)} <span class="count">(${group.length})</span></h3>`;
5069
- warningsHtml += group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5075
+ const cards = group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5076
+ warningsHtml += `<details class="warn-check"><summary>${escapeHtml(cid)} <span class="count">(${group.length})</span></summary><div class="details-body warn-check-body">${cards}</div></details>`;
5070
5077
  }
5071
5078
  }
5072
5079
  const infoHtml = infoItems.length === 0 ? '<p class="muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
@@ -5100,14 +5107,16 @@ function buildHtmlReport(p2) {
5100
5107
  }
5101
5108
  h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
5102
5109
  h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
5103
- h3.grp { margin: 1.5rem 0 0.5rem; font-size: 1rem; color: var(--warn); }
5104
- h3.grp .count { color: var(--muted); font-weight: normal; }
5110
+ details.warn-check { margin-bottom: 0.45rem; }
5111
+ details.warn-check:last-child { margin-bottom: 0; }
5112
+ details.warn-check > summary { color: var(--warn); font-size: 0.95rem; }
5113
+ details.warn-check .count { color: var(--muted); font-weight: normal; }
5114
+ .warn-check-body { padding-top: 0.35rem; }
5105
5115
  h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
5106
5116
  h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
5107
5117
  .badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
5108
5118
  .badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
5109
5119
  details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
5110
- details.open-default { border-color: #3d4f6a; }
5111
5120
  summary {
5112
5121
  cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
5113
5122
  list-style: none; display: flex; align-items: center; gap: 0.5rem;
@@ -5172,12 +5181,12 @@ function buildHtmlReport(p2) {
5172
5181
  <tbody>${checkRows}</tbody>
5173
5182
  </table>
5174
5183
 
5175
- <details class="open-default" open>
5184
+ <details>
5176
5185
  <summary>Blocking (${blocks})</summary>
5177
5186
  <div class="details-body">${blockingHtml}</div>
5178
5187
  </details>
5179
5188
 
5180
- <details class="open-default" open>
5189
+ <details>
5181
5190
  <summary>Warnings (${warns})</summary>
5182
5191
  <div class="details-body">${warningsHtml}</div>
5183
5192
  </details>
@@ -5592,6 +5601,17 @@ function formatConsole(p2) {
5592
5601
  return lines.join("\n");
5593
5602
  }
5594
5603
 
5604
+ // src/lib/http-fetch.ts
5605
+ function getFetch() {
5606
+ const f4 = globalThis.fetch;
5607
+ if (typeof f4 !== "function") {
5608
+ throw new Error(
5609
+ "FrontGuard needs Node.js 18+ (global fetch). Use NODE_VERSION / image >= 18 in CI."
5610
+ );
5611
+ }
5612
+ return f4;
5613
+ }
5614
+
5595
5615
  // src/llm/ollama.ts
5596
5616
  async function callOllamaChat(opts) {
5597
5617
  const fetch = getFetch();
@@ -5929,7 +5949,7 @@ async function runFrontGuard(opts) {
5929
5949
  const config = await loadConfig(opts.cwd);
5930
5950
  const mode = opts.enforce ? "enforce" : config.mode;
5931
5951
  const stack = await detectStack(opts.cwd);
5932
- const pr = await readGithubEvent();
5952
+ const pr = readPrContext(opts.cwd);
5933
5953
  const restrictFiles = pr?.files?.length ? pr.files : null;
5934
5954
  const [
5935
5955
  eslint,
@@ -6018,9 +6038,6 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6018
6038
  g.stdout.write(report.consoleText + "\n\n");
6019
6039
  g.stdout.write(report.markdown + "\n");
6020
6040
  }
6021
- if (opts.ci && g.env.GITHUB_TOKEN) {
6022
- await upsertBriefComment(report.markdown);
6023
- }
6024
6041
  const hasBlock = results.some((r4) => r4.findings.some((f4) => f4.severity === "block"));
6025
6042
  g.exitCode = mode === "enforce" && hasBlock ? 1 : 0;
6026
6043
  }
@@ -6029,13 +6046,13 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6029
6046
  var init2 = defineCommand({
6030
6047
  meta: {
6031
6048
  name: "init",
6032
- description: "Add workflow, PR template, and frontguard.config.js"
6049
+ description: "Add Bitbucket pipeline example, pull_request_template.md, and frontguard.config.js"
6033
6050
  },
6034
6051
  run: async () => {
6035
6052
  const cwd = g.cwd();
6036
6053
  await initFrontGuard(cwd);
6037
6054
  g.stdout.write(
6038
- "FrontGuard initialized.\n\nNext: add the package as a devDependency so CI matches local runs:\n npm install -D @cleartrip/frontguard\n yarn add -D @cleartrip/frontguard\n"
6055
+ "FrontGuard initialized for Bitbucket.\n\n \u2022 bitbucket-pipelines.frontguard.example.yml \u2014 merge into your pipeline\n \u2022 pull_request_template.md \u2014 PR description template (Bitbucket Cloud)\n \u2022 frontguard.config.js (if missing)\n\nAdd the package as a devDependency so CI matches local runs:\n npm install -D @cleartrip/frontguard\n yarn add -D @cleartrip/frontguard\n"
6039
6056
  );
6040
6057
  }
6041
6058
  });
@@ -6045,11 +6062,6 @@ var run = defineCommand({
6045
6062
  description: "Run checks and print the review brief"
6046
6063
  },
6047
6064
  args: {
6048
- ci: {
6049
- type: "boolean",
6050
- description: "Upsert PR comment when GITHUB_TOKEN is available",
6051
- default: false
6052
- },
6053
6065
  markdown: {
6054
6066
  type: "boolean",
6055
6067
  description: "Print markdown only",
@@ -6076,7 +6088,6 @@ var run = defineCommand({
6076
6088
  run: async ({ args }) => {
6077
6089
  await runFrontGuard({
6078
6090
  cwd: g.cwd(),
6079
- ci: Boolean(args.ci),
6080
6091
  markdown: Boolean(args.markdown),
6081
6092
  enforce: Boolean(args.enforce),
6082
6093
  append: typeof args.append === "string" ? args.append : null,