@cleartrip/frontguard 0.2.0 → 0.2.2

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,16 @@ 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
+ // },
2428
+ // // AI strict: only // @frontguard-ai:start \u2026 :end (or // written by AI: start \u2026 :end) in PR files
2429
+ // // aiAssistedReview: { strictScanMode: 'decorator' },
2421
2430
  // cycles: { enabled: true },
2422
2431
  // deadCode: { enabled: true, gate: 'info' },
2423
2432
  // // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
@@ -2457,8 +2466,8 @@ If **Yes**, list tools and what they touched (helps reviewers run a stricter fir
2457
2466
  `;
2458
2467
  async function initFrontGuard(cwd) {
2459
2468
  const root = packageRoot();
2460
- const tplPath = path4.join(root, "templates", "bitbucket-pipelines.yml");
2461
- const outPipeline = path4.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2469
+ const tplPath = path5.join(root, "templates", "bitbucket-pipelines.yml");
2470
+ const outPipeline = path5.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2462
2471
  try {
2463
2472
  await fs.access(outPipeline);
2464
2473
  } catch {
@@ -2473,13 +2482,13 @@ async function initFrontGuard(cwd) {
2473
2482
  );
2474
2483
  }
2475
2484
  }
2476
- const cfgPath = path4.join(cwd, "frontguard.config.js");
2485
+ const cfgPath = path5.join(cwd, "frontguard.config.js");
2477
2486
  try {
2478
2487
  await fs.access(cfgPath);
2479
2488
  } catch {
2480
2489
  await fs.writeFile(cfgPath, CONFIG, "utf8");
2481
2490
  }
2482
- const tplPr = path4.join(cwd, "pull_request_template.md");
2491
+ const tplPr = path5.join(cwd, "pull_request_template.md");
2483
2492
  try {
2484
2493
  await fs.access(tplPr);
2485
2494
  } catch {
@@ -2654,13 +2663,13 @@ function parseNumstat(output) {
2654
2663
  if (tab2 < 0) continue;
2655
2664
  const aStr = t3.slice(0, tab);
2656
2665
  const dStr = t3.slice(tab + 1, tab2);
2657
- const path17 = t3.slice(tab2 + 1);
2666
+ const path18 = t3.slice(tab2 + 1);
2658
2667
  const a3 = aStr === "-" ? 0 : Number(aStr);
2659
2668
  const d3 = dStr === "-" ? 0 : Number(dStr);
2660
2669
  if (!Number.isFinite(a3) || !Number.isFinite(d3)) continue;
2661
2670
  additions += a3;
2662
2671
  deletions += d3;
2663
- if (path17) files.push(path17);
2672
+ if (path18) files.push(path18);
2664
2673
  }
2665
2674
  return { additions, deletions, files };
2666
2675
  }
@@ -2782,6 +2791,7 @@ var defaultConfig = {
2782
2791
  aiAssistedReview: {
2783
2792
  enabled: true,
2784
2793
  gate: "warn",
2794
+ strictScanMode: "both",
2785
2795
  escalate: {
2786
2796
  secretFindingsToBlock: true,
2787
2797
  tsAnyDeltaToBlock: true
@@ -2817,7 +2827,7 @@ var defaultConfig = {
2817
2827
  maxDeltaBytes: null,
2818
2828
  maxTotalBytes: null
2819
2829
  },
2820
- cwv: {
2830
+ coreWebVitals: {
2821
2831
  enabled: true,
2822
2832
  gate: "warn",
2823
2833
  scanGlobs: ["app/**/*.{tsx,jsx}", "pages/**/*.{tsx,jsx}", "src/**/*.{tsx,jsx}"],
@@ -2838,6 +2848,16 @@ var defaultConfig = {
2838
2848
  }
2839
2849
  };
2840
2850
 
2851
+ // src/config/migrate.ts
2852
+ function migrateLegacyConfigKeys(config) {
2853
+ const ch = config.checks;
2854
+ if (!ch) return;
2855
+ if ("cwv" in ch && !("coreWebVitals" in ch)) {
2856
+ ch.coreWebVitals = ch.cwv;
2857
+ delete ch.cwv;
2858
+ }
2859
+ }
2860
+
2841
2861
  // src/config/load.ts
2842
2862
  var CONFIG_NAMES = [
2843
2863
  "frontguard.config.js",
@@ -2860,7 +2880,7 @@ function stripExtends(c4) {
2860
2880
  }
2861
2881
  async function loadExtendsLayer(cwd, spec) {
2862
2882
  if (!spec) return {};
2863
- const req = createRequire(path4.join(cwd, "package.json"));
2883
+ const req = createRequire(path5.join(cwd, "package.json"));
2864
2884
  const specs = Array.isArray(spec) ? spec : [spec];
2865
2885
  let merged = {};
2866
2886
  for (const s3 of specs) {
@@ -2879,7 +2899,7 @@ async function loadExtendsLayer(cwd, spec) {
2879
2899
  async function loadConfig(cwd) {
2880
2900
  let userFile = null;
2881
2901
  for (const name of CONFIG_NAMES) {
2882
- const full = path4.join(cwd, name);
2902
+ const full = path5.join(cwd, name);
2883
2903
  if (!fs2.existsSync(full)) continue;
2884
2904
  try {
2885
2905
  const mod = await importConfig(full);
@@ -2891,6 +2911,8 @@ async function loadConfig(cwd) {
2891
2911
  }
2892
2912
  const extendsSpec = userFile?.extends;
2893
2913
  const orgLayer = await loadExtendsLayer(cwd, extendsSpec);
2914
+ migrateLegacyConfigKeys(orgLayer);
2915
+ if (userFile) migrateLegacyConfigKeys(userFile);
2894
2916
  const user = userFile ? stripExtends(userFile) : {};
2895
2917
  const base = structuredClone(defaultConfig);
2896
2918
  const withOrg = defu2(orgLayer, base);
@@ -2909,7 +2931,7 @@ function hasDep(deps, name) {
2909
2931
  async function detectStack(cwd) {
2910
2932
  let pkg = {};
2911
2933
  try {
2912
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
2934
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
2913
2935
  pkg = JSON.parse(raw);
2914
2936
  } catch {
2915
2937
  return {
@@ -2929,7 +2951,7 @@ async function detectStack(cwd) {
2929
2951
  const isMonorepo = Boolean(pkg.workspaces);
2930
2952
  let tsStrict = null;
2931
2953
  try {
2932
- const tsconfigPath = path4.join(cwd, "tsconfig.json");
2954
+ const tsconfigPath = path5.join(cwd, "tsconfig.json");
2933
2955
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
2934
2956
  const ts = JSON.parse(tsRaw);
2935
2957
  if (typeof ts.compilerOptions?.strict === "boolean") {
@@ -2939,15 +2961,15 @@ async function detectStack(cwd) {
2939
2961
  }
2940
2962
  let pm = "unknown";
2941
2963
  try {
2942
- await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
2964
+ await fs.access(path5.join(cwd, "pnpm-lock.yaml"));
2943
2965
  pm = "pnpm";
2944
2966
  } catch {
2945
2967
  try {
2946
- await fs.access(path4.join(cwd, "yarn.lock"));
2968
+ await fs.access(path5.join(cwd, "yarn.lock"));
2947
2969
  pm = "yarn";
2948
2970
  } catch {
2949
2971
  try {
2950
- await fs.access(path4.join(cwd, "package-lock.json"));
2972
+ await fs.access(path5.join(cwd, "package-lock.json"));
2951
2973
  pm = "npm";
2952
2974
  } catch {
2953
2975
  pm = "npm";
@@ -2977,6 +2999,65 @@ function formatStackOneLiner(s3) {
2977
2999
  bits.push(`pkg: ${s3.packageManager}`);
2978
3000
  return bits.join(" \xB7 ") || "unknown";
2979
3001
  }
3002
+ function normalizePrPath(p2) {
3003
+ return p2.replace(/\\/g, "/").replace(/^\.\//, "");
3004
+ }
3005
+ function hasPrFileList(pr) {
3006
+ return Boolean(pr?.files?.length);
3007
+ }
3008
+ function prPathSet(pr) {
3009
+ if (!hasPrFileList(pr)) return null;
3010
+ return new Set(pr.files.map(normalizePrPath));
3011
+ }
3012
+ function isPathInPrScope(relOrAbs, cwd, prSet) {
3013
+ const raw = relOrAbs.replace(/^file:\/\//, "");
3014
+ let rel = normalizePrPath(raw);
3015
+ if (path5.isAbsolute(raw)) {
3016
+ rel = normalizePrPath(path5.relative(cwd, raw));
3017
+ }
3018
+ if (prSet.has(rel)) return true;
3019
+ const trimmed = rel.replace(/^\.\//, "");
3020
+ if (prSet.has(trimmed)) return true;
3021
+ return false;
3022
+ }
3023
+ var ESLINT_EXT = /\.(js|cjs|mjs|jsx|ts|tsx)$/i;
3024
+ var PRETTIER_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|json|md|css|scss|sass|less|yml|yaml|vue|svelte)$/i;
3025
+ var CYCLES_EXT = /\.(tsx?|jsx?|mjs|cjs|js)$/i;
3026
+ function filterPrFilesForEslint(pr) {
3027
+ if (!hasPrFileList(pr)) return null;
3028
+ return pr.files.map(normalizePrPath).filter((f4) => ESLINT_EXT.test(f4));
3029
+ }
3030
+ function filterPrFilesForPrettier(pr) {
3031
+ if (!hasPrFileList(pr)) return null;
3032
+ return pr.files.map(normalizePrPath).filter((f4) => PRETTIER_EXT.test(f4));
3033
+ }
3034
+ function filterPrFilesForMadge(pr) {
3035
+ if (!hasPrFileList(pr)) return null;
3036
+ return pr.files.map(normalizePrPath).filter((f4) => CYCLES_EXT.test(f4));
3037
+ }
3038
+ async function existingRepoPaths(cwd, rels) {
3039
+ const out = [];
3040
+ for (const rel of rels) {
3041
+ try {
3042
+ await fs.access(path5.join(cwd, rel));
3043
+ out.push(rel);
3044
+ } catch {
3045
+ }
3046
+ }
3047
+ return out;
3048
+ }
3049
+ function filterTscOutputToPrFiles(output, cwd, prSet) {
3050
+ const lines = output.split("\n");
3051
+ const kept = [];
3052
+ for (const line of lines) {
3053
+ const m3 = /^(.+?)\(\d+,\d+\):\s/.exec(line);
3054
+ if (m3?.[1]) {
3055
+ if (isPathInPrScope(m3[1], cwd, prSet)) kept.push(line);
3056
+ continue;
3057
+ }
3058
+ }
3059
+ return kept.join("\n").trim();
3060
+ }
2980
3061
  function stripFileUrl(p2) {
2981
3062
  let s3 = p2.trim();
2982
3063
  if (!/^file:/i.test(s3)) return s3;
@@ -2988,30 +3069,30 @@ function stripFileUrl(p2) {
2988
3069
  return s3;
2989
3070
  }
2990
3071
  function isUnderDir(parent, child) {
2991
- const rel = path4.relative(parent, child);
2992
- return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
3072
+ const rel = path5.relative(parent, child);
3073
+ return rel === "" || !rel.startsWith("..") && !path5.isAbsolute(rel);
2993
3074
  }
2994
3075
  function toRepoRelativePath(cwd, filePath) {
2995
3076
  if (!filePath?.trim()) return void 0;
2996
3077
  const raw = stripFileUrl(filePath);
2997
- const resolvedCwd = path4.resolve(cwd);
2998
- const absFile = path4.isAbsolute(raw) ? path4.resolve(raw) : path4.resolve(resolvedCwd, raw);
3078
+ const resolvedCwd = path5.resolve(cwd);
3079
+ const absFile = path5.isAbsolute(raw) ? path5.resolve(raw) : path5.resolve(resolvedCwd, raw);
2999
3080
  if (!isUnderDir(resolvedCwd, absFile)) {
3000
3081
  return raw.split(/[/\\]/g).join("/");
3001
3082
  }
3002
- let rel = path4.relative(resolvedCwd, absFile);
3083
+ let rel = path5.relative(resolvedCwd, absFile);
3003
3084
  if (!rel || rel === ".") {
3004
- return path4.basename(absFile);
3085
+ return path5.basename(absFile);
3005
3086
  }
3006
- return rel.split(path4.sep).join("/");
3087
+ return rel.split(path5.sep).join("/");
3007
3088
  }
3008
3089
  function stripRepoAbsolutePaths(cwd, text) {
3009
3090
  if (!text || !cwd.trim()) return text;
3010
- const resolvedCwd = path4.resolve(cwd);
3091
+ const resolvedCwd = path5.resolve(cwd);
3011
3092
  const asPosix = (s3) => s3.replace(/\\/g, "/");
3012
3093
  const cwdPosix = asPosix(resolvedCwd);
3013
3094
  let out = asPosix(text);
3014
- const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
3095
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path5.sep])].filter(
3015
3096
  (p2) => p2.length > 1
3016
3097
  );
3017
3098
  for (const prefix of prefixes) {
@@ -3637,7 +3718,7 @@ async function pathExists(file) {
3637
3718
  }
3638
3719
  }
3639
3720
  async function resolveBin(cwd, name) {
3640
- const local = path4.join(cwd, "node_modules", ".bin", name);
3721
+ const local = path5.join(cwd, "node_modules", ".bin", name);
3641
3722
  if (await pathExists(local)) return local;
3642
3723
  const win = local + ".cmd";
3643
3724
  if (await pathExists(win)) return win;
@@ -3693,7 +3774,7 @@ async function runNpx(cwd, args) {
3693
3774
  // src/checks/eslint.ts
3694
3775
  async function hasEslintDependency(cwd) {
3695
3776
  try {
3696
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
3777
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
3697
3778
  const p2 = JSON.parse(raw);
3698
3779
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3699
3780
  } catch {
@@ -3705,6 +3786,7 @@ async function hasEslintConfig(cwd) {
3705
3786
  "eslint.config.js",
3706
3787
  "eslint.config.mjs",
3707
3788
  "eslint.config.cjs",
3789
+ "eslint.config.json",
3708
3790
  ".eslintrc",
3709
3791
  ".eslintrc.json",
3710
3792
  ".eslintrc.cjs",
@@ -3712,14 +3794,47 @@ async function hasEslintConfig(cwd) {
3712
3794
  ".eslintrc.yml"
3713
3795
  ];
3714
3796
  for (const c4 of candidates) {
3715
- if (await pathExists(path4.join(cwd, c4))) return true;
3797
+ if (await pathExists(path5.join(cwd, c4))) return true;
3716
3798
  }
3717
3799
  return false;
3718
3800
  }
3719
3801
  function meaningfulStderr(stderr) {
3720
3802
  return stderr.split("\n").filter((l3) => l3.trim() && !/^npm warn\b/i.test(l3)).join("\n").trim();
3721
3803
  }
3722
- async function runEslint(cwd, config, _stack) {
3804
+ var ESLINT_BATCH = 80;
3805
+ async function runEslintOnPaths(cwd, relPaths) {
3806
+ let worstExit = 0;
3807
+ const merged = [];
3808
+ let stderrAcc = "";
3809
+ for (let i3 = 0; i3 < relPaths.length; i3 += ESLINT_BATCH) {
3810
+ const batch = relPaths.slice(i3, i3 + ESLINT_BATCH);
3811
+ const args = [
3812
+ ...batch,
3813
+ "--max-warnings",
3814
+ "0",
3815
+ "--no-error-on-unmatched-pattern",
3816
+ "-f",
3817
+ "json"
3818
+ ];
3819
+ const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "eslint", args);
3820
+ worstExit = Math.max(worstExit, exitCode ?? 0);
3821
+ stderrAcc += stderr;
3822
+ const t3 = stdout2.trim();
3823
+ if (t3) {
3824
+ try {
3825
+ const rows = JSON.parse(t3);
3826
+ if (Array.isArray(rows)) merged.push(...rows);
3827
+ } catch {
3828
+ }
3829
+ }
3830
+ }
3831
+ return {
3832
+ exitCode: worstExit,
3833
+ stdout: JSON.stringify(merged),
3834
+ stderr: stderrAcc
3835
+ };
3836
+ }
3837
+ async function runEslint(cwd, config, _stack, pr) {
3723
3838
  const t0 = performance.now();
3724
3839
  if (!config.checks.eslint.enabled) {
3725
3840
  return {
@@ -3740,15 +3855,39 @@ async function runEslint(cwd, config, _stack) {
3740
3855
  };
3741
3856
  }
3742
3857
  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);
3858
+ const prPaths = filterPrFilesForEslint(pr);
3859
+ let exitCode;
3860
+ let stdout2;
3861
+ let stderr;
3862
+ if (prPaths !== null) {
3863
+ if (prPaths.length === 0) {
3864
+ return {
3865
+ checkId: "eslint",
3866
+ findings: [],
3867
+ durationMs: Math.round(performance.now() - t0),
3868
+ skipped: "no lintable files in PR diff"
3869
+ };
3870
+ }
3871
+ const existing = await existingRepoPaths(cwd, prPaths);
3872
+ if (existing.length === 0) {
3873
+ return {
3874
+ checkId: "eslint",
3875
+ findings: [],
3876
+ durationMs: Math.round(performance.now() - t0),
3877
+ skipped: hasPrFileList(pr) ? "no lintable files in PR diff (added/modified only)" : "no files"
3878
+ };
3879
+ }
3880
+ ({ exitCode, stdout: stdout2, stderr } = await runEslintOnPaths(cwd, existing));
3881
+ } else {
3882
+ ({ exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "eslint", [
3883
+ glob,
3884
+ "--max-warnings",
3885
+ "0",
3886
+ "--no-error-on-unmatched-pattern",
3887
+ "-f",
3888
+ "json"
3889
+ ]));
3890
+ }
3752
3891
  const errText = meaningfulStderr(stderr);
3753
3892
  const findings = [];
3754
3893
  if (exitCode === 0) {
@@ -3814,7 +3953,25 @@ function truncate(s3, max) {
3814
3953
  }
3815
3954
 
3816
3955
  // src/checks/prettier.ts
3817
- async function runPrettier(cwd, config) {
3956
+ var PRETTIER_BATCH = 100;
3957
+ async function runPrettierOnPaths(cwd, relPaths) {
3958
+ let worstExit = 0;
3959
+ let stdoutAcc = "";
3960
+ let stderrAcc = "";
3961
+ for (let i3 = 0; i3 < relPaths.length; i3 += PRETTIER_BATCH) {
3962
+ const batch = relPaths.slice(i3, i3 + PRETTIER_BATCH);
3963
+ const r4 = await runNpmBinary(cwd, "prettier", [
3964
+ "--check",
3965
+ "--ignore-unknown",
3966
+ ...batch
3967
+ ]);
3968
+ worstExit = Math.max(worstExit, r4.exitCode ?? 0);
3969
+ stdoutAcc += r4.stdout;
3970
+ stderrAcc += r4.stderr;
3971
+ }
3972
+ return { exitCode: worstExit, stdout: stdoutAcc, stderr: stderrAcc };
3973
+ }
3974
+ async function runPrettier(cwd, config, pr) {
3818
3975
  const t0 = performance.now();
3819
3976
  if (!config.checks.prettier.enabled) {
3820
3977
  return {
@@ -3825,11 +3982,36 @@ async function runPrettier(cwd, config) {
3825
3982
  };
3826
3983
  }
3827
3984
  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
- ]);
3985
+ let exitCode;
3986
+ let stdout2;
3987
+ let stderr;
3988
+ const prPaths = filterPrFilesForPrettier(pr);
3989
+ if (prPaths !== null) {
3990
+ if (prPaths.length === 0) {
3991
+ return {
3992
+ checkId: "prettier",
3993
+ findings: [],
3994
+ durationMs: Math.round(performance.now() - t0),
3995
+ skipped: "no formattable files in PR diff"
3996
+ };
3997
+ }
3998
+ const existing = await existingRepoPaths(cwd, prPaths);
3999
+ if (existing.length === 0) {
4000
+ return {
4001
+ checkId: "prettier",
4002
+ findings: [],
4003
+ durationMs: Math.round(performance.now() - t0),
4004
+ skipped: hasPrFileList(pr) ? "no formattable files in PR diff" : "no files"
4005
+ };
4006
+ }
4007
+ ({ exitCode, stdout: stdout2, stderr } = await runPrettierOnPaths(cwd, existing));
4008
+ } else {
4009
+ ({ exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "prettier", [
4010
+ "--check",
4011
+ glob,
4012
+ "--ignore-unknown"
4013
+ ]));
4014
+ }
3833
4015
  const findings = [];
3834
4016
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
3835
4017
  return {
@@ -3858,7 +4040,7 @@ function truncate2(s3, max) {
3858
4040
  if (s3.length <= max) return s3;
3859
4041
  return s3.slice(0, max) + "\u2026";
3860
4042
  }
3861
- async function runTypeScript(cwd, config, stack) {
4043
+ async function runTypeScript(cwd, config, stack, pr) {
3862
4044
  const t0 = performance.now();
3863
4045
  if (!config.checks.typescript.enabled) {
3864
4046
  return {
@@ -3868,7 +4050,7 @@ async function runTypeScript(cwd, config, stack) {
3868
4050
  skipped: "disabled in config"
3869
4051
  };
3870
4052
  }
3871
- const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
4053
+ const hasTs = stack.hasTypeScript || await pathExists(path5.join(cwd, "tsconfig.json"));
3872
4054
  if (!hasTs) {
3873
4055
  return {
3874
4056
  checkId: "typescript",
@@ -3877,7 +4059,14 @@ async function runTypeScript(cwd, config, stack) {
3877
4059
  skipped: "no TypeScript project detected"
3878
4060
  };
3879
4061
  }
3880
- const args = ["--noEmit", ...config.checks.typescript.tscArgs ?? []];
4062
+ const extra = config.checks.typescript.tscArgs ?? [];
4063
+ const hasProject = extra.some((a3) => a3 === "-p" || a3 === "--project");
4064
+ const tsconfigPath = path5.join(cwd, "tsconfig.json");
4065
+ const args = [
4066
+ "--noEmit",
4067
+ ...hasProject || !await pathExists(tsconfigPath) ? [] : ["-p", "tsconfig.json"],
4068
+ ...extra
4069
+ ];
3881
4070
  const { exitCode, stdout: stdout2, stderr } = await runNpmBinary(cwd, "tsc", args);
3882
4071
  const findings = [];
3883
4072
  if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
@@ -3890,12 +4079,16 @@ async function runTypeScript(cwd, config, stack) {
3890
4079
  }
3891
4080
  if (exitCode !== 0) {
3892
4081
  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
- });
4082
+ const prS = prPathSet(pr);
4083
+ const scoped = prS && out ? filterTscOutputToPrFiles(out, cwd, prS) : out;
4084
+ if (scoped) {
4085
+ findings.push({
4086
+ id: "tsc",
4087
+ severity: "warn",
4088
+ message: prS ? "TypeScript: issues in PR-changed files (full project was typechecked)" : "TypeScript compiler reported diagnostics",
4089
+ detail: scoped ? truncate3(scoped, 8e3) : `exit ${exitCode}`
4090
+ });
4091
+ }
3899
4092
  }
3900
4093
  return {
3901
4094
  checkId: "typescript",
@@ -3981,7 +4174,7 @@ async function runSecrets(cwd, config, pr) {
3981
4174
  });
3982
4175
  break;
3983
4176
  }
3984
- const full = path4.join(cwd, rel);
4177
+ const full = path5.join(cwd, rel);
3985
4178
  let content;
3986
4179
  try {
3987
4180
  content = await fs.readFile(full, "utf8");
@@ -4007,7 +4200,7 @@ async function runSecrets(cwd, config, pr) {
4007
4200
  };
4008
4201
  }
4009
4202
  function isProbablyTextFile(rel) {
4010
- const ext = path4.extname(rel).toLowerCase();
4203
+ const ext = path5.extname(rel).toLowerCase();
4011
4204
  return TEXT_EXT.has(ext);
4012
4205
  }
4013
4206
 
@@ -4106,6 +4299,25 @@ function sectionMentioned(body, hint) {
4106
4299
  }
4107
4300
 
4108
4301
  // src/checks/pr-size.ts
4302
+ function expandMessage(template, lines, min) {
4303
+ return template.replaceAll("${lines}", String(lines)).replaceAll("${min}", String(min));
4304
+ }
4305
+ function defaultTiers(cfg) {
4306
+ return [
4307
+ {
4308
+ minLines: cfg.softBlockLines,
4309
+ severity: "warn",
4310
+ id: "pr-size-large",
4311
+ message: "PR is very large (${lines} lines changed; threshold ${min}). Consider splitting for review."
4312
+ },
4313
+ {
4314
+ minLines: cfg.warnLines,
4315
+ severity: "info",
4316
+ id: "pr-size-medium",
4317
+ message: "PR size is elevated (${lines} lines changed; threshold ${min})."
4318
+ }
4319
+ ];
4320
+ }
4109
4321
  function runPrSize(config, pr) {
4110
4322
  const t0 = performance.now();
4111
4323
  if (!config.checks.prSize.enabled) {
@@ -4124,21 +4336,26 @@ function runPrSize(config, pr) {
4124
4336
  skipped: "no PR context (run in a Bitbucket pull-request pipeline so BITBUCKET_PR_* and git diff are available)"
4125
4337
  };
4126
4338
  }
4127
- const findings = [];
4128
4339
  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) {
4340
+ const cfg = config.checks.prSize;
4341
+ const rawTiers = cfg.tiers?.length ? cfg.tiers : defaultTiers(cfg);
4342
+ const sorted = [...rawTiers].sort((a3, b3) => b3.minLines - a3.minLines);
4343
+ const findings = [];
4344
+ let i3 = 0;
4345
+ for (const tier of sorted) {
4346
+ if (lines < tier.minLines) continue;
4347
+ const id = tier.id ?? `pr-size-tier-${i3}`;
4348
+ i3 += 1;
4349
+ const message = tier.message ? expandMessage(tier.message, lines, tier.minLines) : expandMessage(
4350
+ "PR has ${lines} lines changed (\u2265 ${min}).",
4351
+ lines,
4352
+ tier.minLines
4353
+ );
4138
4354
  findings.push({
4139
- id: "pr-size-medium",
4140
- severity: "info",
4141
- message: `PR size is elevated (${lines} lines changed; \u2265 ${warnLines})`
4355
+ id,
4356
+ severity: tier.severity,
4357
+ message,
4358
+ detail: "Total = additions + deletions from the PR diff."
4142
4359
  });
4143
4360
  }
4144
4361
  return {
@@ -4300,7 +4517,7 @@ async function runTsAnyDelta(cwd, config, stack) {
4300
4517
  function gateSeverity2(g4) {
4301
4518
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4302
4519
  }
4303
- async function runCycles(cwd, config, stack) {
4520
+ async function runCycles(cwd, config, stack, pr) {
4304
4521
  const t0 = performance.now();
4305
4522
  const cfg = config.checks.cycles;
4306
4523
  if (!cfg.enabled || !stack.hasTypeScript) {
@@ -4313,12 +4530,12 @@ async function runCycles(cwd, config, stack) {
4313
4530
  }
4314
4531
  let entry = cfg.entries[0] ?? "src";
4315
4532
  for (const e3 of cfg.entries) {
4316
- if (await pathExists(path4.join(cwd, e3))) {
4533
+ if (await pathExists(path5.join(cwd, e3))) {
4317
4534
  entry = e3;
4318
4535
  break;
4319
4536
  }
4320
4537
  }
4321
- if (!await pathExists(path4.join(cwd, entry))) {
4538
+ if (!await pathExists(path5.join(cwd, entry))) {
4322
4539
  return {
4323
4540
  checkId: "cycles",
4324
4541
  findings: [],
@@ -4326,10 +4543,13 @@ async function runCycles(cwd, config, stack) {
4326
4543
  skipped: `entry path not found (${entry})`
4327
4544
  };
4328
4545
  }
4546
+ const prMadge = filterPrFilesForMadge(pr);
4547
+ const prExisting = prMadge?.length ? await existingRepoPaths(cwd, prMadge) : [];
4548
+ const roots = prExisting.length > 0 ? prExisting : [entry];
4329
4549
  const args = [
4330
4550
  "-y",
4331
4551
  "madge@6",
4332
- entry,
4552
+ ...roots,
4333
4553
  "--extensions",
4334
4554
  "ts,tsx,js,jsx",
4335
4555
  "--circular",
@@ -4351,7 +4571,7 @@ async function runCycles(cwd, config, stack) {
4351
4571
  findings.push({
4352
4572
  id: "import-cycle",
4353
4573
  severity: gateSeverity2(cfg.gate),
4354
- message: "Circular dependencies detected (madge)",
4574
+ message: prExisting.length > 0 ? "Circular dependencies detected (madge, scoped to PR files)" : "Circular dependencies detected (madge)",
4355
4575
  detail: truncate4(out || `exit ${exitCode}`, 12e3)
4356
4576
  });
4357
4577
  } else if (exitCode !== 0) {
@@ -4442,7 +4662,7 @@ async function sumGlobBytes(cwd, patterns) {
4442
4662
  });
4443
4663
  for (const rel of files) {
4444
4664
  try {
4445
- const st = await fs.stat(path4.join(cwd, rel));
4665
+ const st = await fs.stat(path5.join(cwd, rel));
4446
4666
  total += st.size;
4447
4667
  } catch {
4448
4668
  }
@@ -4451,7 +4671,7 @@ async function sumGlobBytes(cwd, patterns) {
4451
4671
  return total;
4452
4672
  }
4453
4673
  async function readBaseline(cwd, relPath, baseRef) {
4454
- const disk = path4.join(cwd, relPath);
4674
+ const disk = path5.join(cwd, relPath);
4455
4675
  try {
4456
4676
  const raw = await fs.readFile(disk, "utf8");
4457
4677
  return JSON.parse(raw);
@@ -4495,7 +4715,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4495
4715
  if (!script) return { run: true };
4496
4716
  let scripts;
4497
4717
  try {
4498
- const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
4718
+ const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
4499
4719
  const pkg = JSON.parse(raw);
4500
4720
  scripts = pkg.scripts;
4501
4721
  } catch {
@@ -4642,12 +4862,12 @@ async function runBundle(cwd, config, stack) {
4642
4862
  function gateSeverity5(g4) {
4643
4863
  return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4644
4864
  }
4645
- async function runCwv(cwd, config, stack, pr) {
4865
+ async function runCoreWebVitals(cwd, config, stack, pr) {
4646
4866
  const t0 = performance.now();
4647
- const cfg = config.checks.cwv;
4867
+ const cfg = config.checks.coreWebVitals;
4648
4868
  if (!cfg.enabled) {
4649
4869
  return {
4650
- checkId: "cwv",
4870
+ checkId: "core-web-vitals",
4651
4871
  findings: [],
4652
4872
  durationMs: 0,
4653
4873
  skipped: "disabled in config"
@@ -4655,7 +4875,7 @@ async function runCwv(cwd, config, stack, pr) {
4655
4875
  }
4656
4876
  if (stack.hasReactNative && !stack.hasNext) {
4657
4877
  return {
4658
- checkId: "cwv",
4878
+ checkId: "core-web-vitals",
4659
4879
  findings: [],
4660
4880
  durationMs: Math.round(performance.now() - t0),
4661
4881
  skipped: "skipped for React Native"
@@ -4671,7 +4891,7 @@ async function runCwv(cwd, config, stack, pr) {
4671
4891
  const findings = [];
4672
4892
  const sev2 = gateSeverity5(cfg.gate);
4673
4893
  for (const rel of toScan.slice(0, 400)) {
4674
- const full = path4.join(cwd, rel);
4894
+ const full = path5.join(cwd, rel);
4675
4895
  let text;
4676
4896
  try {
4677
4897
  text = await fs.readFile(full, "utf8");
@@ -4681,23 +4901,23 @@ async function runCwv(cwd, config, stack, pr) {
4681
4901
  if (text.length > cfg.maxFileBytes) continue;
4682
4902
  if (stack.hasNext && /<img\b/i.test(text) && !/from\s+['"]next\/image['"]/.test(text)) {
4683
4903
  findings.push({
4684
- id: "cwv-img-tag",
4904
+ id: "core-web-vitals-img-tag",
4685
4905
  severity: sev2,
4686
- message: "Raw `<img>` detected \u2014 prefer `next/image` for LCP-friendly delivery",
4906
+ message: "Raw `<img>` \u2014 prefer `next/image` for LCP-friendly delivery",
4687
4907
  file: rel
4688
4908
  });
4689
4909
  }
4690
4910
  if (/dangerouslySetInnerHTML/i.test(text)) {
4691
4911
  findings.push({
4692
- id: "cwv-dsh",
4912
+ id: "core-web-vitals-dsh",
4693
4913
  severity: "warn",
4694
- message: "`dangerouslySetInnerHTML` can impact main-thread work \u2014 validate necessity",
4914
+ message: "`dangerouslySetInnerHTML` can add main-thread work \u2014 validate necessity",
4695
4915
  file: rel
4696
4916
  });
4697
4917
  }
4698
4918
  }
4699
4919
  return {
4700
- checkId: "cwv",
4920
+ checkId: "core-web-vitals",
4701
4921
  findings: dedupeFindings(findings).slice(0, 40),
4702
4922
  durationMs: Math.round(performance.now() - t0)
4703
4923
  };
@@ -4756,7 +4976,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4756
4976
  });
4757
4977
  break;
4758
4978
  }
4759
- const full = path4.join(cwd, rel);
4979
+ const full = path5.join(cwd, rel);
4760
4980
  let content;
4761
4981
  try {
4762
4982
  content = await fs.readFile(full, "utf8");
@@ -4793,6 +5013,115 @@ async function runCustomRules(cwd, config, restrictToFiles) {
4793
5013
  durationMs: Math.round(performance.now() - t0)
4794
5014
  };
4795
5015
  }
5016
+
5017
+ // src/lib/ai-decorators.ts
5018
+ var FILE_SCAN_HEAD_LINES = 40;
5019
+ function commentInner(line) {
5020
+ const t3 = line.trim();
5021
+ const mLine = /^\/\/\s*(.*)$/.exec(t3);
5022
+ if (mLine) return mLine[1]?.trim() ?? "";
5023
+ const mBlock = /^\/\*\s*(.*?)\s*\*\/\s*$/.exec(t3);
5024
+ if (mBlock) return mBlock[1]?.trim() ?? "";
5025
+ return null;
5026
+ }
5027
+ function lineMarkerKind(line) {
5028
+ const inner = commentInner(line);
5029
+ if (!inner) return null;
5030
+ if (/@(?:frontguard-ai|ai-written)\s*:\s*file\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*file\b/i.test(inner))
5031
+ return "file";
5032
+ if (/@(?:frontguard-ai|ai-written)\s*:\s*start\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*start\b/i.test(inner))
5033
+ return "start";
5034
+ if (/@(?:frontguard-ai|ai-written)\s*:\s*end\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*end\b/i.test(inner))
5035
+ return "end";
5036
+ return null;
5037
+ }
5038
+ function isAiFileDirectiveLine(line) {
5039
+ return lineMarkerKind(line) === "file";
5040
+ }
5041
+ function parseAiMarkedRegions(source, fileHint) {
5042
+ const lines = source.split(/\r?\n/);
5043
+ const parseWarnings = [];
5044
+ const regions = [];
5045
+ let headNonEmpty = 0;
5046
+ for (let i3 = 0; i3 < lines.length && headNonEmpty < FILE_SCAN_HEAD_LINES; i3++) {
5047
+ if (!lines[i3]?.trim()) continue;
5048
+ headNonEmpty++;
5049
+ if (isAiFileDirectiveLine(lines[i3] ?? "")) {
5050
+ const bodyLines = lines.filter((_4, idx) => idx !== i3);
5051
+ regions.push({
5052
+ startLine: 1,
5053
+ endLine: lines.length,
5054
+ text: bodyLines.join("\n")
5055
+ });
5056
+ return { regions, parseWarnings };
5057
+ }
5058
+ }
5059
+ let open = false;
5060
+ let contentStart = null;
5061
+ const buf = [];
5062
+ for (let i3 = 0; i3 < lines.length; i3++) {
5063
+ const line = lines[i3] ?? "";
5064
+ const kind = lineMarkerKind(line);
5065
+ if (kind === "file") {
5066
+ parseWarnings.push(`${fileHint}:${i3 + 1}: @file directive ignored (only honored in first ${FILE_SCAN_HEAD_LINES} non-empty lines)`);
5067
+ if (open) buf.push(line);
5068
+ continue;
5069
+ }
5070
+ if (kind === "start") {
5071
+ if (open) {
5072
+ parseWarnings.push(`${fileHint}:${i3 + 1}: nested AI:start ignored (flatten your regions)`);
5073
+ buf.push(line);
5074
+ continue;
5075
+ }
5076
+ open = true;
5077
+ contentStart = i3 + 2;
5078
+ buf.length = 0;
5079
+ continue;
5080
+ }
5081
+ if (kind === "end") {
5082
+ if (!open) {
5083
+ parseWarnings.push(`${fileHint}:${i3 + 1}: stray AI:end`);
5084
+ continue;
5085
+ }
5086
+ open = false;
5087
+ if (contentStart !== null) {
5088
+ regions.push({
5089
+ startLine: contentStart,
5090
+ endLine: i3,
5091
+ text: buf.join("\n")
5092
+ });
5093
+ }
5094
+ contentStart = null;
5095
+ buf.length = 0;
5096
+ continue;
5097
+ }
5098
+ if (open) buf.push(line);
5099
+ }
5100
+ if (open && contentStart !== null) {
5101
+ parseWarnings.push(`${fileHint}: unclosed AI:start \u2014 treating region as ending at EOF`);
5102
+ regions.push({
5103
+ startLine: contentStart,
5104
+ endLine: lines.length,
5105
+ text: buf.join("\n")
5106
+ });
5107
+ }
5108
+ return { regions, parseWarnings };
5109
+ }
5110
+ function matchLineNumbersInRegion(regionText, regionStartLine, re) {
5111
+ const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
5112
+ const g4 = new RegExp(re.source, flags);
5113
+ const out = [];
5114
+ let m3;
5115
+ const text = regionText;
5116
+ while ((m3 = g4.exec(text)) !== null) {
5117
+ const lineInRegion = text.slice(0, m3.index).split("\n").length;
5118
+ out.push(regionStartLine + lineInRegion - 1);
5119
+ if (m3.index === g4.lastIndex) g4.lastIndex++;
5120
+ }
5121
+ return out;
5122
+ }
5123
+
5124
+ // src/checks/ai-assisted-strict.ts
4796
5125
  function sev(gate) {
4797
5126
  return gate === "block" ? "block" : gate === "info" ? "info" : "warn";
4798
5127
  }
@@ -4856,6 +5185,20 @@ var PATTERNS2 = [
4856
5185
  message: "Possible dynamic SQL/string \u2014 ensure parameterization, not string concat."
4857
5186
  }
4858
5187
  ];
5188
+ function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
5189
+ for (const { id, re, message, forceBlock } of PATTERNS2) {
5190
+ const lines = matchLineNumbersInRegion(regionText, regionStartLine, re);
5191
+ for (const line of lines) {
5192
+ findings.push({
5193
+ id,
5194
+ severity: forceBlock ? "block" : sev(gate),
5195
+ message: `${tag} ${message}`,
5196
+ file: rel,
5197
+ detail: `line ${line}`
5198
+ });
5199
+ }
5200
+ }
5201
+ }
4859
5202
  async function runAiAssistedStrict(cwd, config, pr) {
4860
5203
  const t0 = performance.now();
4861
5204
  const cfg = config.checks.aiAssistedReview;
@@ -4872,38 +5215,65 @@ async function runAiAssistedStrict(cwd, config, pr) {
4872
5215
  checkId: "ai-assisted-strict",
4873
5216
  findings: [],
4874
5217
  durationMs: Math.round(performance.now() - t0),
4875
- skipped: "not a pull request context"
5218
+ skipped: "no PR context (Bitbucket PR pipeline + BITBUCKET_PR_ID required)"
4876
5219
  };
4877
5220
  }
4878
- if (!pr.aiAssisted) {
5221
+ const mode = cfg.strictScanMode ?? "both";
5222
+ const gate = cfg.gate;
5223
+ const files = (pr.files ?? []).filter((f4) => CODE_EXT.test(f4)).slice(0, 150);
5224
+ const byRel = /* @__PURE__ */ new Map();
5225
+ let anyDecoratorInPr = false;
5226
+ for (const rel of files) {
5227
+ const full = path5.join(cwd, rel);
5228
+ try {
5229
+ const content = await fs.readFile(full, "utf8");
5230
+ if (content.length > 5e5) continue;
5231
+ const parsed = parseAiMarkedRegions(content, rel);
5232
+ byRel.set(rel, { content, parsed });
5233
+ if (parsed.regions.length > 0) anyDecoratorInPr = true;
5234
+ } catch {
5235
+ continue;
5236
+ }
5237
+ }
5238
+ if (mode === "decorator" && !anyDecoratorInPr) {
4879
5239
  return {
4880
5240
  checkId: "ai-assisted-strict",
4881
5241
  findings: [],
4882
5242
  durationMs: Math.round(performance.now() - t0),
4883
- skipped: "PR does not indicate AI-assisted code"
5243
+ skipped: "strictScanMode=decorator \u2014 no `@frontguard-ai:start` / `written by AI: start` regions in PR files"
4884
5244
  };
4885
5245
  }
4886
- const files = (pr.files ?? []).filter((f4) => CODE_EXT.test(f4)).slice(0, 150);
4887
- const gate = cfg.gate;
5246
+ if (mode === "pr-disclosure" && !pr.aiAssisted) {
5247
+ return {
5248
+ checkId: "ai-assisted-strict",
5249
+ findings: [],
5250
+ durationMs: Math.round(performance.now() - t0),
5251
+ skipped: "strictScanMode=pr-disclosure \u2014 PR does not indicate AI-assisted code"
5252
+ };
5253
+ }
5254
+ const useWholeFileFallback = (mode === "pr-disclosure" || mode === "both") && Boolean(pr.aiAssisted);
4888
5255
  const findings = [];
4889
5256
  for (const rel of files) {
4890
- const full = path4.join(cwd, rel);
4891
- let content;
4892
- try {
4893
- content = await fs.readFile(full, "utf8");
4894
- } catch {
4895
- continue;
4896
- }
4897
- if (content.length > 5e5) continue;
4898
- for (const { id, re, message, forceBlock } of PATTERNS2) {
4899
- if (!re.test(content)) continue;
5257
+ const entry = byRel.get(rel);
5258
+ if (!entry) continue;
5259
+ const { content, parsed } = entry;
5260
+ for (const w3 of parsed.parseWarnings) {
4900
5261
  findings.push({
4901
- id,
4902
- severity: forceBlock ? "block" : sev(gate),
4903
- message: `[AI-assisted strict] ${message}`,
5262
+ id: "ai-decorator-parse",
5263
+ severity: "info",
5264
+ message: `[AI markers] ${w3}`,
4904
5265
  file: rel
4905
5266
  });
4906
5267
  }
5268
+ if (parsed.regions.length > 0) {
5269
+ for (const r4 of parsed.regions) {
5270
+ scanRegion(rel, r4.text, r4.startLine, gate, "[AI-marked code]", findings);
5271
+ }
5272
+ continue;
5273
+ }
5274
+ if (useWholeFileFallback) {
5275
+ scanRegion(rel, content, 1, gate, "[AI-assisted strict]", findings);
5276
+ }
4907
5277
  }
4908
5278
  return {
4909
5279
  checkId: "ai-assisted-strict",
@@ -4915,7 +5285,7 @@ function dedupe(f4) {
4915
5285
  const s3 = /* @__PURE__ */ new Set();
4916
5286
  const out = [];
4917
5287
  for (const x3 of f4) {
4918
- const k3 = `${x3.id}:${x3.file ?? ""}`;
5288
+ const k3 = `${x3.id}:${x3.file ?? ""}:${x3.detail ?? ""}`;
4919
5289
  if (s3.has(k3)) continue;
4920
5290
  s3.add(k3);
4921
5291
  out.push(x3);
@@ -4952,25 +5322,6 @@ function escapeHtml(s3) {
4952
5322
  }
4953
5323
 
4954
5324
  // 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
5325
  function parseLineHint(detail) {
4975
5326
  if (!detail) return 0;
4976
5327
  const m3 = /^line\s+(\d+)/i.exec(detail.trim());
@@ -5002,13 +5353,20 @@ function formatDuration(ms) {
5002
5353
  const r4 = s3 % 60;
5003
5354
  return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5004
5355
  }
5356
+ function statusDot(r4) {
5357
+ if (r4.skipped) return '<span class="dot dot-skip" title="Skipped"></span>';
5358
+ if (r4.findings.length === 0) return '<span class="dot dot-ok" title="Clean"></span>';
5359
+ if (r4.findings.some((x3) => x3.severity === "block"))
5360
+ return '<span class="dot dot-block" title="Blocking"></span>';
5361
+ return '<span class="dot dot-warn" title="Issues"></span>';
5362
+ }
5005
5363
  function renderFindingCard(cwd, r4, f4) {
5006
5364
  const d3 = normalizeFinding(cwd, f4);
5007
5365
  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>` : "";
5366
+ 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
5367
  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
5368
  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>`;
5369
+ 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
5370
  }
5013
5371
  function buildHtmlReport(p2) {
5014
5372
  const {
@@ -5024,21 +5382,11 @@ function buildHtmlReport(p2) {
5024
5382
  lines,
5025
5383
  llmAppendix
5026
5384
  } = 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(" ");
5385
+ const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5386
+ const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5039
5387
  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>`;
5388
+ const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5389
+ return `<tr><td class="td-icon">${statusDot(r4)}</td><td><strong class="check-name">${escapeHtml(r4.checkId)}</strong></td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
5042
5390
  }).join("\n");
5043
5391
  const blockItems = sortFindings(
5044
5392
  cwd,
@@ -5065,141 +5413,286 @@ function buildHtmlReport(p2) {
5065
5413
  byCheck.set(item.r.checkId, list);
5066
5414
  }
5067
5415
  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");
5416
+ 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
5417
  let warningsHtml = "";
5070
5418
  if (warnItems.length === 0) {
5071
- warningsHtml = '<p class="ok">No warnings.</p>';
5419
+ warningsHtml = '<p class="empty-state">No warnings.</p>';
5072
5420
  } else {
5073
5421
  for (const cid of checkOrder) {
5074
5422
  const group = sortFindings(cwd, byCheck.get(cid));
5075
5423
  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>`;
5424
+ 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
5425
  }
