@cleartrip/frontguard 0.2.0 → 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,7 +4,7 @@ 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
9
  import { fileURLToPath, pathToFileURL } from 'url';
10
10
  import { execFileSync, spawn } from 'child_process';
@@ -2399,7 +2399,7 @@ async function runMain(cmd, opts = {}) {
2399
2399
  }
2400
2400
  }
2401
2401
  function packageRoot() {
2402
- return path4.resolve(path4.dirname(fileURLToPath(import.meta.url)), "..");
2402
+ return path5.resolve(path5.dirname(fileURLToPath(import.meta.url)), "..");
2403
2403
  }
2404
2404
  var CONFIG = `import { defineConfig } from '@cleartrip/frontguard'
2405
2405
 
@@ -2417,7 +2417,14 @@ export default defineConfig({
2417
2417
  // },
2418
2418
 
2419
2419
  // checks: {
2420
- // 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
+ // },
2421
2428
  // cycles: { enabled: true },
2422
2429
  // deadCode: { enabled: true, gate: 'info' },
2423
2430
  // // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
@@ -2457,8 +2464,8 @@ If **Yes**, list tools and what they touched (helps reviewers run a stricter fir
2457
2464
  `;
2458
2465
  async function initFrontGuard(cwd) {
2459
2466
  const root = packageRoot();
2460
- const tplPath = path4.join(root, "templates", "bitbucket-pipelines.yml");
2461
- const outPipeline = path4.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2467
+ const tplPath = path5.join(root, "templates", "bitbucket-pipelines.yml");
2468
+ const outPipeline = path5.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2462
2469
  try {
2463
2470
  await fs.access(outPipeline);
2464
2471
  } catch {
@@ -2473,13 +2480,13 @@ async function initFrontGuard(cwd) {
2473
2480
  );
2474
2481
  }
2475
2482
  }
2476
- const cfgPath = path4.join(cwd, "frontguard.config.js");
2483
+ const cfgPath = path5.join(cwd, "frontguard.config.js");
2477
2484
  try {
2478
2485
  await fs.access(cfgPath);
2479
2486
  } catch {
2480
2487
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2481
2488
  }
2482
- const tplPr = path4.join(cwd, "pull_request_template.md");
2489
+ const tplPr = path5.join(cwd, "pull_request_template.md");
2483
2490
  try {
2484
2491
  await fs.access(tplPr);
2485
2492
  } catch {
@@ -2654,13 +2661,13 @@ function parseNumstat(output) {
2654
2661
  if (tab2 < 0) continue;
2655
2662
  const aStr = t3.slice(0, tab);
2656
2663
  const dStr = t3.slice(tab + 1, tab2);
2657
- const path17 = t3.slice(tab2 + 1);
2664
+ const path18 = t3.slice(tab2 + 1);
2658
2665
  const a3 = aStr === "-" ? 0 : Number(aStr);
2659
2666
  const d3 = dStr === "-" ? 0 : Number(dStr);
2660
2667
  if (!Number.isFinite(a3) || !Number.isFinite(d3)) continue;
2661
2668
  additions += a3;
2662
2669
  deletions += d3;
2663
- if (path17) files.push(path17);
2670
+ if (path18) files.push(path18);
2664
2671
  }
2665
2672
  return { additions, deletions, files };
2666
2673
  }
@@ -2817,7 +2824,7 @@ var defaultConfig = {
2817
2824
  maxDeltaBytes: null,
2818
2825
  maxTotalBytes: null
2819
2826
  },
