@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 +755 -262
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +40 -5
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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 },
|
|
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 =
|
|
2461
|
-
const outPipeline =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
2964
|
+
await fs.access(path5.join(cwd, "pnpm-lock.yaml"));
|
|
2943
2965
|
pm = "pnpm";
|
|
2944
2966
|
} catch {
|
|
2945
2967
|
try {
|
|
2946
|
-
await fs.access(
|
|
2968
|
+
await fs.access(path5.join(cwd, "yarn.lock"));
|
|
2947
2969
|
pm = "yarn";
|
|
2948
2970
|
} catch {
|
|
2949
2971
|
try {
|
|
2950
|
-
await fs.access(
|
|
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 =
|
|
2992
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
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 =
|
|
2998
|
-
const absFile =
|
|
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 =
|
|
3083
|
+
let rel = path5.relative(resolvedCwd, absFile);
|
|
3003
3084
|
if (!rel || rel === ".") {
|
|
3004
|
-
return
|
|
3085
|
+
return path5.basename(absFile);
|
|
3005
3086
|
}
|
|
3006
|
-
return rel.split(
|
|
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 =
|
|
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 +
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
}
|
|
4137
|
-
|
|
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
|
|
4140
|
-
severity:
|
|
4141
|
-
message
|
|
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(
|
|
4533
|
+
if (await pathExists(path5.join(cwd, e3))) {
|
|
4317
4534
|
entry = e3;
|
|
4318
4535
|
break;
|
|
4319
4536
|
}
|
|
4320
4537
|
}
|
|
4321
|
-
if (!await pathExists(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
4865
|
+
async function runCoreWebVitals(cwd, config, stack, pr) {
|
|
4646
4866
|
const t0 = performance.now();
|
|
4647
|
-
const cfg = config.checks.
|
|
4867
|
+
const cfg = config.checks.coreWebVitals;
|
|
4648
4868
|
if (!cfg.enabled) {
|
|
4649
4869
|
return {
|
|
4650
|
-
checkId: "
|
|
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: "
|
|
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 =
|
|
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: "
|
|
4904
|
+
id: "core-web-vitals-img-tag",
|
|
4685
4905
|
severity: sev2,
|
|
4686
|
-
message: "Raw `<img>`
|
|
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: "
|
|
4912
|
+
id: "core-web-vitals-dsh",
|
|
4693
4913
|
severity: "warn",
|
|
4694
|
-
message: "`dangerouslySetInnerHTML` can
|
|
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: "
|
|
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 =
|
|
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: "
|
|
5218
|
+
skipped: "no PR context (Bitbucket PR pipeline + BITBUCKET_PR_ID required)"
|
|
4876
5219
|
};
|
|
4877
5220
|
}
|
|
4878
|
-
|
|
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: "
|
|
5243
|
+
skipped: "strictScanMode=decorator \u2014 no `@frontguard-ai:start` / `written by AI: start` regions in PR files"
|
|
4884
5244
|
};
|
|
4885
5245
|
}
|
|
4886
|
-
|
|
4887
|
-
|
|
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
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
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:
|
|
4903
|
-
message: `[AI
|
|
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"><
|
|
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}"><
|
|
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" ? "
|
|
5028
|
-
const
|
|
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 ? "
|
|
5041
|
-
return `<tr><td
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|
|
5081
|
-
const appendix = llmAppendix?.trim() ? `<section class="
|
|
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
|
|
5435
|
+
<title>FrontGuard \u2014 Report</title>
|
|
5088
5436
|
<style>
|
|
5089
5437
|
:root {
|
|
5090
|
-
--bg: #
|
|
5091
|
-
--
|
|
5092
|
-
--text: #
|
|
5093
|
-
--muted: #
|
|
5094
|
-
--border: #
|
|
5095
|
-
--
|
|
5096
|
-
--
|
|
5097
|
-
--
|
|
5098
|
-
--
|
|
5099
|
-
--
|
|
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;
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
.
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
.
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
.
|
|
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);
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
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
|
-
.
|
|
5618
|
+
.empty-state { margin: 0; font-size: 0.9rem; color: var(--muted); }
|
|
5145
5619
|
pre.code {
|
|
5146
|
-
margin: 0.5rem 0 0;
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
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
|
-
|
|
5160
|
-
white-space: pre-wrap;
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
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
|
-
<
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
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
|
-
<
|
|
5179
|
-
|
|
5180
|
-
<
|
|
5181
|
-
|
|
5182
|
-
|
|
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
|
-
<
|
|
5185
|
-
<
|
|
5186
|
-
<
|
|
5187
|
-
|
|
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
|
-
<
|
|
5190
|
-
<
|
|
5191
|
-
<div class="
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
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>
|
|
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 =
|
|
5649
|
-
const abs =
|
|
5650
|
-
const relToRoot =
|
|
5651
|
-
if (relToRoot.startsWith("..") ||
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
5971
|
-
runCycles(opts.cwd, config, stack),
|
|
6463
|
+
runCycles(opts.cwd, config, stack, pr),
|
|
5972
6464
|
runDeadCode(opts.cwd, config, stack, pr),
|
|
5973
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
`
|