5078
5426
  }
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>` : "";
5427
+ 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");
5428
+ 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>` : "";
5429
+ const appendix = llmAppendix?.trim() ? `<section class="section"><h2 class="h2">Appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
5082
5430
  return `<!DOCTYPE html>
5083
5431
  <html lang="en">
5084
5432
  <head>
5085
5433
  <meta charset="utf-8" />
5086
5434
  <meta name="viewport" content="width=device-width, initial-scale=1" />
5087
- <title>FrontGuard report</title>
5435
+ <title>FrontGuard \u2014 Report</title>
5088
5436
  <style>
5089
5437
  :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;
5438
+ --bg: #f8fafc;
5439
+ --surface: #ffffff;
5440
+ --text: #0f172a;
5441
+ --muted: #64748b;
5442
+ --border: #e2e8f0;
5443
+ --accent: #4f46e5;
5444
+ --accent-soft: #eef2ff;
5445
+ --block: #dc2626;
5446
+ --warn: #d97706;
5447
+ --info: #0284c7;
5448
+ --ok: #16a34a;
5449
+ --radius: 10px;
5450
+ --shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
5100
5451
  }
5101
5452
  * { box-sizing: border-box; }
5102
5453
  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; }
5454
+ margin: 0;
5455
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
5456
+ background: var(--bg);
5457
+ color: var(--text);
5458
+ line-height: 1.55;
5459
+ font-size: 15px;
5460
+ padding: 2rem clamp(1rem, 4vw, 3rem) 4rem;
5461
+ max-width: 920px;
5462
+ margin-left: auto;
5463
+ margin-right: auto;
5464
+ }
5465
+ .hero {
5466
+ margin-bottom: 2rem;
5467
+ }
5468
+ .brand {
5469
+ font-size: 0.75rem;
5470
+ font-weight: 600;
5471
+ letter-spacing: 0.12em;
5472
+ text-transform: uppercase;
5473
+ color: var(--muted);
5474
+ margin-bottom: 0.35rem;
5475
+ }
5476
+ h1 {
5477
+ font-size: 1.75rem;
5478
+ font-weight: 700;
5479
+ letter-spacing: -0.03em;
5480
+ margin: 0 0 1rem;
5481
+ color: var(--text);
5482
+ }
5483
+ .metrics {
5484
+ display: flex;
5485
+ flex-wrap: wrap;
5486
+ gap: 0.65rem;
5487
+ margin-bottom: 0.5rem;
5488
+ }
5489
+ .metric {
5490
+ background: var(--surface);
5491
+ border: 1px solid var(--border);
5492
+ border-radius: var(--radius);
5493
+ padding: 0.5rem 0.9rem;
5494
+ box-shadow: var(--shadow);
5495
+ display: flex;
5496
+ align-items: center;
5497
+ gap: 0.5rem;
5498
+ }
5499
+ .metric-label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
5500
+ .metric-value { font-weight: 600; font-size: 0.95rem; }
5501
+ .risk-low { color: var(--ok); }
5502
+ .risk-med { color: var(--warn); }
5503
+ .risk-high { color: var(--block); }
5504
+ .section { margin-top: 2.25rem; }
5505
+ .h2 {
5506
+ font-size: 1rem;
5507
+ font-weight: 600;
5508
+ margin: 0 0 0.85rem;
5509
+ color: var(--text);
5510
+ letter-spacing: -0.02em;
5511
+ }
5512
+ .snapshot {
5513
+ width: 100%;
5514
+ border-collapse: collapse;
5515
+ font-size: 0.9rem;
5516
+ background: var(--surface);
5517
+ border-radius: var(--radius);
5518
+ overflow: hidden;
5519
+ border: 1px solid var(--border);
5520
+ box-shadow: var(--shadow);
5521
+ }
5522
+ .snapshot th, .snapshot td {
5523
+ padding: 0.65rem 1rem;
5524
+ text-align: left;
5525
+ border-bottom: 1px solid var(--border);
5526
+ }
5527
+ .snapshot tr:last-child th, .snapshot tr:last-child td { border-bottom: none; }
5528
+ .snapshot th {
5529
+ width: 9rem;
5530
+ color: var(--muted);
5531
+ font-weight: 500;
5532
+ background: #f1f5f9;
5533
+ }
5534
+ table.results {
5535
+ width: 100%;
5536
+ border-collapse: collapse;
5537
+ font-size: 0.875rem;
5538
+ background: var(--surface);
5539
+ border-radius: var(--radius);
5540
+ overflow: hidden;
5541
+ border: 1px solid var(--border);
5542
+ box-shadow: var(--shadow);
5543
+ }
5544
+ table.results th, table.results td {
5545
+ padding: 0.55rem 0.85rem;
5546
+ text-align: left;
5547
+ border-bottom: 1px solid var(--border);
5548
+ }
5549
+ table.results tr:last-child td { border-bottom: none; }
5550
+ table.results thead th {
5551
+ background: #f1f5f9;
5552
+ color: var(--muted);
5553
+ font-weight: 600;
5554
+ font-size: 0.72rem;
5555
+ text-transform: uppercase;
5556
+ letter-spacing: 0.04em;
5557
+ }
5558
+ .td-icon { width: 2rem; vertical-align: middle; }
5559
+ .td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
5560
+ .check-name { font-weight: 600; }
5561
+ .dot {
5562
+ display: inline-block;
5563
+ width: 8px;
5564
+ height: 8px;
5565
+ border-radius: 50%;
5566
+ }
5567
+ .dot-ok { background: var(--ok); }
5568
+ .dot-warn { background: var(--warn); }
5569
+ .dot-block { background: var(--block); }
5570
+ .dot-skip { background: #cbd5e1; }
5571
+ .panel {
5572
+ background: var(--surface);
5573
+ border: 1px solid var(--border);
5574
+ border-radius: var(--radius);
5575
+ margin-bottom: 0.65rem;
5576
+ box-shadow: var(--shadow);
5577
+ }
5578
+ .panel summary {
5579
+ cursor: pointer;
5580
+ padding: 0.85rem 1rem;
5581
+ list-style: none;
5582
+ display: flex;
5583
+ align-items: center;
5584
+ justify-content: space-between;
5585
+ font-weight: 600;
5586
+ font-size: 0.9rem;
5587
+ }
5588
+ .panel summary::-webkit-details-marker { display: none; }
5589
+ .panel[open] summary { border-bottom: 1px solid var(--border); }
5590
+ .panel-body { padding: 0.75rem 1rem 1rem; }
5591
+ .nested summary { font-weight: 500; color: var(--warn); }
5592
+ .summary-count {
5593
+ font-size: 0.8rem;
5594
+ font-weight: 500;
5595
+ color: var(--muted);
5596
+ background: #f1f5f9;
5597
+ padding: 0.15rem 0.5rem;
5598
+ border-radius: 999px;
5599
+ }
5133
5600
  .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; }