2820
- cwv: {
2827
+ coreWebVitals: {
2821
2828
  enabled: true,
2822
2829
  gate: "warn",
2823
2830
  scanGlobs: ["app/**/*.{tsx,jsx}", "pages/**/*.{tsx,jsx}", "src/**/*.{tsx,jsx}"],
@@ -2838,6 +2845,16 @@ var defaultConfig = {
2838
2845
  }
2839
2846
  };
2840
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
+
2841
2858
  // src/config/load.ts
2842
2859
  var CONFIG_NAMES = [
2843
2860
  "frontguard.config.js",
@@ -2860,7 +2877,7 @@ function stripExtends(c4) {
2860
2877
  }
2861
2878
  async function loadExtendsLayer(cwd, spec) {
2862
2879
  if (!spec) return {};
2863
- const req = createRequire(path4.join(cwd, "package.json"));
2880
+ const req = createRequire(path5.join(cwd, "package.json"));
2864
2881
  const specs = Array.isArray(spec) ? spec : [spec];
2865
2882
  let merged = {};
2866
2883
  for (const s3 of specs) {
@@ -2879,7 +2896,7 @@ async function loadExtendsLayer(cwd, spec) {
2879
2896
  async function loadConfig(cwd) {
2880
2897
  let userFile = null;
2881
2898
  for (const name of CONFIG_NAMES) {
2882
- const full = path4.join(cwd, name);
2899
+ const full = path5.join(cwd, name);
2883
2900
  if (!fs2.existsSync(full)) continue;
2884
2901
  try {
2885
2902
  const mod = await importConfig(full);
@@ -2891,6 +2908,8 @@ async function loadConfig(cwd) {
2891
2908
  }
2892
2909
  const extendsSpec = userFile?.extends;
2893
2910
  const orgLayer = await loadExtendsLayer(cwd, extendsSpec);
2911
+ migrateLegacyConfigKeys(orgLayer);
2912
+ if (userFile) migrateLegacyConfigKeys(userFile);
2894
2913
  const user = userFile ? stripExtends(userFile) : {};
2895
2914
  const base = structuredClone(defaultConfig);
2896
2915
  const withOrg = defu2(orgLayer, base);
@@ -2909,7 +2928,7 @@ function hasDep(deps, name) {
2909
2928
  async function detectStack(cwd) {
2910
2929
  let pkg = {};
2911
2930
  try {
2912
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
2931
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
2913
2932
  pkg = JSON.parse(raw);
2914
2933
  } catch {
2915
2934
  return {
@@ -2929,7 +2948,7 @@ async function detectStack(cwd) {
2929
2948
  const isMonorepo = Boolean(pkg.workspaces);
2930
2949
  let tsStrict = null;
2931
2950
  try {
2932
- const tsconfigPath = path4.join(cwd, "tsconfig.json");
2951
+ const tsconfigPath = path5.join(cwd, "tsconfig.json");
2933
2952
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2934
2953
  const ts = JSON.parse(tsRaw);
2935
2954
  if (typeof ts.compilerOptions?.strict === "boolean") {
@@ -2939,15 +2958,15 @@ async function detectStack(cwd) {
2939
2958
  }
2940
2959
  let pm = "unknown";
2941
2960
  try {
2942
- await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
2961
+ await fs.access(path5.join(cwd, "pnpm-lock.yaml"));
2943
2962
  pm = "pnpm";
2944
2963
  } catch {
2945
2964
  try {
2946
- await fs.access(path4.join(cwd, "yarn.lock"));
2965
+ await fs.access(path5.join(cwd, "yarn.lock"));
2947
2966
  pm = "yarn";
2948
2967
  } catch {
2949
2968
  try {
2950
- await fs.access(path4.join(cwd, "package-lock.json"));
2969
+ await fs.access(path5.join(cwd, "package-lock.json"));
2951
2970
  pm = "npm";
2952
2971
  } catch {
2953
2972
  pm = "npm";
@@ -2977,6 +2996,65 @@ function formatStackOneLiner(s3) {
2977
2996
  bits.push(`pkg: ${s3.packageManager}`);
2978
2997
  return bits.join(" \xB7 ") || "unknown";
2979
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
+ }
2980
3058
  function stripFileUrl(p2) {
2981
3059
  let s3 = p2.trim();
2982
3060
  if (!/^file:/i.test(s3)) return s3;
@@ -2988,30 +3066,30 @@ function stripFileUrl(p2) {
2988
3066
  return s3;
2989
3067
  }
2990
3068
  function isUnderDir(parent, child) {
2991
- const rel = path4.relative(parent, child);
2992
- return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
3069
+ const rel = path5.relative(parent, child);
3070
+ return rel === "" || !rel.startsWith("..") && !path5.isAbsolute(rel);
2993
3071
  }
2994
3072
  function toRepoRelativePath(cwd, filePath) {
2995
3073
  if (!filePath?.trim()) return void 0;
2996
3074
  const raw = stripFileUrl(filePath);
2997
- const resolvedCwd = path4.resolve(cwd);
2998
- 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);
2999
3077
  if (!isUnderDir(resolvedCwd, absFile)) {
3000
3078
  return raw.split(/[/\\]/g).join("/");
3001
3079
  }
3002
- let rel = path4.relative(resolvedCwd, absFile);
3080
+ let rel = path5.relative(resolvedCwd, absFile);
3003
3081
  if (!rel || rel === ".") {
3004
- return path4.basename(absFile);
3082
+ return path5.basename(absFile);
3005
3083
  }
3006
- return rel.split(path4.sep).join("/");
3084
+ return rel.split(path5.sep).join("/");
3007
3085
  }
3008
3086
  function stripRepoAbsolutePaths(cwd, text) {
3009
3087
  if (!text || !cwd.trim()) return text;
3010
- const resolvedCwd = path4.resolve(cwd);
3088
+ const resolvedCwd = path5.resolve(cwd);
3011
3089
  const asPosix = (s3) => s3.replace(/\\/g, "/");
3012
3090
  const cwdPosix = asPosix(resolvedCwd);
3013
3091
  let out = asPosix(text);
3014
- const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
3092
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path5.sep])].filter(
3015
3093
  (p2) => p2.length > 1
3016
3094
  );
3017
3095
  for (const prefix of prefixes) {
@@ -3637,7 +3715,7 @@ async function pathExists(file) {
3637
3715
  }
3638
3716
  }
3639
3717
  async function resolveBin(cwd, name) {
3640
- const local = path4.join(cwd, "node_modules", ".bin", name);
3718
+ const local = path5.join(cwd, "node_modules", ".bin", name);
3641
3719
  if (await pathExists(local)) return local;
3642
3720
  const win = local + ".cmd";
3643
3721
  if (await pathExists(win)) return win;
@@ -3693,7 +3771,7 @@ async function runNpx(cwd, args) {
3693
3771
  // src/checks/eslint.ts
3694
3772
  async function hasEslintDependency(cwd) {
3695
3773
  try {
3696
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
3774
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
3697
3775
  const p2 = JSON.parse(raw);
3698
3776
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3699
3777
  } catch {
@@ -3705,6 +3783,7 @@ async function hasEslintConfig(cwd) {
3705
3783
  "eslint.config.js",
3706
3784
  "eslint.config.mjs",
3707
3785
  "eslint.config.cjs",
3786
+ "eslint.config.json",
3708
3787
  ".eslintrc",
3709
3788
  ".eslintrc.json",
3710
3789
  ".eslintrc.cjs",
@@ -3712,14 +3791,47 @@ async function hasEslintConfig(cwd) {
3712
3791
  ".eslintrc.yml"
3713
3792
  ];
3714
3793
  for (const c4 of candidates) {
3715
- if (await pathExists(path4.join(cwd, c4))) return true;
3794
+ if (await pathExists(path5.join(cwd, c4))) return true;
3716
3795
  }
3717
3796
  return false;
3718
3797
  }
3719
3798
  function meaningfulStderr(stderr) {
3720
3799
  return stderr.split("\n").filter((l3) => l3.trim() && !/^npm warn\b/i.test(l3)).join("\n").trim();
3721
3800
  }
3722
- 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) {
3723
3835
  const t0 = performance.now();
3724
3836
  if (!config.checks.eslint.enabled) {
3725
3837
  return {
@@ -3740,15 +3852,39 @@ async function runEslint(cwd, config, _stack) {
3740
3852
  };
3741
3853
  }
3742
3854
  const glob = config.checks.eslint.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx}";
3743
- const args = [
3744
- glob,
3745
- "--max-warnings",
3746
- "0",
3747
- "--no-error-on-unmatched-pattern",
3748
- "-f",
3749
- "json"
3750
- ];
3751
- 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
+ }
3752
3888
  const errText = meaningfulStderr(stderr);
3753
3889
  const findings = [];
3754
3890
  if (exitCode === 0) {
@@ -3814,7 +3950,25 @@ function truncate(s3, max) {
3814
3950
  }
3815
3951
 
3816
3952
  // src/checks/prettier.ts
3817
- 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) {
3818
3972
  const t0 = performance.now();
3819
3973
  if (!config.checks.prettier.enabled) {
3820
3974
  return {
@@ -3825,11 +3979,36 @@ async function runPrettier(cwd, config) {
3825
3979
  };
3826
3980
  }
3827
3981
  const glob = config.checks.prettier.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx,json,md,css,scss,yml,yaml}";
3828
- const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "prettier", [
3829
- "--check",
3830
- glob,
3831
- "--ignore-unknown"
3832
- ]);
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
+ }
3833
4012
  const findings = [];
3834
4013
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
3835
4014
  return {
@@ -3858,7 +4037,7 @@ function truncate2(s3, max) {
3858
4037
  if (s3.length <= max) return s3;
3859
4038
  return s3.slice(0, max) + "\u2026";
3860
4039
  }
3861
- async function runTypeScript(cwd, config, stack) {
4040
+ async function runTypeScript(cwd, config, stack, pr) {
3862
4041
  const t0 = performance.now();
3863
4042
  if (!config.checks.typescript.enabled) {
3864
4043
  return {
@@ -3868,7 +4047,7 @@ async function runTypeScript(cwd, config, stack) {
3868
4047
  skipped: "disabled in config"
3869
4048
  };
3870
4049
  }
3871
- const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
4050
+ const hasTs = stack.hasTypeScript || await pathExists(path5.join(cwd, "tsconfig.json"));
3872
4051
  if (!hasTs) {
3873
4052
  return {
3874
4053
  checkId: "typescript",
@@ -3877,7 +4056,14 @@ async function runTypeScript(cwd, config, stack) {
3877
4056
  skipped: "no TypeScript project detected"
3878
4057
  };
3879
4058
  }
3880
- 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
+ ];
3881
4067
  const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "tsc", args);
3882
4068
  const findings = [];
3883
4069
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
@@ -3890,12 +4076,16 @@ async function runTypeScript(cwd, config, stack) {
3890
4076
  }
3891
4077
  if (exitCode !== 0) {
3892
4078
  const out = [stdout2, stderr].filter(Boolean).join("\n").trim();
3893
- findings.push({
3894
- id: "tsc",
3895
- severity: "warn",
3896
- message: "TypeScript compiler reported diagnostics",
3897
- detail: out ? truncate3(out, 8e3) : `exit ${exitCode}`
3898
- });
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
+ }
3899
4089
  }
3900
4090
  return {
3901
4091
  checkId: "typescript",
@@ -3981,7 +4171,7 @@ async function runSecrets(cwd, config, pr) {
3981
4171
  });
3982
4172
  break;
3983
4173
  }
3984
- const full = path4.join(cwd, rel);
4174
+ const full = path5.join(cwd, rel);
3985
4175
  let content;
3986
4176
  try {
3987
4177
  content = await fs.readFile(full, "utf8");
@@ -4007,7 +4197,7 @@ async function runSecrets(cwd, config, pr) {
4007
4197
  };
4008
4198
  }
4009
4199
  function isProbablyTextFile(rel) {
4010
- const ext = path4.extname(rel).toLowerCase();
4200
+ const ext = path5.extname(rel).toLowerCase();
4011
4201
  return TEXT_EXT.has(ext);
4012
4202
  }
4013
4203
 
@@ -4106,6 +4296,25 @@ function sectionMentioned(body, hint) {
4106
4296
  }
4107
4297
 
4108
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
+ }
4109
4318
  function runPrSize(config, pr) {
4110
4319
  const t0 = performance.now();
4111
4320
  if (!config.checks.prSize.enabled) {
@@ -4124,21 +4333,26 @@ function runPrSize(config, pr) {
4124
4333
  skipped: "no PR context (run in a Bitbucket pull-request pipeline so BITBUCKET_PR_* and git diff are available)"
4125
4334
  };
4126
4335
  }
4127
- const findings = [];
4128
4336
  const lines = pr.additions + pr.deletions;
4129
- const { warnLines, softBlockLines } = config.checks.prSize;
4130
- if (lines >= softBlockLines) {
4131
- findings.push({
4132
- id: "pr-size-large",
4133
- severity: "warn",
4134
- message: `PR is very large (${lines} lines changed; \u2265 ${softBlockLines})`,
4135
- detail: "Consider splitting to improve review quality."
4136
- });
4137
- } 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
+ );
4138
4351
  findings.push({
4139
- id: "pr-size-medium",
4140
- severity: "info",
4141
- 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."
4142
4356
  });
4143
4357
  }
4144
4358
  return {
@@ -4300,7 +4514,7 @@ async function runTsAnyDelta(cwd, config, stack) {
4300
4514
  function gateSeverity2(g4) {
4301
4515
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4302
4516
  }
4303
- async function runCycles(cwd, config, stack) {
4517
+ async function runCycles(cwd, config, stack, pr) {
4304
4518
  const t0 = performance.now();
4305
4519
  const cfg = config.checks.cycles;
4306
4520
  if (!cfg.enabled || !stack.hasTypeScript) {
@@ -4313,12 +4527,12 @@ async function runCycles(cwd, config, stack) {
4313
4527
  }
4314
4528
  let entry = cfg.entries[0] ?? "src";
4315
4529
  for (const e3 of cfg.entries) {
4316
- if (await pathExists(path4.join(cwd, e3))) {
4530
+ if (await pathExists(path5.join(cwd, e3))) {
4317
4531
  entry = e3;
4318
4532
  break;
4319
4533
  }
4320
4534
  }
4321
- if (!await pathExists(path4.join(cwd, entry))) {
4535
+ if (!await pathExists(path5.join(cwd, entry))) {
4322
4536
  return {
4323
4537
  checkId: "cycles",
4324
4538
  findings: [],
@@ -4326,10 +4540,13 @@ async function runCycles(cwd, config, stack) {
4326
4540
  skipped: `entry path not found (${entry})`
4327
4541
  };
4328
4542
  }
4543
+ const prMadge = filterPrFilesForMadge(pr);
4544
+ const prExisting = prMadge?.length ? await existingRepoPaths(cwd, prMadge) : [];
4545
+ const roots = prExisting.length > 0 ? prExisting : [entry];
4329
4546
  const args = [
4330
4547
  "-y",
4331
4548
  "madge@6",
4332
- entry,
4549
+ ...roots,
4333
4550
  "--extensions",
4334
4551
  "ts,tsx,js,jsx",
4335
4552
  "--circular",
@@ -4351,7 +4568,7 @@ async function runCycles(cwd, config, stack) {
4351
4568
  findings.push({
4352
4569
  id: "import-cycle",
4353
4570
  severity: gateSeverity2(cfg.gate),
4354
- message: "Circular dependencies detected (madge)",
4571
+ message: prExisting.length > 0 ? "Circular dependencies detected (madge, scoped to PR files)" : "Circular dependencies detected (madge)",
4355
4572
  detail: truncate4(out || `exit ${exitCode}`, 12e3)
4356
4573
  });
4357
4574
  } else if (exitCode !== 0) {
@@ -4442,7 +4659,7 @@ async function sumGlobBytes(cwd, patterns) {
4442
4659
  });
4443
4660
  for (const rel of files) {
4444
4661
  try {
4445
- const st = await fs.stat(path4.join(cwd, rel));
4662
+ const st = await fs.stat(path5.join(cwd, rel));
4446
4663
  total += st.size;
4447
4664
  } catch {
4448
4665
  }
@@ -4451,7 +4668,7 @@ async function sumGlobBytes(cwd, patterns) {
4451
4668
  return total;
4452
4669
  }
4453
4670
  async function readBaseline(cwd, relPath, baseRef) {
4454
- const disk = path4.join(cwd, relPath);
4671
+ const disk = path5.join(cwd, relPath);
4455
4672
  try {
4456
4673
  const raw = await fs.readFile(disk, "utf8");
4457
4674
  return JSON.parse(raw);
@@ -4495,7 +4712,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4495
4712
  if (!script) return { run: true };
4496
4713
  let scripts;
4497
4714
  try {
4498
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
4715
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
4499
4716
  const pkg = JSON.parse(raw);
4500
4717
  scripts = pkg.scripts;
4501
4718
  } catch {
@@ -4642,12 +4859,12 @@ async function runBundle(cwd, config, stack) {
4642
4859
  function gateSeverity5(g4) {
4643
4860
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4644
4861
  }
4645
- async function runCwv(cwd, config, stack, pr) {
4862
+ async function runCoreWebVitals(cwd, config, stack, pr) {
4646
4863
  const t0 = performance.now();
4647
- const cfg = config.checks.cwv;
4864
+ const cfg = config.checks.coreWebVitals;
4648
4865
  if (!cfg.enabled) {
4649
4866
  return {
4650
- checkId: "cwv",
4867
+ checkId: "core-web-vitals",
4651
4868
  findings: [],
4652
4869
  durationMs: 0,
4653
4870
  skipped: "disabled in config"
@@ -4655,7 +4872,7 @@ async function runCwv(cwd, config, stack, pr) {
4655
4872
  }
4656
4873
  if (stack.hasReactNative && !stack.hasNext) {
4657
4874
  return {
4658
- checkId: "cwv",
4875
+ checkId: "core-web-vitals",
4659
4876
  findings: [],
4660
4877
  durationMs: Math.round(performance.now() - t0),
4661
4878
  skipped: "skipped for React Native"
@@ -4671,7 +4888,7 @@ async function runCwv(cwd, config, stack, pr) {
4671
4888
  const findings = [];
4672
4889
  const sev2 = gateSeverity5(cfg.gate);
4673
4890
  for (const rel of toScan.slice(0, 400)) {
4674
- const full = path4.join(cwd, rel);
4891
+ const full = path5.join(cwd, rel);
4675
4892
  let text;
4676
4893
  try {
4677
4894
  text = await fs.readFile(full, "utf8");
@@ -4681,23 +4898,23 @@ async function runCwv(cwd, config, stack, pr) {
4681
4898
  if (text.length > cfg.maxFileBytes) continue;
4682
4899
  if (stack.hasNext && /<img\b/i.test(text) && !/from\s+['"]next\/image['"]/.test(text)) {
4683
4900
  findings.push({
4684
- id: "cwv-img-tag",
4901
+ id: "core-web-vitals-img-tag",
4685
4902
  severity: sev2,
4686
- message: "Raw `<img>` detected \u2014 prefer `next/image` for LCP-friendly delivery",
4903
+ message: "Raw `<img>` \u2014 prefer `next/image` for LCP-friendly delivery",
4687
4904
  file: rel
4688
4905
  });
4689
4906
  }
4690
4907
  if (/dangerouslySetInnerHTML/i.test(text)) {
4691
4908
  findings.push({
4692
- id: "cwv-dsh",
4909
+ id: "core-web-vitals-dsh",
4693
4910
  severity: "warn",
4694
- message: "`dangerouslySetInnerHTML` can impact main-thread work \u2014 validate necessity",
4911
+ message: "`dangerouslySetInnerHTML` can add main-thread work \u2014 validate necessity",
4695
4912
  file: rel
4696
4913
  });
4697
4914
  }
4698
4915
  }
4699
4916
  return {
4700
- checkId: "cwv",
4917
+ checkId: "core-web-vitals",
4701
4918
  findings: dedupeFindings(findings).slice(0, 40),
4702
4919
  durationMs: Math.round(performance.now() - t0)
4703
4920
  };
@@ -4756,7 +4973,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4756
4973
  });
4757
4974
  break;
4758
4975
  }
4759
- const full = path4.join(cwd, rel);
4976
+ const full = path5.join(cwd, rel);
4760
4977
  let content;
4761
4978
  try {
4762
4979
  content = await fs.readFile(full, "utf8");
@@ -4872,7 +5089,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4872
5089
  checkId: "ai-assisted-strict",
4873
5090
  findings: [],
4874
5091
  durationMs: Math.round(performance.now() - t0),
4875
- skipped: "not a pull request context"
5092
+ skipped: "no PR context (Bitbucket PR pipeline + BITBUCKET_PR_ID required)"
4876
5093
  };
4877
5094
  }
4878
5095
  if (!pr.aiAssisted) {
@@ -4887,7 +5104,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
4887
5104
  const gate = cfg.gate;
4888
5105
  const findings = [];
4889
5106
  for (const rel of files) {
4890
- const full = path4.join(cwd, rel);
5107
+ const full = path5.join(cwd, rel);
4891
5108
  let content;
4892
5109
  try {
4893
5110
  content = await fs.readFile(full, "utf8");
@@ -4952,25 +5169,6 @@ function escapeHtml(s3) {
4952
5169
  }
4953
5170
 
4954
5171
  // src/report/html-report.ts
4955
- function shieldUrl(label, message, color) {
4956
- const q2 = new URLSearchParams({ label, message, color, style: "for-the-badge" });
4957
- return `https://img.shields.io/static/v1?${q2}`;
4958
- }
4959
- function riskColor(risk) {
4960
- if (risk === "LOW") return "brightgreen";
4961
- if (risk === "MEDIUM") return "orange";
4962
- return "red";
4963
- }
4964
- function modeColor(mode) {
4965
- return mode === "enforce" ? "critical" : "blue";
4966
- }
4967
- function countColor(kind, n3) {
4968
- if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
4969
- if (kind === "info") return n3 === 0 ? "inactive" : "informational";
4970
- if (n3 === 0) return "brightgreen";
4971
- if (n3 <= 10) return "yellow";
4972
- return "orange";
4973
- }
4974
5172
  function parseLineHint(detail) {
4975
5173
  if (!detail) return 0;
4976
5174
  const m3 = /^line\s+(\d+)/i.exec(detail.trim());
@@ -5002,13 +5200,20 @@ function formatDuration(ms) {
5002
5200
  const r4 = s3 % 60;
5003
5201
  return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5004
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
+ }
5005
5210
  function renderFindingCard(cwd, r4, f4) {
5006
5211
  const d3 = normalizeFinding(cwd, f4);
5007
5212
  const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
5008
- 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>` : "";
5009
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>` : "";
5010
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>` : "";
5011
- 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>`;
5012
5217
  }
5013
5218
  function buildHtmlReport(p2) {
5014
5219
  const {
@@ -5024,21 +5229,11 @@ function buildHtmlReport(p2) {
5024
5229
  lines,
5025
5230
  llmAppendix
5026
5231
  } = p2;
5027
- const modeLabel = mode === "enforce" ? "enforce" : "warn only";
5028
- const badges = [
5029
- ["risk", riskScore, riskColor(riskScore)],
5030
- ["mode", modeLabel, modeColor(mode)],
5031
- ["blocking", String(blocks), countColor("block", blocks)],
5032
- ["warnings", String(warns), countColor("warn", warns)],
5033
- ["info", String(infos), countColor("info", infos)]
5034
- ];
5035
- const badgeImgs = badges.map(([l3, m3, c4]) => {
5036
- const alt = `${l3}: ${m3}`;
5037
- return `<img class="badge" src="${escapeHtml(shieldUrl(l3, m3, c4))}" alt="${escapeHtml(alt)}" loading="lazy" />`;
5038
- }).join(" ");
5232
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5233
+ const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5039
5234
  const checkRows = results.map((r4) => {
5040
- const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
5041
- 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>`;
5042
5237
  }).join("\n");
5043
5238
  const blockItems = sortFindings(
5044
5239
  cwd,
@@ -5065,141 +5260,286 @@ function buildHtmlReport(p2) {
5065
5260
  byCheck.set(item.r.checkId, list);
5066
5261
  }
5067
5262
  const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
5068
- 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");
5069
5264
  let warningsHtml = "";
5070
5265
  if (warnItems.length === 0) {
5071
- warningsHtml = '<p class="ok">No warnings.</p>';
5266
+ warningsHtml = '<p class="empty-state">No warnings.</p>';
5072
5267
  } else {
5073
5268
  for (const cid of checkOrder) {
5074
5269
  const group = sortFindings(cwd, byCheck.get(cid));
5075
5270
  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>`;
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>`;
5077
5272
  }
5078
5273
  }
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");
5080
- const prBlock = pr && lines != null ? `<tr><th>PR size</th><td>${lines} LOC (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
5081
- 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>` : "";
5082
5277
  return `<!DOCTYPE html>
5083
5278
  <html lang="en">
5084
5279
  <head>
5085
5280
  <meta charset="utf-8" />
5086
5281
  <meta name="viewport" content="width=device-width, initial-scale=1" />
5087
- <title>FrontGuard report</title>
5282
+ <title>FrontGuard \u2014 Report</title>
5088
5283
  <style>
5089
5284
  :root {
5090
- --bg: #0f1419;
5091
- --panel: #1a2332;
5092
- --text: #e7ecf3;
5093
- --muted: #8b9aab;
5094
- --border: #2d3d52;
5095
- --block: #f87171;
5096
- --warn: #fbbf24;
5097
- --info: #38bdf8;
5098
- --accent: #a78bfa;
5099
- --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);
5100
5298
  }
5101
5299
  * { box-sizing: border-box; }
5102
5300
  body {
5103
- margin: 0; font-family: ui-sans-serif, system-ui, sans-serif;
5104
- background: var(--bg); color: var(--text); line-height: 1.5;
5105
- padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem;
5106
- max-width: 58rem; margin-left: auto; margin-right: auto;
5107
- }
5108
- h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
5109
- h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
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; }
5115
- h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
5116
- h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
5117
- .badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
5118
- .badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
5119
- details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
5120
- summary {
5121
- cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
5122
- list-style: none; display: flex; align-items: center; gap: 0.5rem;
5123
- }
5124
- summary::-webkit-details-marker { display: none; }
5125
- details[open] > summary { border-bottom: 1px solid var(--border); }
5126
- .details-body { padding: 0.75rem 1rem 1rem; }
5127
- table.results { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0.5rem 0 1rem; }
5128
- table.results th, table.results td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
5129
- table.results th { background: #243044; color: var(--muted); font-weight: 600; }
5130
- .snapshot { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 0.5rem 0; }
5131
- .snapshot th, .snapshot td { border: 1px solid var(--border); padding: 0.5rem 0.65rem; vertical-align: top; }
5132
- .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
+ }
5133
5447
  .card {
5134
- border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem 1rem;
5135
- margin-bottom: 0.65rem; background: #131c28;
5136
- }
5137
- .card.sev-block { border-left: 4px solid var(--block); }
5138
- .card.sev-warn { border-left: 4px solid var(--warn); }
5139
- .card.sev-info { border-left: 4px solid var(--info); }
5140
- table.meta { width: 100%; font-size: 0.8rem; border-collapse: collapse; margin: 0.5rem 0; }
5141
- 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; }
5142
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; }
5143
5464
  .muted { color: var(--muted); }
5144
- .ok { color: var(--ok); }
5465
+ .empty-state { margin: 0; font-size: 0.9rem; color: var(--muted); }
5145
5466
  pre.code {
5146
- margin: 0.5rem 0 0; padding: 0.65rem 0.75rem; background: #0a0e14; border-radius: 6px;
5147
- overflow: auto; font-size: 0.78rem; border: 1px solid var(--border);
5148
- }
5149
- pre.code code { font-family: ui-monospace, monospace; white-space: pre; }
5150
- .suggested-fix {
5151
- margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
5152
- }
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; }
5153
5479
  .fix-md { font-size: 0.85rem; white-space: pre-wrap; margin: 0.25rem 0 0.5rem; }
5154
- .tag {
5155
- font-size: 0.65rem; background: var(--accent); color: var(--bg);
5156
- padding: 0.1rem 0.35rem; border-radius: 4px; vertical-align: middle;
5157
- }
5158
5480
  .disclaimer { font-size: 0.72rem; color: var(--muted); margin: 0.5rem 0 0; }
5159
- .appendix pre.md-raw {
5160
- white-space: pre-wrap; font-size: 0.85rem; background: var(--panel);
5161
- padding: 1rem; border-radius: 8px; border: 1px solid var(--border);
5162
- }
5163
- 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; }
5164
5499
  </style>
5165
5500
  </head>
5166
5501
  <body>
5167
- <h1>FrontGuard review</h1>
5168
- <div class="badges">${badgeImgs}</div>
5169
-
5170
- <h2>Snapshot</h2>
5171
- <table class="snapshot">
5172
- <tr><th>Risk</th><td><strong>${riskScore}</strong> (heuristic)</td></tr>
5173
- <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
5174
- <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
5175
- ${prBlock}
5176
- </table>
5177
-
5178
- <h2>Check results</h2>
5179
- <table class="results">
5180
- <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5181
- <tbody>${checkRows}</tbody>
5182
- </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>
5183
5513
 
5184
- <details>
5185
- <summary>Blocking (${blocks})</summary>
5186
- <div class="details-body">${blockingHtml}</div>
5187
- </details>
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>
5188
5523
 
5189
- <details>
5190
- <summary>Warnings (${warns})</summary>
5191
- <div class="details-body">${warningsHtml}</div>
5192
- </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>
5193
5531
 
5194
- <details>
5195
- <summary>Info (${infos})</summary>
5196
- <div class="details-body">${infoHtml}</div>
5197
- </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>
5198
5538
 
5199
5539
  ${appendix}
5200
5540
 
5201
5541
  <footer>
5202
- <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>
5203
5543
  </footer>
5204
5544
  </body>
5205
5545
  </html>`;
@@ -5645,10 +5985,10 @@ async function callOllamaChat(opts) {
5645
5985
 
5646
5986
  // src/llm/finding-fixes.ts
5647
5987
  async function safeReadRepoFile(cwd, rel, maxChars) {
5648
- const root = path4.resolve(cwd);
5649
- const abs = path4.resolve(root, rel);
5650
- const relToRoot = path4.relative(root, abs);
5651
- 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;
5652
5992
  try {
5653
5993
  let t3 = await fs.readFile(abs, "utf8");
5654
5994
  if (t3.length > maxChars) {
@@ -5677,7 +6017,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
5677
6017
  }
5678
6018
  let pkgSnippet = "";
5679
6019
  try {
5680
- const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
6020
+ const pj = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
5681
6021
  pkgSnippet = pj.slice(0, 4e3);
5682
6022
  } catch {
5683
6023
  pkgSnippet = "";
@@ -5761,7 +6101,7 @@ async function loadManualAppendix(opts) {
5761
6101
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
5762
6102
  const resolvedPath = filePath?.trim() || envFile;
5763
6103
  if (resolvedPath) {
5764
- const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
6104
+ const abs = path5.isAbsolute(resolvedPath) ? resolvedPath : path5.join(cwd, resolvedPath);
5765
6105
  try {
5766
6106
  let text = await fs.readFile(abs, "utf8");
5767
6107
  if (text.length > MAX_CHARS) {
@@ -5956,21 +6296,21 @@ async function runFrontGuard(opts) {
5956
6296
  prettier,
5957
6297
  typescript,
5958
6298
  secrets,
5959
- tsAnyDelta,
5960
6299
  cycles,
5961
6300
  deadCode,
5962
- cwv,
6301
+ coreWebVitals,
6302
+ tsAnyDelta,
5963
6303
  customRules,
5964
6304
  aiStrict
5965
6305
  ] = await Promise.all([
5966
- runEslint(opts.cwd, config),
5967
- runPrettier(opts.cwd, config),
5968
- 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),
5969
6309
  runSecrets(opts.cwd, config, pr),
5970
- runTsAnyDelta(opts.cwd, config, stack),
5971
- runCycles(opts.cwd, config, stack),
6310
+ runCycles(opts.cwd, config, stack, pr),
5972
6311
  runDeadCode(opts.cwd, config, stack, pr),
5973
- runCwv(opts.cwd, config, stack, pr),
6312
+ runCoreWebVitals(opts.cwd, config, stack, pr),
6313
+ runTsAnyDelta(opts.cwd, config, stack),
5974
6314
  runCustomRules(opts.cwd, config, restrictFiles),
5975
6315
  runAiAssistedStrict(opts.cwd, config, pr)
5976
6316
  ]);
@@ -5982,15 +6322,15 @@ async function runFrontGuard(opts) {
5982
6322
  prettier,
5983
6323
  typescript,
5984
6324
  secrets,
5985
- tsAnyDelta,
5986
6325
  cycles,
5987
6326
  deadCode,
5988
6327
  bundle,
5989
- cwv,
5990
- customRules,
6328
+ coreWebVitals,
5991
6329
  aiStrict,
5992
6330
  prHygiene,
5993
- prSize
6331
+ prSize,
6332
+ tsAnyDelta,
6333
+ customRules
5994
6334
  ];
5995
6335
  applyAiAssistedEscalation(results, pr, config);
5996
6336
  results = await enrichFindingsWithOllamaFixes({
@@ -6021,7 +6361,7 @@ async function runFrontGuard(opts) {
6021
6361
  }
6022
6362
  if (opts.prCommentOut) {
6023
6363
  const snippet = formatBitbucketPrSnippet(report);
6024
- 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);
6025
6365
  await fs.writeFile(abs, snippet, "utf8");
6026
6366
  g.stderr.write(
6027
6367
  `