@cleartrip/frontguard 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,12 +4,12 @@ import g, { stdin, stdout, cwd } from 'process';
4
4
  import f from 'readline';
5
5
  import * as tty from 'tty';
6
6
  import { WriteStream } from 'tty';
7
- import path4, { sep, normalize, delimiter, resolve, dirname } from 'path';
7
+ import path5, { 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 path5.resolve(path5.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,14 @@ export default defineConfig({
2458
2417
  // },
2459
2418
 
2460
2419
  // checks: {
2461
- // bundle: { baselineRef: 'main', maxDeltaBytes: 50_000 }, // ref for git baseline file
2420
+ // bundle: { baselineRef: 'main', maxDeltaBytes: 50_000 },
2421
+ // coreWebVitals: { scanGlobs: ['src/**/*.{tsx,jsx}'] },
2422
+ // prSize: {
2423
+ // tiers: [
2424
+ // { minLines: 1000, severity: 'warn', message: 'Very large PR (\${lines} lines; \u2265 \${min})' },
2425
+ // { minLines: 500, severity: 'info', message: 'Consider splitting (\${lines} lines)' },
2426
+ // ],
2427
+ // },
2462
2428
  // cycles: { enabled: true },
2463
2429
  // deadCode: { enabled: true, gate: 'info' },
2464
2430
  // // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
@@ -2496,27 +2462,35 @@ If **Yes**, list tools and what they touched (helps reviewers run a stricter fir
2496
2462
  ## AI assistance (optional detail)
2497
2463
  - [ ] I have reviewed every AI-suggested line for security, auth, and product correctness
2498
2464
  `;
2499
- async function ensureDir(dir) {
2500
- await fs.mkdir(dir, { recursive: true });
2501
- }
2502
2465
  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");
2507
- const cfgPath = path4.join(cwd, "frontguard.config.js");
2466
+ const root = packageRoot();
2467
+ const tplPath = path5.join(root, "templates", "bitbucket-pipelines.yml");
2468
+ const outPipeline = path5.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2469
+ try {
2470
+ await fs.access(outPipeline);
2471
+ } catch {
2472
+ try {
2473
+ const yml = await fs.readFile(tplPath, "utf8");
2474
+ await fs.writeFile(outPipeline, yml, "utf8");
2475
+ } catch {
2476
+ await fs.writeFile(
2477
+ outPipeline,
2478
+ "# Copy bitbucket-pipelines.yml from @cleartrip/frontguard/templates in node_modules\n",
2479
+ "utf8"
2480
+ );
2481
+ }
2482
+ }
2483
+ const cfgPath = path5.join(cwd, "frontguard.config.js");
2508
2484
  try {
2509
2485
  await fs.access(cfgPath);
2510
2486
  } catch {
2511
2487
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2512
2488
  }
2513
- const tplRoot = path4.join(cwd, ".github");
2514
- await ensureDir(tplRoot);
2515
- const tplPath = path4.join(tplRoot, "pull_request_template.md");
2489
+ const tplPr = path5.join(cwd, "pull_request_template.md");
2516
2490
  try {
2517
- await fs.access(tplPath);
2491
+ await fs.access(tplPr);
2518
2492
  } catch {
2519
- await fs.writeFile(tplPath, PR_TEMPLATE, "utf8");
2493
+ await fs.writeFile(tplPr, PR_TEMPLATE, "utf8");
2520
2494
  }
2521
2495
  }
2522
2496
 
@@ -2654,110 +2628,86 @@ function parseAiDisclosure(body) {
2654
2628
  return { assisted, explicitNo, ambiguous };
2655
2629
  }
2656
2630
 
2657
- // src/ci/github.ts
2658
- async function readGithubEvent() {
2659
- const p2 = process.env.GITHUB_EVENT_PATH;
2660
- if (!p2) return null;
2661
- try {
2662
- const raw = await fs.readFile(p2, "utf8");
2663
- const payload = JSON.parse(raw);
2664
- const pr = payload.pull_request;
2665
- if (!pr) return null;
2666
- const files = (pr.files ?? []).map((f4) => f4.filename ?? "").filter(Boolean);
2667
- const body = pr.body ?? "";
2668
- const ai = parseAiDisclosure(body);
2669
- return {
2670
- number: pr.number ?? 0,
2671
- title: pr.title ?? "",
2672
- body,
2673
- baseRef: pr.base?.ref ?? "main",
2674
- headRef: pr.head?.ref ?? "",
2675
- additions: pr.additions ?? 0,
2676
- deletions: pr.deletions ?? 0,
2677
- changedFiles: pr.changed_files ?? files.length,
2678
- files,
2679
- aiAssisted: ai.assisted,
2680
- aiExplicitNo: ai.explicitNo,
2681
- aiDisclosureAmbiguous: ai.ambiguous
2682
- };
2683
- } catch {
2684
- return null;
2685
- }
2686
- }
2687
-
2688
- // src/lib/http-fetch.ts
2689
- function getFetch() {
2690
- const f4 = globalThis.fetch;
2691
- if (typeof f4 !== "function") {
2692
- throw new Error(
2693
- "FrontGuard needs Node.js 18+ (global fetch). Use NODE_VERSION / image >= 18 in CI."
2694
- );
2695
- }
2696
- return f4;
2697
- }
2698
-
2699
- // src/ci/pr-comment.ts
2700
- var MARKER = "<!-- frontguard:brief -->";
2701
- async function resolvePrNumber() {
2702
- const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
2703
- const n3 = Number(raw);
2704
- if (Number.isFinite(n3) && n3 > 0) return n3;
2705
- const path17 = process.env.GITHUB_EVENT_PATH;
2706
- if (!path17) return null;
2707
- try {
2708
- const payload = JSON.parse(await fs.readFile(path17, "utf8"));
2709
- const num = payload.pull_request?.number;
2710
- return typeof num === "number" && num > 0 ? num : null;
2711
- } catch {
2712
- return null;
2631
+ // src/ci/pr-context.ts
2632
+ function gitTrimmed(cwd, args) {
2633
+ return execFileSync("git", ["-C", cwd, ...args], {
2634
+ encoding: "utf8",
2635
+ maxBuffer: 20 * 1024 * 1024
2636
+ }).trimEnd();
2637
+ }
2638
+ function resolveCompareRef(cwd, destBranch) {
2639
+ const safe = destBranch.trim();
2640
+ if (!safe || safe.startsWith("-") || safe.includes("..")) return null;
2641
+ const candidates = [safe, `origin/${safe}`];
2642
+ for (const c4 of candidates) {
2643
+ try {
2644
+ gitTrimmed(cwd, ["rev-parse", "--verify", `${c4}^{commit}`]);
2645
+ return c4;
2646
+ } catch {
2647
+ }
2713
2648
  }
2649
+ return null;
2714
2650
  }
2715
- async function upsertBriefComment(body) {
2716
- const token = process.env.GITHUB_TOKEN;
2717
- const repo = process.env.GITHUB_REPOSITORY;
2718
- if (!token || !repo) {
2719
- return;
2720
- }
2721
- const [owner, name] = repo.split("/");
2722
- if (!owner || !name) return;
2723
- const prNumber = await resolvePrNumber();
2724
- if (!prNumber) {
2725
- return;
2651
+ function parseNumstat(output) {
2652
+ let additions = 0;
2653
+ let deletions = 0;
2654
+ const files = [];
2655
+ for (const line of output.split("\n")) {
2656
+ const t3 = line.trim();
2657
+ if (!t3) continue;
2658
+ const tab = t3.indexOf(" ");
2659
+ if (tab < 0) continue;
2660
+ const tab2 = t3.indexOf(" ", tab + 1);
2661
+ if (tab2 < 0) continue;
2662
+ const aStr = t3.slice(0, tab);
2663
+ const dStr = t3.slice(tab + 1, tab2);
2664
+ const path18 = t3.slice(tab2 + 1);
2665
+ const a3 = aStr === "-" ? 0 : Number(aStr);
2666
+ const d3 = dStr === "-" ? 0 : Number(dStr);
2667
+ if (!Number.isFinite(a3) || !Number.isFinite(d3)) continue;
2668
+ additions += a3;
2669
+ deletions += d3;
2670
+ if (path18) files.push(path18);
2671
+ }
2672
+ return { additions, deletions, files };
2673
+ }
2674
+ function buildBitbucketPrContext(cwd) {
2675
+ const prId = process.env.BITBUCKET_PR_ID?.trim();
2676
+ if (!prId) return null;
2677
+ const dest = process.env.BITBUCKET_PR_DESTINATION_BRANCH?.trim() || "main";
2678
+ const body = process.env.FRONTGUARD_PR_BODY?.trim() ?? "";
2679
+ const headRef = process.env.BITBUCKET_PR_SOURCE_BRANCH?.trim() ?? "";
2680
+ const title = process.env.BITBUCKET_PR_TITLE?.trim() ?? "";
2681
+ let additions = 0;
2682
+ let deletions = 0;
2683
+ let files = [];
2684
+ const destRef = resolveCompareRef(cwd, dest);
2685
+ if (destRef) {
2686
+ try {
2687
+ const raw = gitTrimmed(cwd, ["diff", `${destRef}...HEAD`, "--numstat"]);
2688
+ const stat = parseNumstat(raw);
2689
+ additions = stat.additions;
2690
+ deletions = stat.deletions;
2691
+ files = stat.files;
2692
+ } catch {
2693
+ }
2726
2694
  }
2727
- const apiBase = process.env.GITHUB_API_URL ?? "https://api.github.com";
2728
- const headers = {
2729
- authorization: `Bearer ${token}`,
2730
- accept: "application/vnd.github+json",
2731
- "x-github-api-version": "2022-11-28",
2732
- "content-type": "application/json"
2695
+ const ai = parseAiDisclosure(body);
2696
+ return {
2697
+ number: Number.parseInt(prId, 10) || 0,
2698
+ title,
2699
+ body,
2700
+ baseRef: dest,
2701
+ headRef,
2702
+ additions,
2703
+ deletions,
2704
+ changedFiles: files.length,
2705
+ files,
2706
+ ...ai
2733
2707
  };
2734
- const prefixed = `${MARKER}
2735
- ${body}`;
2736
- const listUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments?per_page=100`;
2737
- const fetch = getFetch();
2738
- const listRes = await fetch(listUrl, { headers });
2739
- if (!listRes.ok) {
2740
- return;
2741
- }
2742
- const comments = await listRes.json();
2743
- const existing = comments.find(
2744
- (c4) => typeof c4.body === "string" && c4.body.includes(MARKER)
2745
- );
2746
- if (existing) {
2747
- const patchUrl = `${apiBase}/repos/${owner}/${name}/issues/comments/${existing.id}`;
2748
- await fetch(patchUrl, {
2749
- method: "PATCH",
2750
- headers,
2751
- body: JSON.stringify({ body: prefixed })
2752
- });
2753
- return;
2754
- }
2755
- const postUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments`;
2756
- await fetch(postUrl, {
2757
- method: "POST",
2758
- headers,
2759
- body: JSON.stringify({ body: prefixed })
2760
- });
2708
+ }
2709
+ function readPrContext(cwd) {
2710
+ return buildBitbucketPrContext(cwd);
2761
2711
  }
2762
2712
 
2763
2713
  // node_modules/defu/dist/defu.mjs
@@ -2874,7 +2824,7 @@ var defaultConfig = {
2874
2824
  maxDeltaBytes: null,
2875
2825
  maxTotalBytes: null
2876
2826
  },
2877
- cwv: {
2827
+ coreWebVitals: {
2878
2828
  enabled: true,
2879
2829
  gate: "warn",
2880
2830
  scanGlobs: ["app/**/*.{tsx,jsx}", "pages/**/*.{tsx,jsx}", "src/**/*.{tsx,jsx}"],
@@ -2895,6 +2845,16 @@ var defaultConfig = {
2895
2845
  }
2896
2846
  };
2897
2847
 
2848
+ // src/config/migrate.ts
2849
+ function migrateLegacyConfigKeys(config) {
2850
+ const ch = config.checks;
2851
+ if (!ch) return;
2852
+ if ("cwv" in ch && !("coreWebVitals" in ch)) {
2853
+ ch.coreWebVitals = ch.cwv;
2854
+ delete ch.cwv;
2855
+ }
2856
+ }
2857
+
2898
2858
  // src/config/load.ts
2899
2859
  var CONFIG_NAMES = [
2900
2860
  "frontguard.config.js",
@@ -2917,7 +2877,7 @@ function stripExtends(c4) {
2917
2877
  }
2918
2878
  async function loadExtendsLayer(cwd, spec) {
2919
2879
  if (!spec) return {};
2920
- const req = createRequire(path4.join(cwd, "package.json"));
2880
+ const req = createRequire(path5.join(cwd, "package.json"));
2921
2881
  const specs = Array.isArray(spec) ? spec : [spec];
2922
2882
  let merged = {};
2923
2883
  for (const s3 of specs) {
@@ -2936,8 +2896,8 @@ async function loadExtendsLayer(cwd, spec) {
2936
2896
  async function loadConfig(cwd) {
2937
2897
  let userFile = null;
2938
2898
  for (const name of CONFIG_NAMES) {
2939
- const full = path4.join(cwd, name);
2940
- if (!fs4.existsSync(full)) continue;
2899
+ const full = path5.join(cwd, name);
2900
+ if (!fs2.existsSync(full)) continue;
2941
2901
  try {
2942
2902
  const mod = await importConfig(full);
2943
2903
  userFile = normalizeExport(mod);
@@ -2948,6 +2908,8 @@ async function loadConfig(cwd) {
2948
2908
  }
2949
2909
  const extendsSpec = userFile?.extends;
2950
2910
  const orgLayer = await loadExtendsLayer(cwd, extendsSpec);
2911
+ migrateLegacyConfigKeys(orgLayer);
2912
+ if (userFile) migrateLegacyConfigKeys(userFile);
2951
2913
  const user = userFile ? stripExtends(userFile) : {};
2952
2914
  const base = structuredClone(defaultConfig);
2953
2915
  const withOrg = defu2(orgLayer, base);
@@ -2966,7 +2928,7 @@ function hasDep(deps, name) {
2966
2928
  async function detectStack(cwd) {
2967
2929
  let pkg = {};
2968
2930
  try {
2969
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
2931
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
2970
2932
  pkg = JSON.parse(raw);
2971
2933
  } catch {
2972
2934
  return {
@@ -2986,7 +2948,7 @@ async function detectStack(cwd) {
2986
2948
  const isMonorepo = Boolean(pkg.workspaces);
2987
2949
  let tsStrict = null;
2988
2950
  try {
2989
- const tsconfigPath = path4.join(cwd, "tsconfig.json");
2951
+ const tsconfigPath = path5.join(cwd, "tsconfig.json");
2990
2952
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2991
2953
  const ts = JSON.parse(tsRaw);
2992
2954
  if (typeof ts.compilerOptions?.strict === "boolean") {
@@ -2996,15 +2958,15 @@ async function detectStack(cwd) {
2996
2958
  }
2997
2959
  let pm = "unknown";
2998
2960
  try {
2999
- await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
2961
+ await fs.access(path5.join(cwd, "pnpm-lock.yaml"));
3000
2962
  pm = "pnpm";
3001
2963
  } catch {
3002
2964
  try {
3003
- await fs.access(path4.join(cwd, "yarn.lock"));
2965
+ await fs.access(path5.join(cwd, "yarn.lock"));
3004
2966
  pm = "yarn";
3005
2967
  } catch {
3006
2968
  try {
3007
- await fs.access(path4.join(cwd, "package-lock.json"));
2969
+ await fs.access(path5.join(cwd, "package-lock.json"));
3008
2970
  pm = "npm";
3009
2971
  } catch {
3010
2972
  pm = "npm";
@@ -3034,6 +2996,65 @@ function formatStackOneLiner(s3) {
3034
2996
  bits.push(`pkg: ${s3.packageManager}`);
3035
2997
  return bits.join(" \xB7 ") || "unknown";
3036
2998
  }
2999
+ function normalizePrPath(p2) {
3000
+ return p2.replace(/\\/g, "/").replace(/^\.\//, "");
3001
+ }
3002
+ function hasPrFileList(pr) {
3003
+ return Boolean(pr?.files?.length);
3004
+ }
3005
+ function prPathSet(pr) {
3006
+ if (!hasPrFileList(pr)) return null;
3007
+ return new Set(pr.files.map(normalizePrPath));
3008
+ }
3009
+ function isPathInPrScope(relOrAbs, cwd, prSet) {
3010
+ const raw = relOrAbs.replace(/^file:\/\//, "");
3011
+ let rel = normalizePrPath(raw);
3012
+ if (path5.isAbsolute(raw)) {
3013
+ rel = normalizePrPath(path5.relative(cwd, raw));
3014
+ }
3015
+ if (prSet.has(rel)) return true;
3016
+ const trimmed = rel.replace(/^\.\//, "");
3017
+ if (prSet.has(trimmed)) return true;
3018
+ return false;
3019
+ }
3020
+ var ESLINT_EXT = /\.(js|cjs|mjs|jsx|ts|tsx)$/i;
3021
+ var PRETTIER_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|json|md|css|scss|sass|less|yml|yaml|vue|svelte)$/i;
3022
+ var CYCLES_EXT = /\.(tsx?|jsx?|mjs|cjs|js)$/i;
3023
+ function filterPrFilesForEslint(pr) {
3024
+ if (!hasPrFileList(pr)) return null;
3025
+ return pr.files.map(normalizePrPath).filter((f4) => ESLINT_EXT.test(f4));
3026
+ }
3027
+ function filterPrFilesForPrettier(pr) {
3028
+ if (!hasPrFileList(pr)) return null;
3029
+ return pr.files.map(normalizePrPath).filter((f4) => PRETTIER_EXT.test(f4));
3030
+ }
3031
+ function filterPrFilesForMadge(pr) {
3032
+ if (!hasPrFileList(pr)) return null;
3033
+ return pr.files.map(normalizePrPath).filter((f4) => CYCLES_EXT.test(f4));
3034
+ }
3035
+ async function existingRepoPaths(cwd, rels) {
3036
+ const out = [];
3037
+ for (const rel of rels) {
3038
+ try {
3039
+ await fs.access(path5.join(cwd, rel));
3040
+ out.push(rel);
3041
+ } catch {
3042
+ }
3043
+ }
3044
+ return out;
3045
+ }
3046
+ function filterTscOutputToPrFiles(output, cwd, prSet) {
3047
+ const lines = output.split("\n");
3048
+ const kept = [];
3049
+ for (const line of lines) {
3050
+ const m3 = /^(.+?)\(\d+,\d+\):\s/.exec(line);
3051
+ if (m3?.[1]) {
3052
+ if (isPathInPrScope(m3[1], cwd, prSet)) kept.push(line);
3053
+ continue;
3054
+ }
3055
+ }
3056
+ return kept.join("\n").trim();
3057
+ }
3037
3058
  function stripFileUrl(p2) {
3038
3059
  let s3 = p2.trim();
3039
3060
  if (!/^file:/i.test(s3)) return s3;
@@ -3045,30 +3066,30 @@ function stripFileUrl(p2) {
3045
3066
  return s3;
3046
3067
  }
3047
3068
  function isUnderDir(parent, child) {
3048
- const rel = path4.relative(parent, child);
3049
- return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
3069
+ const rel = path5.relative(parent, child);
3070
+ return rel === "" || !rel.startsWith("..") && !path5.isAbsolute(rel);
3050
3071
  }
3051
3072
  function toRepoRelativePath(cwd, filePath) {
3052
3073
  if (!filePath?.trim()) return void 0;
3053
3074
  const raw = stripFileUrl(filePath);
3054
- const resolvedCwd = path4.resolve(cwd);
3055
- const absFile = path4.isAbsolute(raw) ? path4.resolve(raw) : path4.resolve(resolvedCwd, raw);
3075
+ const resolvedCwd = path5.resolve(cwd);
3076
+ const absFile = path5.isAbsolute(raw) ? path5.resolve(raw) : path5.resolve(resolvedCwd, raw);
3056
3077
  if (!isUnderDir(resolvedCwd, absFile)) {
3057
3078
  return raw.split(/[/\\]/g).join("/");
3058
3079
  }
3059
- let rel = path4.relative(resolvedCwd, absFile);
3080
+ let rel = path5.relative(resolvedCwd, absFile);
3060
3081
  if (!rel || rel === ".") {
3061
- return path4.basename(absFile);
3082
+ return path5.basename(absFile);
3062
3083
  }
3063
- return rel.split(path4.sep).join("/");
3084
+ return rel.split(path5.sep).join("/");
3064
3085
  }
3065
3086
  function stripRepoAbsolutePaths(cwd, text) {
3066
3087
  if (!text || !cwd.trim()) return text;
3067
- const resolvedCwd = path4.resolve(cwd);
3088
+ const resolvedCwd = path5.resolve(cwd);
3068
3089
  const asPosix = (s3) => s3.replace(/\\/g, "/");
3069
3090
  const cwdPosix = asPosix(resolvedCwd);
3070
3091
  let out = asPosix(text);
3071
- const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
3092
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path5.sep])].filter(
3072
3093
  (p2) => p2.length > 1
3073
3094
  );
3074
3095
  for (const prefix of prefixes) {
@@ -3694,7 +3715,7 @@ async function pathExists(file) {
3694
3715
  }
3695
3716
  }
3696
3717
  async function resolveBin(cwd, name) {
3697
- const local = path4.join(cwd, "node_modules", ".bin", name);
3718
+ const local = path5.join(cwd, "node_modules", ".bin", name);
3698
3719
  if (await pathExists(local)) return local;
3699
3720
  const win = local + ".cmd";
3700
3721
  if (await pathExists(win)) return win;
@@ -3750,7 +3771,7 @@ async function runNpx(cwd, args) {
3750
3771
  // src/checks/eslint.ts
3751
3772
  async function hasEslintDependency(cwd) {
3752
3773
  try {
3753
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
3774
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
3754
3775
  const p2 = JSON.parse(raw);
3755
3776
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3756
3777
  } catch {
@@ -3762,6 +3783,7 @@ async function hasEslintConfig(cwd) {
3762
3783
  "eslint.config.js",
3763
3784
  "eslint.config.mjs",
3764
3785
  "eslint.config.cjs",
3786
+ "eslint.config.json",
3765
3787
  ".eslintrc",
3766
3788
  ".eslintrc.json",
3767
3789
  ".eslintrc.cjs",
@@ -3769,14 +3791,47 @@ async function hasEslintConfig(cwd) {
3769
3791
  ".eslintrc.yml"
3770
3792
  ];
3771
3793
  for (const c4 of candidates) {
3772
- if (await pathExists(path4.join(cwd, c4))) return true;
3794
+ if (await pathExists(path5.join(cwd, c4))) return true;
3773
3795
  }
3774
3796
  return false;
3775
3797
  }
3776
3798
  function meaningfulStderr(stderr) {
3777
3799
  return stderr.split("\n").filter((l3) => l3.trim() && !/^npm warn\b/i.test(l3)).join("\n").trim();
3778
3800
  }
3779
- async function runEslint(cwd, config, _stack) {
3801
+ var ESLINT_BATCH = 80;
3802
+ async function runEslintOnPaths(cwd, relPaths) {
3803
+ let worstExit = 0;
3804
+ const merged = [];
3805
+ let stderrAcc = "";
3806
+ for (let i3 = 0; i3 < relPaths.length; i3 += ESLINT_BATCH) {
3807
+ const batch = relPaths.slice(i3, i3 + ESLINT_BATCH);
3808
+ const args = [
3809
+ ...batch,
3810
+ "--max-warnings",
3811
+ "0",
3812
+ "--no-error-on-unmatched-pattern",
3813
+ "-f",
3814
+ "json"
3815
+ ];
3816
+ const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "eslint", args);
3817
+ worstExit = Math.max(worstExit, exitCode ?? 0);
3818
+ stderrAcc += stderr;
3819
+ const t3 = stdout2.trim();
3820
+ if (t3) {
3821
+ try {
3822
+ const rows = JSON.parse(t3);
3823
+ if (Array.isArray(rows)) merged.push(...rows);
3824
+ } catch {
3825
+ }
3826
+ }
3827
+ }
3828
+ return {
3829
+ exitCode: worstExit,
3830
+ stdout: JSON.stringify(merged),
3831
+ stderr: stderrAcc
3832
+ };
3833
+ }
3834
+ async function runEslint(cwd, config, _stack, pr) {
3780
3835
  const t0 = performance.now();
3781
3836
  if (!config.checks.eslint.enabled) {
3782
3837
  return {
@@ -3797,15 +3852,39 @@ async function runEslint(cwd, config, _stack) {
3797
3852
  };
3798
3853
  }
3799
3854
  const glob = config.checks.eslint.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx}";
3800
- const args = [
3801
- glob,
3802
- "--max-warnings",
3803
- "0",
3804
- "--no-error-on-unmatched-pattern",
3805
- "-f",
3806
- "json"
3807
- ];
3808
- const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "eslint", args);
3855
+ const prPaths = filterPrFilesForEslint(pr);
3856
+ let exitCode;
3857
+ let stdout2;
3858
+ let stderr;
3859
+ if (prPaths !== null) {
3860
+ if (prPaths.length === 0) {
3861
+ return {
3862
+ checkId: "eslint",
3863
+ findings: [],
3864
+ durationMs: Math.round(performance.now() - t0),
3865
+ skipped: "no lintable files in PR diff"
3866
+ };
3867
+ }
3868
+ const existing = await existingRepoPaths(cwd, prPaths);
3869
+ if (existing.length === 0) {
3870
+ return {
3871
+ checkId: "eslint",
3872
+ findings: [],
3873
+ durationMs: Math.round(performance.now() - t0),
3874
+ skipped: hasPrFileList(pr) ? "no lintable files in PR diff (added/modified only)" : "no files"
3875
+ };
3876
+ }
3877
+ ({ exitCode, stdout: stdout2, stderr } = await runEslintOnPaths(cwd, existing));
3878
+ } else {
3879
+ ({ exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "eslint", [
3880
+ glob,
3881
+ "--max-warnings",
3882
+ "0",
3883
+ "--no-error-on-unmatched-pattern",
3884
+ "-f",
3885
+ "json"
3886
+ ]));
3887
+ }
3809
3888
  const errText = meaningfulStderr(stderr);
3810
3889
  const findings = [];
3811
3890
  if (exitCode === 0) {
@@ -3871,7 +3950,25 @@ function truncate(s3, max) {
3871
3950
  }
3872
3951
 
3873
3952
  // src/checks/prettier.ts
3874
- async function runPrettier(cwd, config) {
3953
+ var PRETTIER_BATCH = 100;
3954
+ async function runPrettierOnPaths(cwd, relPaths) {
3955
+ let worstExit = 0;
3956
+ let stdoutAcc = "";
3957
+ let stderrAcc = "";
3958
+ for (let i3 = 0; i3 < relPaths.length; i3 += PRETTIER_BATCH) {
3959
+ const batch = relPaths.slice(i3, i3 + PRETTIER_BATCH);
3960
+ const r4 = await runNpmBinary(cwd, "prettier", [
3961
+ "--check",
3962
+ "--ignore-unknown",
3963
+ ...batch
3964
+ ]);
3965
+ worstExit = Math.max(worstExit, r4.exitCode ?? 0);
3966
+ stdoutAcc += r4.stdout;
3967
+ stderrAcc += r4.stderr;
3968
+ }
3969
+ return { exitCode: worstExit, stdout: stdoutAcc, stderr: stderrAcc };
3970
+ }
3971
+ async function runPrettier(cwd, config, pr) {
3875
3972
  const t0 = performance.now();
3876
3973
  if (!config.checks.prettier.enabled) {
3877
3974
  return {
@@ -3882,11 +3979,36 @@ async function runPrettier(cwd, config) {
3882
3979
  };
3883
3980
  }
3884
3981
  const glob = config.checks.prettier.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx,json,md,css,scss,yml,yaml}";
3885
- const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "prettier", [
3886
- "--check",
3887
- glob,
3888
- "--ignore-unknown"
3889
- ]);
3982
+ let exitCode;
3983
+ let stdout2;
3984
+ let stderr;
3985
+ const prPaths = filterPrFilesForPrettier(pr);
3986
+ if (prPaths !== null) {
3987
+ if (prPaths.length === 0) {
3988
+ return {
3989
+ checkId: "prettier",
3990
+ findings: [],
3991
+ durationMs: Math.round(performance.now() - t0),
3992
+ skipped: "no formattable files in PR diff"
3993
+ };
3994
+ }
3995
+ const existing = await existingRepoPaths(cwd, prPaths);
3996
+ if (existing.length === 0) {
3997
+ return {
3998
+ checkId: "prettier",
3999
+ findings: [],
4000
+ durationMs: Math.round(performance.now() - t0),
4001
+ skipped: hasPrFileList(pr) ? "no formattable files in PR diff" : "no files"
4002
+ };
4003
+ }
4004
+ ({ exitCode, stdout: stdout2, stderr } = await runPrettierOnPaths(cwd, existing));
4005
+ } else {
4006
+ ({ exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "prettier", [
4007
+ "--check",
4008
+ glob,
4009
+ "--ignore-unknown"
4010
+ ]));
4011
+ }
3890
4012
  const findings = [];
3891
4013
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
3892
4014
  return {
@@ -3915,7 +4037,7 @@ function truncate2(s3, max) {
3915
4037
  if (s3.length <= max) return s3;
3916
4038
  return s3.slice(0, max) + "\u2026";
3917
4039
  }
3918
- async function runTypeScript(cwd, config, stack) {
4040
+ async function runTypeScript(cwd, config, stack, pr) {
3919
4041
  const t0 = performance.now();
3920
4042
  if (!config.checks.typescript.enabled) {
3921
4043
  return {
@@ -3925,7 +4047,7 @@ async function runTypeScript(cwd, config, stack) {
3925
4047
  skipped: "disabled in config"
3926
4048
  };
3927
4049
  }
3928
- const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
4050
+ const hasTs = stack.hasTypeScript || await pathExists(path5.join(cwd, "tsconfig.json"));
3929
4051
  if (!hasTs) {
3930
4052
  return {
3931
4053
  checkId: "typescript",
@@ -3934,7 +4056,14 @@ async function runTypeScript(cwd, config, stack) {
3934
4056
  skipped: "no TypeScript project detected"
3935
4057
  };
3936
4058
  }
3937
- const args = ["--noEmit", ...config.checks.typescript.tscArgs ?? []];
4059
+ const extra = config.checks.typescript.tscArgs ?? [];
4060
+ const hasProject = extra.some((a3) => a3 === "-p" || a3 === "--project");
4061
+ const tsconfigPath = path5.join(cwd, "tsconfig.json");
4062
+ const args = [
4063
+ "--noEmit",
4064
+ ...hasProject || !await pathExists(tsconfigPath) ? [] : ["-p", "tsconfig.json"],
4065
+ ...extra
4066
+ ];
3938
4067
  const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "tsc", args);
3939
4068
  const findings = [];
3940
4069
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
@@ -3947,12 +4076,16 @@ async function runTypeScript(cwd, config, stack) {
3947
4076
  }
3948
4077
  if (exitCode !== 0) {
3949
4078
  const out = [stdout2, stderr].filter(Boolean).join("\n").trim();
3950
- findings.push({
3951
- id: "tsc",
3952
- severity: "warn",
3953
- message: "TypeScript compiler reported diagnostics",
3954
- detail: out ? truncate3(out, 8e3) : `exit ${exitCode}`
3955
- });
4079
+ const prS = prPathSet(pr);
4080
+ const scoped = prS && out ? filterTscOutputToPrFiles(out, cwd, prS) : out;
4081
+ if (scoped) {
4082
+ findings.push({
4083
+ id: "tsc",
4084
+ severity: "warn",
4085
+ message: prS ? "TypeScript: issues in PR-changed files (full project was typechecked)" : "TypeScript compiler reported diagnostics",
4086
+ detail: scoped ? truncate3(scoped, 8e3) : `exit ${exitCode}`
4087
+ });
4088
+ }
3956
4089
  }
3957
4090
  return {
3958
4091
  checkId: "typescript",
@@ -4038,7 +4171,7 @@ async function runSecrets(cwd, config, pr) {
4038
4171
  });
4039
4172
  break;
4040
4173
  }
4041
- const full = path4.join(cwd, rel);
4174
+ const full = path5.join(cwd, rel);
4042
4175
  let content;
4043
4176
  try {
4044
4177
  content = await fs.readFile(full, "utf8");
@@ -4064,7 +4197,7 @@ async function runSecrets(cwd, config, pr) {
4064
4197
  };
4065
4198
  }
4066
4199
  function isProbablyTextFile(rel) {
4067
- const ext = path4.extname(rel).toLowerCase();
4200
+ const ext = path5.extname(rel).toLowerCase();
4068
4201
  return TEXT_EXT.has(ext);
4069
4202
  }
4070
4203
 
@@ -4084,7 +4217,7 @@ function runPrHygiene(config, pr) {
4084
4217
  checkId: "pr-hygiene",
4085
4218
  findings: [],
4086
4219
  durationMs: Math.round(performance.now() - t0),
4087
- skipped: "not running in GitHub pull_request context"
4220
+ skipped: "no PR context (run in a Bitbucket PR pipeline with BITBUCKET_PR_ID, or set FRONTGUARD_PR_BODY for description checks)"
4088
4221
  };
4089
4222
  }
4090
4223
  const findings = [];
@@ -4163,6 +4296,25 @@ function sectionMentioned(body, hint) {
4163
4296
  }
4164
4297
 
4165
4298
  // src/checks/pr-size.ts
4299
+ function expandMessage(template, lines, min) {
4300
+ return template.replaceAll("${lines}", String(lines)).replaceAll("${min}", String(min));
4301
+ }
4302
+ function defaultTiers(cfg) {
4303
+ return [
4304
+ {
4305
+ minLines: cfg.softBlockLines,
4306
+ severity: "warn",
4307
+ id: "pr-size-large",
4308
+ message: "PR is very large (${lines} lines changed; threshold ${min}). Consider splitting for review."
4309
+ },
4310
+ {
4311
+ minLines: cfg.warnLines,
4312
+ severity: "info",
4313
+ id: "pr-size-medium",
4314
+ message: "PR size is elevated (${lines} lines changed; threshold ${min})."
4315
+ }
4316
+ ];
4317
+ }
4166
4318
  function runPrSize(config, pr) {
4167
4319
  const t0 = performance.now();
4168
4320
  if (!config.checks.prSize.enabled) {
@@ -4178,24 +4330,29 @@ function runPrSize(config, pr) {
4178
4330
  checkId: "pr-size",
4179
4331
  findings: [],
4180
4332
  durationMs: Math.round(performance.now() - t0),
4181
- skipped: "not running in GitHub pull_request context"
4333
+ skipped: "no PR context (run in a Bitbucket pull-request pipeline so BITBUCKET_PR_* and git diff are available)"
4182
4334
  };
4183
4335
  }
4184
- const findings = [];
4185
4336
  const lines = pr.additions + pr.deletions;
4186
- const { warnLines, softBlockLines } = config.checks.prSize;
4187
- if (lines >= softBlockLines) {
4188
- findings.push({
4189
- id: "pr-size-large",
4190
- severity: "warn",
4191
- message: `PR is very large (${lines} lines changed; \u2265 ${softBlockLines})`,
4192
- detail: "Consider splitting to improve review quality."
4193
- });
4194
- } else if (lines >= warnLines) {
4337
+ const cfg = config.checks.prSize;
4338
+ const rawTiers = cfg.tiers?.length ? cfg.tiers : defaultTiers(cfg);
4339
+ const sorted = [...rawTiers].sort((a3, b3) => b3.minLines - a3.minLines);
4340
+ const findings = [];
4341
+ let i3 = 0;
4342
+ for (const tier of sorted) {
4343
+ if (lines < tier.minLines) continue;
4344
+ const id = tier.id ?? `pr-size-tier-${i3}`;
4345
+ i3 += 1;
4346
+ const message = tier.message ? expandMessage(tier.message, lines, tier.minLines) : expandMessage(
4347
+ "PR has ${lines} lines changed (\u2265 ${min}).",
4348
+ lines,
4349
+ tier.minLines
4350
+ );
4195
4351
  findings.push({
4196
- id: "pr-size-medium",
4197
- severity: "info",
4198
- message: `PR size is elevated (${lines} lines changed; \u2265 ${warnLines})`
4352
+ id,
4353
+ severity: tier.severity,
4354
+ message,
4355
+ detail: "Total = additions + deletions from the PR diff."
4199
4356
  });
4200
4357
  }
4201
4358
  return {
@@ -4248,13 +4405,18 @@ async function gitDiffForReview(cwd, baseRef, maxChars) {
4248
4405
  }
4249
4406
  }
4250
4407
  async function resolveDiffBaseRef(cwd, fallback) {
4251
- const gh = process.env.GITHUB_BASE_REF;
4252
- if (gh) {
4253
- const origin = `origin/${gh}`;
4408
+ const bb = process.env.BITBUCKET_PR_DESTINATION_BRANCH?.trim();
4409
+ if (bb) {
4410
+ const origin = `origin/${bb}`;
4254
4411
  try {
4255
4412
  await W2("git", ["rev-parse", "--verify", origin], { nodeOptions: { cwd } });
4256
4413
  return origin;
4257
4414
  } catch {
4415
+ try {
4416
+ await W2("git", ["rev-parse", "--verify", bb], { nodeOptions: { cwd } });
4417
+ return bb;
4418
+ } catch {
4419
+ }
4258
4420
  }
4259
4421
  }
4260
4422
  try {
@@ -4352,7 +4514,7 @@ async function runTsAnyDelta(cwd, config, stack) {
4352
4514
  function gateSeverity2(g4) {
4353
4515
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4354
4516
  }
4355
- async function runCycles(cwd, config, stack) {
4517
+ async function runCycles(cwd, config, stack, pr) {
4356
4518
  const t0 = performance.now();
4357
4519
  const cfg = config.checks.cycles;
4358
4520
  if (!cfg.enabled || !stack.hasTypeScript) {
@@ -4365,12 +4527,12 @@ async function runCycles(cwd, config, stack) {
4365
4527
  }
4366
4528
  let entry = cfg.entries[0] ?? "src";
4367
4529
  for (const e3 of cfg.entries) {
4368
- if (await pathExists(path4.join(cwd, e3))) {
4530
+ if (await pathExists(path5.join(cwd, e3))) {
4369
4531
  entry = e3;
4370
4532
  break;
4371
4533
  }
4372
4534
  }
4373
- if (!await pathExists(path4.join(cwd, entry))) {
4535
+ if (!await pathExists(path5.join(cwd, entry))) {
4374
4536
  return {
4375
4537
  checkId: "cycles",
4376
4538
  findings: [],
@@ -4378,10 +4540,13 @@ async function runCycles(cwd, config, stack) {
4378
4540
  skipped: `entry path not found (${entry})`
4379
4541
  };
4380
4542
  }
4543
+ const prMadge = filterPrFilesForMadge(pr);
4544
+ const prExisting = prMadge?.length ? await existingRepoPaths(cwd, prMadge) : [];
4545
+ const roots = prExisting.length > 0 ? prExisting : [entry];
4381
4546
  const args = [
4382
4547
  "-y",
4383
4548
  "madge@6",
4384
- entry,
4549
+ ...roots,
4385
4550
  "--extensions",
4386
4551
  "ts,tsx,js,jsx",
4387
4552
  "--circular",
@@ -4403,7 +4568,7 @@ async function runCycles(cwd, config, stack) {
4403
4568
  findings.push({
4404
4569
  id: "import-cycle",
4405
4570
  severity: gateSeverity2(cfg.gate),
4406
- message: "Circular dependencies detected (madge)",
4571
+ message: prExisting.length > 0 ? "Circular dependencies detected (madge, scoped to PR files)" : "Circular dependencies detected (madge)",
4407
4572
  detail: truncate4(out || `exit ${exitCode}`, 12e3)
4408
4573
  });
4409
4574
  } else if (exitCode !== 0) {
@@ -4494,7 +4659,7 @@ async function sumGlobBytes(cwd, patterns) {
4494
4659
  });
4495
4660
  for (const rel of files) {
4496
4661
  try {
4497
- const st = await fs.stat(path4.join(cwd, rel));
4662
+ const st = await fs.stat(path5.join(cwd, rel));
4498
4663
  total += st.size;
4499
4664
  } catch {
4500
4665
  }
@@ -4503,7 +4668,7 @@ async function sumGlobBytes(cwd, patterns) {
4503
4668
  return total;
4504
4669
  }
4505
4670
  async function readBaseline(cwd, relPath, baseRef) {
4506
- const disk = path4.join(cwd, relPath);
4671
+ const disk = path5.join(cwd, relPath);
4507
4672
  try {
4508
4673
  const raw = await fs.readFile(disk, "utf8");
4509
4674
  return JSON.parse(raw);
@@ -4533,6 +4698,39 @@ async function gitOkQuick(cwd) {
4533
4698
  function tokenizeCommand(cmd) {
4534
4699
  return cmd.trim().split(/\s+/).map((t3) => t3.trim()).filter(Boolean);
4535
4700
  }
4701
+ function npmScriptFromBuildCommand(cmd) {
4702
+ const t3 = cmd.trim();
4703
+ if (/^yarn\s+build\b/i.test(t3)) return "build";
4704
+ const np = /^(?:npm|pnpm)\s+run\s+(\S+)/i.exec(t3);
4705
+ if (np?.[1]) return np[1];
4706
+ const yr = /^yarn\s+run\s+(\S+)/i.exec(t3);
4707
+ if (yr?.[1]) return yr[1];
4708
+ return null;
4709
+ }
4710
+ async function bundleBuildPrecheck(cwd, buildCommand) {
4711
+ const script = npmScriptFromBuildCommand(buildCommand);
4712
+ if (!script) return { run: true };
4713
+ let scripts;
4714
+ try {
4715
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
4716
+ const pkg = JSON.parse(raw);
4717
+ scripts = pkg.scripts;
4718
+ } catch {
4719
+ return {
4720
+ run: false,
4721
+ message: "Skipped bundle build \u2014 no readable package.json",
4722
+ 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."
4723
+ };
4724
+ }
4725
+ if (!scripts?.[script]) {
4726
+ return {
4727
+ run: false,
4728
+ message: `Skipped bundle build \u2014 no scripts.${script} in package.json`,
4729
+ 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`)."
4730
+ };
4731
+ }
4732
+ return { run: true };
4733
+ }
4536
4734
  async function runBundle(cwd, config, stack) {
4537
4735
  const t0 = performance.now();
4538
4736
  const cfg = config.checks.bundle;
@@ -4552,6 +4750,7 @@ async function runBundle(cwd, config, stack) {
4552
4750
  skipped: "skipped for React Native (configure web artifacts if needed)"
4553
4751
  };
4554
4752
  }
4753
+ const preFindings = [];
4555
4754
  if (cfg.runBuild) {
4556
4755
  const parts = tokenizeCommand(cfg.buildCommand);
4557
4756
  if (parts.length === 0) {
@@ -4567,21 +4766,31 @@ async function runBundle(cwd, config, stack) {
4567
4766
  durationMs: Math.round(performance.now() - t0)
4568
4767
  };
4569
4768
  }
4570
- const [bin, ...args] = parts;
4571
- const res = await W2(bin, args, { nodeOptions: { cwd } });
4572
- if ((res.exitCode ?? 0) !== 0) {
4573
- return {
4574
- checkId: "bundle",
4575
- findings: [
4576
- {
4577
- id: "bundle-build",
4578
- severity: gateSeverity4(cfg.gate),
4579
- message: "Build command failed \u2014 cannot measure bundle",
4580
- detail: [res.stdout, res.stderr].filter(Boolean).join("\n").slice(0, 8e3)
4581
- }
4582
- ],
4583
- durationMs: Math.round(performance.now() - t0)
4584
- };
4769
+ const pre = await bundleBuildPrecheck(cwd, cfg.buildCommand);
4770
+ if (!pre.run) {
4771
+ preFindings.push({
4772
+ id: "bundle-build-skipped",
4773
+ severity: "info",
4774
+ message: pre.message,
4775
+ detail: pre.detail
4776
+ });
4777
+ } else {
4778
+ const [bin, ...args] = parts;
4779
+ const res = await W2(bin, args, { nodeOptions: { cwd } });
4780
+ if ((res.exitCode ?? 0) !== 0) {
4781
+ return {
4782
+ checkId: "bundle",
4783
+ findings: [
4784
+ {
4785
+ id: "bundle-build",
4786
+ severity: gateSeverity4(cfg.gate),
4787
+ message: "Build command failed \u2014 cannot measure bundle",
4788
+ detail: [res.stdout, res.stderr].filter(Boolean).join("\n").slice(0, 8e3)
4789
+ }
4790
+ ],
4791
+ durationMs: Math.round(performance.now() - t0)
4792
+ };
4793
+ }
4585
4794
  }
4586
4795
  }
4587
4796
  const total = await sumGlobBytes(cwd, cfg.measureGlobs);
@@ -4589,6 +4798,7 @@ async function runBundle(cwd, config, stack) {
4589
4798
  return {
4590
4799
  checkId: "bundle",
4591
4800
  findings: [
4801
+ ...preFindings,
4592
4802
  {
4593
4803
  id: "bundle-empty",
4594
4804
  severity: "info",
@@ -4600,7 +4810,7 @@ async function runBundle(cwd, config, stack) {
4600
4810
  }
4601
4811
  const baseRef = await gitOkQuick(cwd) ? await resolveDiffBaseRef(cwd, cfg.baselineRef) : null;
4602
4812
  const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
4603
- const findings = [];
4813
+ const findings = [...preFindings];
4604
4814
  const infoLines = [
4605
4815
  `Measured ${total} bytes (${(total / 1024 / 1024).toFixed(2)} MiB)`,
4606
4816
  baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
@@ -4649,12 +4859,12 @@ async function runBundle(cwd, config, stack) {
4649
4859
  function gateSeverity5(g4) {
4650
4860
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4651
4861
  }
4652
- async function runCwv(cwd, config, stack, pr) {
4862
+ async function runCoreWebVitals(cwd, config, stack, pr) {
4653
4863
  const t0 = performance.now();
4654
- const cfg = config.checks.cwv;
4864
+ const cfg = config.checks.coreWebVitals;
4655
4865
  if (!cfg.enabled) {
4656
4866
  return {
4657
- checkId: "cwv",
4867
+ checkId: "core-web-vitals",
4658
4868
  findings: [],
4659
4869
  durationMs: 0,
4660
4870
  skipped: "disabled in config"
@@ -4662,7 +4872,7 @@ async function runCwv(cwd, config, stack, pr) {
4662
4872
  }
4663
4873
  if (stack.hasReactNative && !stack.hasNext) {
4664
4874
  return {
4665
- checkId: "cwv",
4875
+ checkId: "core-web-vitals",
4666
4876
  findings: [],
4667
4877
  durationMs: Math.round(performance.now() - t0),
4668
4878
  skipped: "skipped for React Native"
@@ -4678,7 +4888,7 @@ async function runCwv(cwd, config, stack, pr) {
4678
4888
  const findings = [];
4679
4889
  const sev2 = gateSeverity5(cfg.gate);
4680
4890
  for (const rel of toScan.slice(0, 400)) {
4681
- const full = path4.join(cwd, rel);
4891
+ const full = path5.join(cwd, rel);
4682
4892
  let text;
4683
4893
  try {
4684
4894
  text = await fs.readFile(full, "utf8");
@@ -4688,23 +4898,23 @@ async function runCwv(cwd, config, stack, pr) {
4688
4898
  if (text.length > cfg.maxFileBytes) continue;
4689
4899
  if (stack.hasNext && /<img\b/i.test(text) && !/from\s+['"]next\/image['"]/.test(text)) {
4690
4900
  findings.push({
4691
- id: "cwv-img-tag",
4901
+ id: "core-web-vitals-img-tag",
4692
4902
  severity: sev2,
4693
- message: "Raw `<img>` detected \u2014 prefer `next/image` for LCP-friendly delivery",
4903
+ message: "Raw `<img>` \u2014 prefer `next/image` for LCP-friendly delivery",
4694
4904
  file: rel
4695
4905
  });
4696
4906
  }
4697
4907
  if (/dangerouslySetInnerHTML/i.test(text)) {
4698
4908
  findings.push({
4699
- id: "cwv-dsh",
4909
+ id: "core-web-vitals-dsh",
4700
4910
  severity: "warn",
4701
- message: "`dangerouslySetInnerHTML` can impact main-thread work \u2014 validate necessity",
4911
+ message: "`dangerouslySetInnerHTML` can add main-thread work \u2014 validate necessity",
4702
4912
  file: rel
4703
4913
  });
4704
4914
  }
4705
4915
  }
4706
4916
  return {
4707
- checkId: "cwv",
4917
+ checkId: "core-web-vitals",
4708
4918
  findings: dedupeFindings(findings).slice(0, 40),
4709
4919
  durationMs: Math.round(performance.now() - t0)
4710
4920
  };
@@ -4763,7 +4973,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4763
4973
  });
4764
4974
  break;
4765
4975
  }
4766
- const full = path4.join(cwd, rel);
4976
+ const full = path5.join(cwd, rel);
4767
4977
  let content;
4768
4978
  try {
4769
4979
  content = await fs.readFile(full, "utf8");
@@ -4879,7 +5089,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4879
5089
  checkId: "ai-assisted-strict",
4880
5090
  findings: [],
4881
5091
  durationMs: Math.round(performance.now() - t0),
4882
- skipped: "not a pull request context"
5092
+ skipped: "no PR context (Bitbucket PR pipeline + BITBUCKET_PR_ID required)"
4883
5093
  };
4884
5094
  }
4885
5095
  if (!pr.aiAssisted) {
@@ -4894,7 +5104,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4894
5104
  const gate = cfg.gate;
4895
5105
  const findings = [];
4896
5106
  for (const rel of files) {
4897
- const full = path4.join(cwd, rel);
5107
+ const full = path5.join(cwd, rel);
4898
5108
  let content;
4899
5109
  try {
4900
5110
  content = await fs.readFile(full, "utf8");
@@ -4959,25 +5169,6 @@ function escapeHtml(s3) {
4959
5169
  }
4960
5170
 
4961
5171
  // src/report/html-report.ts
4962
- function shieldUrl(label, message, color) {
4963
- const q2 = new URLSearchParams({ label, message, color, style: "for-the-badge" });
4964
- return `https://img.shields.io/static/v1?${q2}`;
4965
- }
4966
- function riskColor(risk) {
4967
- if (risk === "LOW") return "brightgreen";
4968
- if (risk === "MEDIUM") return "orange";
4969
- return "red";
4970
- }
4971
- function modeColor(mode) {
4972
- return mode === "enforce" ? "critical" : "blue";
4973
- }
4974
- function countColor(kind, n3) {
4975
- if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
4976
- if (kind === "info") return n3 === 0 ? "inactive" : "informational";
4977
- if (n3 === 0) return "brightgreen";
4978
- if (n3 <= 10) return "yellow";
4979
- return "orange";
4980
- }
4981
5172
  function parseLineHint(detail) {
4982
5173
  if (!detail) return 0;
4983
5174
  const m3 = /^line\s+(\d+)/i.exec(detail.trim());
@@ -5009,13 +5200,20 @@ function formatDuration(ms) {
5009
5200
  const r4 = s3 % 60;
5010
5201
  return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5011
5202
  }
5203
+ function statusDot(r4) {
5204
+ if (r4.skipped) return '<span class="dot dot-skip" title="Skipped"></span>';
5205
+ if (r4.findings.length === 0) return '<span class="dot dot-ok" title="Clean"></span>';
5206
+ if (r4.findings.some((x3) => x3.severity === "block"))
5207
+ return '<span class="dot dot-block" title="Blocking"></span>';
5208
+ return '<span class="dot dot-warn" title="Issues"></span>';
5209
+ }
5012
5210
  function renderFindingCard(cwd, r4, f4) {
5013
5211
  const d3 = normalizeFinding(cwd, f4);
5014
5212
  const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
5015
- const fixBlock = f4.suggestedFix ? `<div class="suggested-fix"><h5>Suggested fix <span class="tag">LLM</span></h5><div class="fix-md">${escapeHtml(f4.suggestedFix.summary)}</div>${f4.suggestedFix.code ? `<pre class="code"><code>${escapeHtml(f4.suggestedFix.code)}</code></pre>` : ""}<p class="disclaimer">Suggestions are non-binding; review and test before applying.</p></div>` : "";
5213
+ const fixBlock = f4.suggestedFix ? `<div class="suggested-fix"><div class="fix-label">Suggested fix <span class="pill pill-llm">LLM</span></div><div class="fix-md">${escapeHtml(f4.suggestedFix.summary)}</div>${f4.suggestedFix.code ? `<pre class="code"><code>${escapeHtml(f4.suggestedFix.code)}</code></pre>` : ""}<p class="disclaimer">Suggestions are non-binding; review before applying.</p></div>` : "";
5016
5214
  const hintRow = d3.detail && !d3.detail.includes("\n") && d3.detail.length <= 220 && !d3.detail.includes("|") ? `<tr><th>Hint</th><td>${escapeHtml(d3.detail)}</td></tr>` : "";
5017
5215
  const detailFence = d3.detail && (d3.detail.includes("\n") || d3.detail.length > 220 || d3.detail.includes("|")) ? `<pre class="code"><code>${escapeHtml(d3.detail)}</code></pre>` : "";
5018
- return `<article class="card ${sevClass}"><h4>${escapeHtml(d3.file ?? "\u2014")} <span class="muted">\xB7</span> ${escapeHtml(d3.message)}</h4><table class="meta"><tr><th>Check</th><td><code>${escapeHtml(r4.checkId)}</code></td></tr><tr><th>Rule</th><td><code>${escapeHtml(f4.id)}</code></td></tr>${d3.file ? `<tr><th>File</th><td><code>${escapeHtml(d3.file)}</code></td></tr>` : ""}${hintRow}</table>${detailFence}${fixBlock}</article>`;
5216
+ return `<article class="card ${sevClass}"><div class="card-title">${escapeHtml(d3.file ?? "\u2014")}</div><p class="card-msg">${escapeHtml(d3.message)}</p><table class="meta"><tr><th>Check</th><td><code>${escapeHtml(r4.checkId)}</code></td></tr><tr><th>Rule</th><td><code>${escapeHtml(f4.id)}</code></td></tr>${d3.file ? `<tr><th>File</th><td><code>${escapeHtml(d3.file)}</code></td></tr>` : ""}${hintRow}</table>${detailFence}${fixBlock}</article>`;
5019
5217
  }
5020
5218
  function buildHtmlReport(p2) {
5021
5219
  const {
@@ -5031,21 +5229,11 @@ function buildHtmlReport(p2) {
5031
5229
  lines,
5032
5230
  llmAppendix
5033
5231
  } = p2;
5034
- const modeLabel = mode === "enforce" ? "enforce" : "warn only";
5035
- const badges = [
5036
- ["risk", riskScore, riskColor(riskScore)],
5037
- ["mode", modeLabel, modeColor(mode)],
5038
- ["blocking", String(blocks), countColor("block", blocks)],
5039
- ["warnings", String(warns), countColor("warn", warns)],
5040
- ["info", String(infos), countColor("info", infos)]
5041
- ];
5042
- const badgeImgs = badges.map(([l3, m3, c4]) => {
5043
- const alt = `${l3}: ${m3}`;
5044
- return `<img class="badge" src="${escapeHtml(shieldUrl(l3, m3, c4))}" alt="${escapeHtml(alt)}" loading="lazy" />`;
5045
- }).join(" ");
5232
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5233
+ const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5046
5234
  const checkRows = results.map((r4) => {
5047
- const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
5048
- return `<tr><td>${r4.skipped ? "\u23ED\uFE0F" : r4.findings.length === 0 ? "\u{1F7E2}" : r4.findings.some((x3) => x3.severity === "block") ? "\u{1F534}" : "\u{1F7E1}"}</td><td><strong>${escapeHtml(r4.checkId)}</strong></td><td>${status}</td><td>${r4.skipped ? "\u2014" : r4.findings.length}</td><td>${formatDuration(r4.durationMs)}</td></tr>`;
5235
+ const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5236
+ 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>`;
5049
5237
  }).join("\n");
5050
5238
  const blockItems = sortFindings(
5051
5239
  cwd,
@@ -5072,138 +5260,286 @@ function buildHtmlReport(p2) {
5072
5260
  byCheck.set(item.r.checkId, list);
5073
5261
  }
5074
5262
  const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
5075
- const blockingHtml = blockItems.length === 0 ? '<p class="ok">No blocking findings.</p>' : blockItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5263
+ const blockingHtml = blockItems.length === 0 ? '<p class="empty-state">No blocking findings.</p>' : blockItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5076
5264
  let warningsHtml = "";
5077
5265
  if (warnItems.length === 0) {
5078
- warningsHtml = '<p class="ok">No warnings.</p>';
5266
+ warningsHtml = '<p class="empty-state">No warnings.</p>';
5079
5267
  } else {
5080
5268
  for (const cid of checkOrder) {
5081
5269
  const group = sortFindings(cwd, byCheck.get(cid));
5082
- warningsHtml += `<h3 class="grp">${escapeHtml(cid)} <span class="count">(${group.length})</span></h3>`;
5083
- warningsHtml += group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5270
+ const cards = group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5271
+ warningsHtml += `<details class="panel nested"><summary><span class="summary-title">${escapeHtml(cid)}</span><span class="summary-count">${group.length}</span></summary><div class="panel-body">${cards}</div></details>`;
5084
5272
  }
5085
5273
  }
5086
- const infoHtml = infoItems.length === 0 ? '<p class="muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5087
- const prBlock = pr && lines != null ? `<tr><th>PR size</th><td>${lines} LOC (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
5088
- const appendix = llmAppendix?.trim() ? `<section class="appendix"><h2>AI / manual appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
5274
+ const infoHtml = infoItems.length === 0 ? '<p class="empty-state muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
5275
+ const prBlock = pr && lines != null ? `<tr><th>Pull request</th><td>${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
5276
+ const appendix = llmAppendix?.trim() ? `<section class="section"><h2 class="h2">Appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
5089
5277
  return `<!DOCTYPE html>
5090
5278
  <html lang="en">
5091
5279
  <head>
5092
5280
  <meta charset="utf-8" />
5093
5281
  <meta name="viewport" content="width=device-width, initial-scale=1" />
5094
- <title>FrontGuard report</title>
5282
+ <title>FrontGuard \u2014 Report</title>
5095
5283
  <style>
5096
5284
  :root {
5097
- --bg: #0f1419;
5098
- --panel: #1a2332;
5099
- --text: #e7ecf3;
5100
- --muted: #8b9aab;
5101
- --border: #2d3d52;
5102
- --block: #f87171;
5103
- --warn: #fbbf24;
5104
- --info: #38bdf8;
5105
- --accent: #a78bfa;
5106
- --ok: #4ade80;
5285
+ --bg: #f8fafc;
5286
+ --surface: #ffffff;
5287
+ --text: #0f172a;
5288
+ --muted: #64748b;
5289
+ --border: #e2e8f0;
5290
+ --accent: #4f46e5;
5291
+ --accent-soft: #eef2ff;
5292
+ --block: #dc2626;
5293
+ --warn: #d97706;
5294
+ --info: #0284c7;
5295
+ --ok: #16a34a;
5296
+ --radius: 10px;
5297
+ --shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
5107
5298
  }
5108
5299
  * { box-sizing: border-box; }
5109
5300
  body {
5110
- margin: 0; font-family: ui-sans-serif, system-ui, sans-serif;
5111
- background: var(--bg); color: var(--text); line-height: 1.5;
5112
- padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem;
5113
- max-width: 58rem; margin-left: auto; margin-right: auto;
5114
- }
5115
- h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
5116
- h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
5117
- h3.grp { margin: 1.5rem 0 0.5rem; font-size: 1rem; color: var(--warn); }
5118
- h3.grp .count { color: var(--muted); font-weight: normal; }
5119
- h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
5120
- h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
5121
- .badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
5122
- .badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
5123
- details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
5124
- summary {
5125
- cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
5126
- list-style: none; display: flex; align-items: center; gap: 0.5rem;
5127
- }
5128
- summary::-webkit-details-marker { display: none; }
5129
- details[open] > summary { border-bottom: 1px solid var(--border); }
5130
- .details-body { padding: 0.75rem 1rem 1rem; }
5131
- table.results { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0.5rem 0 1rem; }
5132
- table.results th, table.results td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
5133
- table.results th { background: #243044; color: var(--muted); font-weight: 600; }
5134
- .snapshot { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 0.5rem 0; }
5135
- .snapshot th, .snapshot td { border: 1px solid var(--border); padding: 0.5rem 0.65rem; vertical-align: top; }
5136
- .snapshot th { width: 10rem; background: #243044; color: var(--muted); text-align: left; }
5301
+ margin: 0;
5302
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
5303
+ background: var(--bg);
5304
+ color: var(--text);
5305
+ line-height: 1.55;
5306
+ font-size: 15px;
5307
+ padding: 2rem clamp(1rem, 4vw, 3rem) 4rem;
5308
+ max-width: 920px;
5309
+ margin-left: auto;
5310
+ margin-right: auto;
5311
+ }
5312
+ .hero {
5313
+ margin-bottom: 2rem;
5314
+ }
5315
+ .brand {
5316
+ font-size: 0.75rem;
5317
+ font-weight: 600;
5318
+ letter-spacing: 0.12em;
5319
+ text-transform: uppercase;
5320
+ color: var(--muted);
5321
+ margin-bottom: 0.35rem;
5322
+ }
5323
+ h1 {
5324
+ font-size: 1.75rem;
5325
+ font-weight: 700;
5326
+ letter-spacing: -0.03em;
5327
+ margin: 0 0 1rem;
5328
+ color: var(--text);
5329
+ }
5330
+ .metrics {
5331
+ display: flex;
5332
+ flex-wrap: wrap;
5333
+ gap: 0.65rem;
5334
+ margin-bottom: 0.5rem;
5335
+ }
5336
+ .metric {
5337
+ background: var(--surface);
5338
+ border: 1px solid var(--border);
5339
+ border-radius: var(--radius);
5340
+ padding: 0.5rem 0.9rem;
5341
+ box-shadow: var(--shadow);
5342
+ display: flex;
5343
+ align-items: center;
5344
+ gap: 0.5rem;
5345
+ }
5346
+ .metric-label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
5347
+ .metric-value { font-weight: 600; font-size: 0.95rem; }
5348
+ .risk-low { color: var(--ok); }
5349
+ .risk-med { color: var(--warn); }
5350
+ .risk-high { color: var(--block); }
5351
+ .section { margin-top: 2.25rem; }
5352
+ .h2 {
5353
+ font-size: 1rem;
5354
+ font-weight: 600;
5355
+ margin: 0 0 0.85rem;
5356
+ color: var(--text);
5357
+ letter-spacing: -0.02em;
5358
+ }
5359
+ .snapshot {
5360
+ width: 100%;
5361
+ border-collapse: collapse;
5362
+ font-size: 0.9rem;
5363
+ background: var(--surface);
5364
+ border-radius: var(--radius);
5365
+ overflow: hidden;
5366
+ border: 1px solid var(--border);
5367
+ box-shadow: var(--shadow);
5368
+ }
5369
+ .snapshot th, .snapshot td {
5370
+ padding: 0.65rem 1rem;
5371
+ text-align: left;
5372
+ border-bottom: 1px solid var(--border);
5373
+ }
5374
+ .snapshot tr:last-child th, .snapshot tr:last-child td { border-bottom: none; }
5375
+ .snapshot th {
5376
+ width: 9rem;
5377
+ color: var(--muted);
5378
+ font-weight: 500;
5379
+ background: #f1f5f9;
5380
+ }
5381
+ table.results {
5382
+ width: 100%;
5383
+ border-collapse: collapse;
5384
+ font-size: 0.875rem;
5385
+ background: var(--surface);
5386
+ border-radius: var(--radius);
5387
+ overflow: hidden;
5388
+ border: 1px solid var(--border);
5389
+ box-shadow: var(--shadow);
5390
+ }
5391
+ table.results th, table.results td {
5392
+ padding: 0.55rem 0.85rem;
5393
+ text-align: left;
5394
+ border-bottom: 1px solid var(--border);
5395
+ }
5396
+ table.results tr:last-child td { border-bottom: none; }
5397
+ table.results thead th {
5398
+ background: #f1f5f9;
5399
+ color: var(--muted);
5400
+ font-weight: 600;
5401
+ font-size: 0.72rem;
5402
+ text-transform: uppercase;
5403
+ letter-spacing: 0.04em;
5404
+ }
5405
+ .td-icon { width: 2rem; vertical-align: middle; }
5406
+ .td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
5407
+ .check-name { font-weight: 600; }
5408
+ .dot {
5409
+ display: inline-block;
5410
+ width: 8px;
5411
+ height: 8px;
5412
+ border-radius: 50%;
5413
+ }
5414
+ .dot-ok { background: var(--ok); }
5415
+ .dot-warn { background: var(--warn); }
5416
+ .dot-block { background: var(--block); }
5417
+ .dot-skip { background: #cbd5e1; }
5418
+ .panel {
5419
+ background: var(--surface);
5420
+ border: 1px solid var(--border);
5421
+ border-radius: var(--radius);
5422
+ margin-bottom: 0.65rem;
5423
+ box-shadow: var(--shadow);
5424
+ }
5425
+ .panel summary {
5426
+ cursor: pointer;
5427
+ padding: 0.85rem 1rem;
5428
+ list-style: none;
5429
+ display: flex;
5430
+ align-items: center;
5431
+ justify-content: space-between;
5432
+ font-weight: 600;
5433
+ font-size: 0.9rem;
5434
+ }
5435
+ .panel summary::-webkit-details-marker { display: none; }
5436
+ .panel[open] summary { border-bottom: 1px solid var(--border); }
5437
+ .panel-body { padding: 0.75rem 1rem 1rem; }
5438
+ .nested summary { font-weight: 500; color: var(--warn); }
5439
+ .summary-count {
5440
+ font-size: 0.8rem;
5441
+ font-weight: 500;
5442
+ color: var(--muted);
5443
+ background: #f1f5f9;
5444
+ padding: 0.15rem 0.5rem;
5445
+ border-radius: 999px;
5446
+ }
5137
5447
  .card {
5138
- border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem 1rem;
5139
- margin-bottom: 0.65rem; background: #131c28;
5140
- }
5141
- .card.sev-block { border-left: 4px solid var(--block); }
5142
- .card.sev-warn { border-left: 4px solid var(--warn); }
5143
- .card.sev-info { border-left: 4px solid var(--info); }
5144
- table.meta { width: 100%; font-size: 0.8rem; border-collapse: collapse; margin: 0.5rem 0; }
5145
- table.meta th { text-align: left; color: var(--muted); width: 5.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
5448
+ border: 1px solid var(--border);
5449
+ border-radius: 8px;
5450
+ padding: 1rem;
5451
+ margin-bottom: 0.65rem;
5452
+ background: #fafafa;
5453
+ }
5454
+ .card:last-child { margin-bottom: 0; }
5455
+ .card.sev-block { border-left: 3px solid var(--block); }
5456
+ .card.sev-warn { border-left: 3px solid var(--warn); }
5457
+ .card.sev-info { border-left: 3px solid var(--info); }
5458
+ .card-title { font-size: 0.8rem; font-weight: 600; color: var(--muted); margin-bottom: 0.35rem; }
5459
+ .card-msg { margin: 0 0 0.65rem; font-size: 0.9rem; }
5460
+ table.meta { width: 100%; font-size: 0.78rem; border-collapse: collapse; margin: 0.35rem 0 0; }
5461
+ table.meta th { text-align: left; color: var(--muted); width: 4.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
5146
5462
  table.meta td { padding: 0.2rem 0; }
5463
+ table.meta code { font-size: 0.85em; background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; }
5147
5464
  .muted { color: var(--muted); }
5148
- .ok { color: var(--ok); }
5465
+ .empty-state { margin: 0; font-size: 0.9rem; color: var(--muted); }
5149
5466
  pre.code {
5150
- margin: 0.5rem 0 0; padding: 0.65rem 0.75rem; background: #0a0e14; border-radius: 6px;
5151
- overflow: auto; font-size: 0.78rem; border: 1px solid var(--border);
5152
- }
5153
- pre.code code { font-family: ui-monospace, monospace; white-space: pre; }
5154
- .suggested-fix {
5155
- margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
5156
- }
5467
+ margin: 0.5rem 0 0;
5468
+ padding: 0.75rem;
5469
+ background: #f1f5f9;
5470
+ border-radius: 6px;
5471
+ overflow: auto;
5472
+ font-size: 0.78rem;
5473
+ border: 1px solid var(--border);
5474
+ }
5475
+ pre.code code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
5476
+ .suggested-fix { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border); }
5477
+ .fix-label { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); margin-bottom: 0.35rem; }
5478
+ .pill-llm { background: var(--accent-soft); color: var(--accent); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.65rem; }
5157
5479
  .fix-md { font-size: 0.85rem; white-space: pre-wrap; margin: 0.25rem 0 0.5rem; }
5158
- .tag {
5159
- font-size: 0.65rem; background: var(--accent); color: var(--bg);
5160
- padding: 0.1rem 0.35rem; border-radius: 4px; vertical-align: middle;
5161
- }
5162
5480
  .disclaimer { font-size: 0.72rem; color: var(--muted); margin: 0.5rem 0 0; }
5163
- .appendix pre.md-raw {
5164
- white-space: pre-wrap; font-size: 0.85rem; background: var(--panel);
5165
- padding: 1rem; border-radius: 8px; border: 1px solid var(--border);
5166
- }
5167
- footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--muted); }
5481
+ pre.md-raw {
5482
+ white-space: pre-wrap;
5483
+ font-size: 0.85rem;
5484
+ background: var(--surface);
5485
+ padding: 1rem;
5486
+ border-radius: var(--radius);
5487
+ border: 1px solid var(--border);
5488
+ margin: 0;
5489
+ }
5490
+ footer {
5491
+ margin-top: 3rem;
5492
+ padding-top: 1.25rem;
5493
+ border-top: 1px solid var(--border);
5494
+ font-size: 0.8rem;
5495
+ color: var(--muted);
5496
+ }
5497
+ footer a { color: var(--accent); text-decoration: none; }
5498
+ footer a:hover { text-decoration: underline; }
5168
5499
  </style>
5169
5500
  </head>
5170
5501
  <body>
5171
- <h1>FrontGuard review</h1>
5172
- <div class="badges">${badgeImgs}</div>
5173
-
5174
- <h2>Snapshot</h2>
5175
- <table class="snapshot">
5176
- <tr><th>Risk</th><td><strong>${riskScore}</strong> (heuristic)</td></tr>
5177
- <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
5178
- <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
5179
- ${prBlock}
5180
- </table>
5502
+ <header class="hero">
5503
+ <div class="brand">FrontGuard</div>
5504
+ <h1>Code review report</h1>
5505
+ <div class="metrics">
5506
+ <div class="metric"><span class="metric-label">Risk</span><span class="metric-value ${riskClass}">${riskScore}</span></div>
5507
+ <div class="metric"><span class="metric-label">Mode</span><span class="metric-value">${escapeHtml(modeLabel)}</span></div>
5508
+ <div class="metric"><span class="metric-label">Blocking</span><span class="metric-value">${blocks}</span></div>
5509
+ <div class="metric"><span class="metric-label">Warnings</span><span class="metric-value">${warns}</span></div>
5510
+ <div class="metric"><span class="metric-label">Info</span><span class="metric-value">${infos}</span></div>
5511
+ </div>
5512
+ </header>
5181
5513
 
5182
- <h2>Check results</h2>
5183
- <table class="results">
5184
- <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5185
- <tbody>${checkRows}</tbody>
5186
- </table>
5514
+ <section class="section">
5515
+ <h2 class="h2">Overview</h2>
5516
+ <table class="snapshot">
5517
+ <tr><th>Risk score</th><td><strong>${riskScore}</strong> <span class="muted">\u2014 heuristic</span></td></tr>
5518
+ <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
5519
+ <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
5520
+ ${prBlock}
5521
+ </table>
5522
+ </section>
5187
5523
 
5188
- <details>
5189
- <summary>Blocking (${blocks})</summary>
5190
- <div class="details-body">${blockingHtml}</div>
5191
- </details>
5524
+ <section class="section">
5525
+ <h2 class="h2">Checks</h2>
5526
+ <table class="results">
5527
+ <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5528
+ <tbody>${checkRows}</tbody>
5529
+ </table>
5530
+ </section>
5192
5531
 
5193
- <details>
5194
- <summary>Warnings (${warns})</summary>
5195
- <div class="details-body">${warningsHtml}</div>
5196
- </details>
5197
-
5198
- <details>
5199
- <summary>Info (${infos})</summary>
5200
- <div class="details-body">${infoHtml}</div>
5201
- </details>
5532
+ <section class="section">
5533
+ <h2 class="h2">Findings</h2>
5534
+ <details class="panel"><summary>Blocking <span class="summary-count">${blocks}</span></summary><div class="panel-body">${blockingHtml}</div></details>
5535
+ <details class="panel"><summary>Warnings <span class="summary-count">${warns}</span></summary><div class="panel-body">${warningsHtml}</div></details>
5536
+ <details class="panel"><summary>Info <span class="summary-count">${infos}</span></summary><div class="panel-body">${infoHtml}</div></details>
5537
+ </section>
5202
5538
 
5203
5539
  ${appendix}
5204
5540
 
5205
5541
  <footer>
5206
- <p>Self-contained HTML report \u2014 open locally or from CI artifacts. Badges load from <a href="https://shields.io" style="color: var(--info);">shields.io</a>.</p>
5542
+ <p>Static report \u2014 open in any browser. Generated by <strong>FrontGuard</strong>.</p>
5207
5543
  </footer>
5208
5544
  </body>
5209
5545
  </html>`;
@@ -5605,6 +5941,17 @@ function formatConsole(p2) {
5605
5941
  return lines.join("\n");
5606
5942
  }
5607
5943
 
5944
+ // src/lib/http-fetch.ts
5945
+ function getFetch() {
5946
+ const f4 = globalThis.fetch;
5947
+ if (typeof f4 !== "function") {
5948
+ throw new Error(
5949
+ "FrontGuard needs Node.js 18+ (global fetch). Use NODE_VERSION / image >= 18 in CI."
5950
+ );
5951
+ }
5952
+ return f4;
5953
+ }
5954
+
5608
5955
  // src/llm/ollama.ts
5609
5956
  async function callOllamaChat(opts) {
5610
5957
  const fetch = getFetch();
@@ -5638,10 +5985,10 @@ async function callOllamaChat(opts) {
5638
5985
 
5639
5986
  // src/llm/finding-fixes.ts
5640
5987
  async function safeReadRepoFile(cwd, rel, maxChars) {
5641
- const root = path4.resolve(cwd);
5642
- const abs = path4.resolve(root, rel);
5643
- const relToRoot = path4.relative(root, abs);
5644
- if (relToRoot.startsWith("..") || path4.isAbsolute(relToRoot)) return null;
5988
+ const root = path5.resolve(cwd);
5989
+ const abs = path5.resolve(root, rel);
5990
+ const relToRoot = path5.relative(root, abs);
5991
+ if (relToRoot.startsWith("..") || path5.isAbsolute(relToRoot)) return null;
5645
5992
  try {
5646
5993
  let t3 = await fs.readFile(abs, "utf8");
5647
5994
  if (t3.length > maxChars) {
@@ -5670,7 +6017,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
5670
6017
  }
5671
6018
  let pkgSnippet = "";
5672
6019
  try {
5673
- const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
6020
+ const pj = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
5674
6021
  pkgSnippet = pj.slice(0, 4e3);
5675
6022
  } catch {
5676
6023
  pkgSnippet = "";
@@ -5754,7 +6101,7 @@ async function loadManualAppendix(opts) {
5754
6101
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
5755
6102
  const resolvedPath = filePath?.trim() || envFile;
5756
6103
  if (resolvedPath) {
5757
- const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
6104
+ const abs = path5.isAbsolute(resolvedPath) ? resolvedPath : path5.join(cwd, resolvedPath);
5758
6105
  try {
5759
6106
  let text = await fs.readFile(abs, "utf8");
5760
6107
  if (text.length > MAX_CHARS) {
@@ -5942,28 +6289,28 @@ async function runFrontGuard(opts) {
5942
6289
  const config = await loadConfig(opts.cwd);
5943
6290
  const mode = opts.enforce ? "enforce" : config.mode;
5944
6291
  const stack = await detectStack(opts.cwd);
5945
- const pr = await readGithubEvent();
6292
+ const pr = readPrContext(opts.cwd);
5946
6293
  const restrictFiles = pr?.files?.length ? pr.files : null;
5947
6294
  const [
5948
6295
  eslint,
5949
6296
  prettier,
5950
6297
  typescript,
5951
6298
  secrets,
5952
- tsAnyDelta,
5953
6299
  cycles,
5954
6300
  deadCode,
5955
- cwv,
6301
+ coreWebVitals,
6302
+ tsAnyDelta,
5956
6303
  customRules,
5957
6304
  aiStrict
5958
6305
  ] = await Promise.all([
5959
- runEslint(opts.cwd, config),
5960
- runPrettier(opts.cwd, config),
5961
- runTypeScript(opts.cwd, config, stack),
6306
+ runEslint(opts.cwd, config, stack, pr),
6307
+ runPrettier(opts.cwd, config, pr),
6308
+ runTypeScript(opts.cwd, config, stack, pr),
5962
6309
  runSecrets(opts.cwd, config, pr),
5963
- runTsAnyDelta(opts.cwd, config, stack),
5964
- runCycles(opts.cwd, config, stack),
6310
+ runCycles(opts.cwd, config, stack, pr),
5965
6311
  runDeadCode(opts.cwd, config, stack, pr),
5966
- runCwv(opts.cwd, config, stack, pr),
6312
+ runCoreWebVitals(opts.cwd, config, stack, pr),
6313
+ runTsAnyDelta(opts.cwd, config, stack),
5967
6314
  runCustomRules(opts.cwd, config, restrictFiles),
5968
6315
  runAiAssistedStrict(opts.cwd, config, pr)
5969
6316
  ]);
@@ -5975,15 +6322,15 @@ async function runFrontGuard(opts) {
5975
6322
  prettier,
5976
6323
  typescript,
5977
6324
  secrets,
5978
- tsAnyDelta,
5979
6325
  cycles,
5980
6326
  deadCode,
5981
6327
  bundle,
5982
- cwv,
5983
- customRules,
6328
+ coreWebVitals,
5984
6329
  aiStrict,
5985
6330
  prHygiene,
5986
- prSize
6331
+ prSize,
6332
+ tsAnyDelta,
6333
+ customRules
5987
6334
  ];
5988
6335
  applyAiAssistedEscalation(results, pr, config);
5989
6336
  results = await enrichFindingsWithOllamaFixes({
@@ -6014,7 +6361,7 @@ async function runFrontGuard(opts) {
6014
6361
  }
6015
6362
  if (opts.prCommentOut) {
6016
6363
  const snippet = formatBitbucketPrSnippet(report);
6017
- const abs = path4.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path4.join(opts.cwd, opts.prCommentOut);
6364
+ const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
6018
6365
  await fs.writeFile(abs, snippet, "utf8");
6019
6366
  g.stderr.write(
6020
6367
  `
@@ -6031,9 +6378,6 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6031
6378
  g.stdout.write(report.consoleText + "\n\n");
6032
6379
  g.stdout.write(report.markdown + "\n");
6033
6380
  }
6034
- if (opts.ci && g.env.GITHUB_TOKEN) {
6035
- await upsertBriefComment(report.markdown);
6036
- }
6037
6381
  const hasBlock = results.some((r4) => r4.findings.some((f4) => f4.severity === "block"));
6038
6382
  g.exitCode = mode === "enforce" && hasBlock ? 1 : 0;
6039
6383
  }
@@ -6042,13 +6386,13 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
6042
6386
  var init2 = defineCommand({
6043
6387
  meta: {
6044
6388
  name: "init",
6045
- description: "Add workflow, PR template, and frontguard.config.js"
6389
+ description: "Add Bitbucket pipeline example, pull_request_template.md, and frontguard.config.js"
6046
6390
  },
6047
6391
  run: async () => {
6048
6392
  const cwd = g.cwd();
6049
6393
  await initFrontGuard(cwd);
6050
6394
  g.stdout.write(
6051
- "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"
6395
+ "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"
6052
6396
  );
6053
6397
  }
6054
6398
  });
@@ -6058,11 +6402,6 @@ var run = defineCommand({
6058
6402
  description: "Run checks and print the review brief"
6059
6403
  },
6060
6404
  args: {
6061
- ci: {
6062
- type: "boolean",
6063
- description: "Upsert PR comment when GITHUB_TOKEN is available",
6064
- default: false
6065
- },
6066
6405
  markdown: {
6067
6406
  type: "boolean",
6068
6407
  description: "Print markdown only",
@@ -6089,7 +6428,6 @@ var run = defineCommand({
6089
6428
  run: async ({ args }) => {
6090
6429
  await runFrontGuard({
6091
6430
  cwd: g.cwd(),
6092
- ci: Boolean(args.ci),
6093
6431
  markdown: Boolean(args.markdown),
6094
6432
  enforce: Boolean(args.enforce),
6095
6433
  append: typeof args.append === "string" ? args.append : null,