5601
+ border: 1px solid var(--border);
5602
+ border-radius: 8px;
5603
+ padding: 1rem;
5604
+ margin-bottom: 0.65rem;
5605
+ background: #fafafa;
5606
+ }
5607
+ .card:last-child { margin-bottom: 0; }
5608
+ .card.sev-block { border-left: 3px solid var(--block); }
5609
+ .card.sev-warn { border-left: 3px solid var(--warn); }
5610
+ .card.sev-info { border-left: 3px solid var(--info); }
5611
+ .card-title { font-size: 0.8rem; font-weight: 600; color: var(--muted); margin-bottom: 0.35rem; }
5612
+ .card-msg { margin: 0 0 0.65rem; font-size: 0.9rem; }
5613
+ table.meta { width: 100%; font-size: 0.78rem; border-collapse: collapse; margin: 0.35rem 0 0; }
5614
+ table.meta th { text-align: left; color: var(--muted); width: 4.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
5142
5615
  table.meta td { padding: 0.2rem 0; }
5616
+ table.meta code { font-size: 0.85em; background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; }
5143
5617
  .muted { color: var(--muted); }
5144
- .ok { color: var(--ok); }
5618
+ .empty-state { margin: 0; font-size: 0.9rem; color: var(--muted); }
5145
5619
  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
- }
5620
+ margin: 0.5rem 0 0;
5621
+ padding: 0.75rem;
5622
+ background: #f1f5f9;
5623
+ border-radius: 6px;
5624
+ overflow: auto;
5625
+ font-size: 0.78rem;
5626
+ border: 1px solid var(--border);
5627
+ }
5628
+ pre.code code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
5629
+ .suggested-fix { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border); }
5630
+ .fix-label { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); margin-bottom: 0.35rem; }
5631
+ .pill-llm { background: var(--accent-soft); color: var(--accent); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.65rem; }
5153
5632
  .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
5633
  .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); }
5634
+ pre.md-raw {
5635
+ white-space: pre-wrap;
5636
+ font-size: 0.85rem;
5637
+ background: var(--surface);
5638
+ padding: 1rem;
5639
+ border-radius: var(--radius);
5640
+ border: 1px solid var(--border);
5641
+ margin: 0;
5642
+ }
5643
+ footer {
5644
+ margin-top: 3rem;
5645
+ padding-top: 1.25rem;
5646
+ border-top: 1px solid var(--border);
5647
+ font-size: 0.8rem;
5648
+ color: var(--muted);
5649
+ }
5650
+ footer a { color: var(--accent); text-decoration: none; }
5651
+ footer a:hover { text-decoration: underline; }
5164
5652
  </style>
5165
5653
  </head>
5166
5654
  <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>
5655
+ <header class="hero">
5656
+ <div class="brand">FrontGuard</div>
5657
+ <h1>Code review report</h1>
5658
+ <div class="metrics">
5659
+ <div class="metric"><span class="metric-label">Risk</span><span class="metric-value ${riskClass}">${riskScore}</span></div>
5660
+ <div class="metric"><span class="metric-label">Mode</span><span class="metric-value">${escapeHtml(modeLabel)}</span></div>
5661
+ <div class="metric"><span class="metric-label">Blocking</span><span class="metric-value">${blocks}</span></div>
5662
+ <div class="metric"><span class="metric-label">Warnings</span><span class="metric-value">${warns}</span></div>
5663
+ <div class="metric"><span class="metric-label">Info</span><span class="metric-value">${infos}</span></div>
5664
+ </div>
5665
+ </header>
5177
5666
 
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>
5667
+ <section class="section">
5668
+ <h2 class="h2">Overview</h2>
5669
+ <table class="snapshot">
5670
+ <tr><th>Risk score</th><td><strong>${riskScore}</strong> <span class="muted">\u2014 heuristic</span></td></tr>
5671
+ <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
5672
+ <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
5673
+ ${prBlock}
5674
+ </table>
5675
+ </section>
5183
5676
 
5184
- <details>
5185
- <summary>Blocking (${blocks})</summary>
5186
- <div class="details-body">${blockingHtml}</div>
5187
- </details>
5677
+ <section class="section">
5678
+ <h2 class="h2">Checks</h2>
5679
+ <table class="results">
5680
+ <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
5681
+ <tbody>${checkRows}</tbody>
5682
+ </table>
5683
+ </section>
5188
5684
 
5189
- <details>
5190
- <summary>Warnings (${warns})</summary>
5191
- <div class="details-body">${warningsHtml}</div>
5192
- </details>
5193
-
5194
- <details>
5195
- <summary>Info (${infos})</summary>
5196
- <div class="details-body">${infoHtml}</div>
5197
- </details>
5685
+ <section class="section">
5686
+ <h2 class="h2">Findings</h2>
5687
+ <details class="panel"><summary>Blocking <span class="summary-count">${blocks}</span></summary><div class="panel-body">${blockingHtml}</div></details>
5688
+ <details class="panel"><summary>Warnings <span class="summary-count">${warns}</span></summary><div class="panel-body">${warningsHtml}</div></details>
5689
+ <details class="panel"><summary>Info <span class="summary-count">${infos}</span></summary><div class="panel-body">${infoHtml}</div></details>
5690
+ </section>
5198
5691
 
5199
5692
  ${appendix}
5200
5693
 
5201
5694
  <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>
5695
+ <p>Static report \u2014 open in any browser. Generated by <strong>FrontGuard</strong>.</p>
5203
5696
  </footer>
5204
5697
  </body>
5205
5698
  </html>`;
@@ -5645,10 +6138,10 @@ async function callOllamaChat(opts) {
5645
6138
 
5646
6139
  // src/llm/finding-fixes.ts
5647
6140
  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;
6141
+ const root = path5.resolve(cwd);
6142
+ const abs = path5.resolve(root, rel);
6143
+ const relToRoot = path5.relative(root, abs);
6144
+ if (relToRoot.startsWith("..") || path5.isAbsolute(relToRoot)) return null;
5652
6145
  try {
5653
6146
  let t3 = await fs.readFile(abs, "utf8");
5654
6147
  if (t3.length > maxChars) {
@@ -5677,7 +6170,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
5677
6170
  }
5678
6171
  let pkgSnippet = "";
5679
6172
  try {
5680
- const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
6173
+ const pj = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
5681
6174
  pkgSnippet = pj.slice(0, 4e3);
5682
6175
  } catch {
5683
6176
  pkgSnippet = "";
@@ -5761,7 +6254,7 @@ async function loadManualAppendix(opts) {
5761
6254
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
5762
6255
  const resolvedPath = filePath?.trim() || envFile;
5763
6256
  if (resolvedPath) {
5764
- const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
6257
+ const abs = path5.isAbsolute(resolvedPath) ? resolvedPath : path5.join(cwd, resolvedPath);
5765
6258
  try {
5766
6259
  let text = await fs.readFile(abs, "utf8");
5767
6260
  if (text.length > MAX_CHARS) {
@@ -5956,21 +6449,21 @@ async function runFrontGuard(opts) {
5956
6449
  prettier,
5957
6450
  typescript,
5958
6451
  secrets,
5959
- tsAnyDelta,
5960
6452
  cycles,
5961
6453
  deadCode,
5962
- cwv,
6454
+ coreWebVitals,
6455
+ tsAnyDelta,
5963
6456
  customRules,
5964
6457
  aiStrict
5965
6458
  ] = await Promise.all([
5966
- runEslint(opts.cwd, config),
5967
- runPrettier(opts.cwd, config),
5968
- runTypeScript(opts.cwd, config, stack),
6459
+ runEslint(opts.cwd, config, stack, pr),
6460
+ runPrettier(opts.cwd, config, pr),
6461
+ runTypeScript(opts.cwd, config, stack, pr),
5969
6462
  runSecrets(opts.cwd, config, pr),
5970
- runTsAnyDelta(opts.cwd, config, stack),
5971
- runCycles(opts.cwd, config, stack),
6463
+ runCycles(opts.cwd, config, stack, pr),
5972
6464
  runDeadCode(opts.cwd, config, stack, pr),
5973
- runCwv(opts.cwd, config, stack, pr),
6465
+ runCoreWebVitals(opts.cwd, config, stack, pr),
6466
+ runTsAnyDelta(opts.cwd, config, stack),
5974
6467
  runCustomRules(opts.cwd, config, restrictFiles),
5975
6468
  runAiAssistedStrict(opts.cwd, config, pr)
5976
6469
  ]);
@@ -5982,15 +6475,15 @@ async function runFrontGuard(opts) {
5982
6475
  prettier,
5983
6476
  typescript,
5984
6477
  secrets,
5985
- tsAnyDelta,
5986
6478
  cycles,
5987
6479
  deadCode,
5988
6480
  bundle,
5989
- cwv,
5990
- customRules,
6481
+ coreWebVitals,
5991
6482
  aiStrict,
5992
6483
  prHygiene,
5993
- prSize
6484
+ prSize,
6485
+ tsAnyDelta,
6486
+ customRules
5994
6487
  ];
5995
6488
  applyAiAssistedEscalation(results, pr, config);
5996
6489
  results = await enrichFindingsWithOllamaFixes({
@@ -6021,7 +6514,7 @@ async function runFrontGuard(opts) {
6021
6514
  }
6022
6515
  if (opts.prCommentOut) {
6023
6516
  const snippet = formatBitbucketPrSnippet(report);
6024
- const abs = path4.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path4.join(opts.cwd, opts.prCommentOut);
6517
+ const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
6025
6518
  await fs.writeFile(abs, snippet, "utf8");
6026
6519
  g.stderr.write(
6027
6520
  `