@cleartrip/frontguard 0.1.4 → 0.1.7
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 +951 -166
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/bitbucket-pipelines.yml +41 -4
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 path4, { sep, normalize, delimiter, resolve, dirname } from 'path';
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import { createRequire } from 'module';
|
|
10
10
|
import fs4 from 'fs';
|
|
@@ -2461,15 +2461,18 @@ export default defineConfig({
|
|
|
2461
2461
|
// bundle: { enabled: true, maxDeltaBytes: 50_000, maxTotalBytes: null },
|
|
2462
2462
|
// cycles: { enabled: true },
|
|
2463
2463
|
// deadCode: { enabled: true, gate: 'info' },
|
|
2464
|
-
// // LLM in CI
|
|
2465
|
-
// //
|
|
2466
|
-
// //
|
|
2467
|
-
// // or FRONTGUARD_MANUAL_APPENDIX_FILE / FRONTGUARD_MANUAL_APPENDIX
|
|
2464
|
+
// // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
|
|
2465
|
+
// // ollama serve && ollama pull llama3.2
|
|
2466
|
+
// // Paste workflow (no key): frontguard run --append ./.frontguard/review-notes.md
|
|
2468
2467
|
// llm: {
|
|
2469
2468
|
// enabled: true,
|
|
2470
|
-
// provider: '
|
|
2471
|
-
//
|
|
2469
|
+
// provider: 'ollama',
|
|
2470
|
+
// model: 'llama3.2',
|
|
2471
|
+
// ollamaUrl: 'http://127.0.0.1:11434',
|
|
2472
|
+
// perFindingFixes: true,
|
|
2473
|
+
// maxFixSuggestions: 8,
|
|
2472
2474
|
// },
|
|
2475
|
+
// // llm: { enabled: true, provider: 'openai', apiKeyEnv: 'OPENAI_API_KEY' },
|
|
2473
2476
|
// },
|
|
2474
2477
|
})
|
|
2475
2478
|
`;
|
|
@@ -2497,19 +2500,19 @@ async function ensureDir(dir) {
|
|
|
2497
2500
|
await fs.mkdir(dir, { recursive: true });
|
|
2498
2501
|
}
|
|
2499
2502
|
async function initFrontGuard(cwd) {
|
|
2500
|
-
const gh =
|
|
2503
|
+
const gh = path4.join(cwd, ".github", "workflows");
|
|
2501
2504
|
await ensureDir(gh);
|
|
2502
|
-
const wfPath =
|
|
2505
|
+
const wfPath = path4.join(gh, "frontguard.yml");
|
|
2503
2506
|
await fs.writeFile(wfPath, WORKFLOW, "utf8");
|
|
2504
|
-
const cfgPath =
|
|
2507
|
+
const cfgPath = path4.join(cwd, "frontguard.config.js");
|
|
2505
2508
|
try {
|
|
2506
2509
|
await fs.access(cfgPath);
|
|
2507
2510
|
} catch {
|
|
2508
2511
|
await fs.writeFile(cfgPath, CONFIG, "utf8");
|
|
2509
2512
|
}
|
|
2510
|
-
const tplRoot =
|
|
2513
|
+
const tplRoot = path4.join(cwd, ".github");
|
|
2511
2514
|
await ensureDir(tplRoot);
|
|
2512
|
-
const tplPath =
|
|
2515
|
+
const tplPath = path4.join(tplRoot, "pull_request_template.md");
|
|
2513
2516
|
try {
|
|
2514
2517
|
await fs.access(tplPath);
|
|
2515
2518
|
} catch {
|
|
@@ -2517,6 +2520,76 @@ async function initFrontGuard(cwd) {
|
|
|
2517
2520
|
}
|
|
2518
2521
|
}
|
|
2519
2522
|
|
|
2523
|
+
// src/ci/bitbucket-pr-snippet.ts
|
|
2524
|
+
function bitbucketPipelineResultsUrl() {
|
|
2525
|
+
const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
|
|
2526
|
+
const bn = process.env.BITBUCKET_BUILD_NUMBER?.trim();
|
|
2527
|
+
if (full && bn) {
|
|
2528
|
+
return `https://bitbucket.org/${full}/pipelines/results/${bn}`;
|
|
2529
|
+
}
|
|
2530
|
+
const ws = process.env.BITBUCKET_WORKSPACE?.trim();
|
|
2531
|
+
const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
|
|
2532
|
+
if (ws && slug && bn) {
|
|
2533
|
+
return `https://bitbucket.org/${ws}/${slug}/pipelines/results/${bn}`;
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
}
|
|
2537
|
+
function bitbucketDownloadsPageUrl() {
|
|
2538
|
+
const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
|
|
2539
|
+
if (full) return `https://bitbucket.org/${full}/downloads/`;
|
|
2540
|
+
const ws = process.env.BITBUCKET_WORKSPACE?.trim();
|
|
2541
|
+
const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
|
|
2542
|
+
if (ws && slug) return `https://bitbucket.org/${ws}/${slug}/downloads/`;
|
|
2543
|
+
return null;
|
|
2544
|
+
}
|
|
2545
|
+
function formatBitbucketPrSnippet(report) {
|
|
2546
|
+
const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
|
|
2547
|
+
const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
|
|
2548
|
+
const downloadsPage = bitbucketDownloadsPageUrl();
|
|
2549
|
+
const pipeline = bitbucketPipelineResultsUrl();
|
|
2550
|
+
let linkLine;
|
|
2551
|
+
if (publicReport) {
|
|
2552
|
+
linkLine = `**[Open full FrontGuard report (HTML)](${publicReport})** \u2014 interactive report in the browser (all findings).`;
|
|
2553
|
+
} else if (downloadsName && downloadsPage) {
|
|
2554
|
+
linkLine = `**[Repo Downloads](${downloadsPage})** \u2014 open \`${downloadsName}\` (uploaded by this pipeline). Download the file, then open it in a browser.`;
|
|
2555
|
+
} else if (pipeline) {
|
|
2556
|
+
linkLine = [
|
|
2557
|
+
`**[Open this pipeline run](${pipeline})** (Bitbucket login required).`,
|
|
2558
|
+
"On that page: open the **Artifacts** section \u2192 download **`frontguard-report.html`** \u2192 open the file in your browser to see the full interactive report."
|
|
2559
|
+
].join(" ");
|
|
2560
|
+
} else {
|
|
2561
|
+
linkLine = "_Run FrontGuard inside Bitbucket Pipelines so `BITBUCKET_REPO_FULL_NAME` and `BITBUCKET_BUILD_NUMBER` are set, or set `FRONTGUARD_PUBLIC_REPORT_URL` after uploading the HTML._";
|
|
2562
|
+
}
|
|
2563
|
+
const { riskScore, results } = report;
|
|
2564
|
+
const blocks = results.reduce(
|
|
2565
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "block").length,
|
|
2566
|
+
0
|
|
2567
|
+
);
|
|
2568
|
+
const warns = results.reduce(
|
|
2569
|
+
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
|
|
2570
|
+
0
|
|
2571
|
+
);
|
|
2572
|
+
const lines = [
|
|
2573
|
+
"## FrontGuard",
|
|
2574
|
+
"",
|
|
2575
|
+
`**Risk:** ${riskScore} \xB7 **Blocking:** ${blocks} \xB7 **Warnings:** ${warns}`,
|
|
2576
|
+
"",
|
|
2577
|
+
linkLine,
|
|
2578
|
+
"",
|
|
2579
|
+
"| Check | Status |",
|
|
2580
|
+
"|:--|:--|"
|
|
2581
|
+
];
|
|
2582
|
+
for (const r4 of results) {
|
|
2583
|
+
const status = r4.skipped ? `Skipped (${r4.skipped.slice(0, 80)}${r4.skipped.length > 80 ? "\u2026" : ""})` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
|
|
2584
|
+
lines.push(`| \`${r4.checkId}\` | ${status} |`);
|
|
2585
|
+
}
|
|
2586
|
+
lines.push("");
|
|
2587
|
+
lines.push(
|
|
2588
|
+
publicReport ? "_All findings and suggested fixes are in the linked HTML report._" : "_Summary only \u2014 use the link above for the full interactive report._"
|
|
2589
|
+
);
|
|
2590
|
+
return lines.join("\n");
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2520
2593
|
// src/ci/parse-ai-disclosure.ts
|
|
2521
2594
|
function extractAiSection(body) {
|
|
2522
2595
|
const lines = body.split(/\r?\n/);
|
|
@@ -2614,10 +2687,10 @@ async function resolvePrNumber() {
|
|
|
2614
2687
|
const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
|
|
2615
2688
|
const n3 = Number(raw);
|
|
2616
2689
|
if (Number.isFinite(n3) && n3 > 0) return n3;
|
|
2617
|
-
const
|
|
2618
|
-
if (!
|
|
2690
|
+
const path16 = process.env.GITHUB_EVENT_PATH;
|
|
2691
|
+
if (!path16) return null;
|
|
2619
2692
|
try {
|
|
2620
|
-
const payload = JSON.parse(await fs.readFile(
|
|
2693
|
+
const payload = JSON.parse(await fs.readFile(path16, "utf8"));
|
|
2621
2694
|
const num = payload.pull_request?.number;
|
|
2622
2695
|
return typeof num === "number" && num > 0 ? num : null;
|
|
2623
2696
|
} catch {
|
|
@@ -2797,7 +2870,11 @@ var defaultConfig = {
|
|
|
2797
2870
|
model: "gpt-4o-mini",
|
|
2798
2871
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
2799
2872
|
maxDiffChars: 48e3,
|
|
2800
|
-
timeoutMs: 6e4
|
|
2873
|
+
timeoutMs: 6e4,
|
|
2874
|
+
ollamaUrl: "http://127.0.0.1:11434",
|
|
2875
|
+
perFindingFixes: false,
|
|
2876
|
+
maxFixSuggestions: 12,
|
|
2877
|
+
maxFileContextChars: 24e3
|
|
2801
2878
|
}
|
|
2802
2879
|
}
|
|
2803
2880
|
};
|
|
@@ -2824,7 +2901,7 @@ function stripExtends(c4) {
|
|
|
2824
2901
|
}
|
|
2825
2902
|
async function loadExtendsLayer(cwd, spec) {
|
|
2826
2903
|
if (!spec) return {};
|
|
2827
|
-
const req = createRequire(
|
|
2904
|
+
const req = createRequire(path4.join(cwd, "package.json"));
|
|
2828
2905
|
const specs = Array.isArray(spec) ? spec : [spec];
|
|
2829
2906
|
let merged = {};
|
|
2830
2907
|
for (const s3 of specs) {
|
|
@@ -2843,7 +2920,7 @@ async function loadExtendsLayer(cwd, spec) {
|
|
|
2843
2920
|
async function loadConfig(cwd) {
|
|
2844
2921
|
let userFile = null;
|
|
2845
2922
|
for (const name of CONFIG_NAMES) {
|
|
2846
|
-
const full =
|
|
2923
|
+
const full = path4.join(cwd, name);
|
|
2847
2924
|
if (!fs4.existsSync(full)) continue;
|
|
2848
2925
|
try {
|
|
2849
2926
|
const mod = await importConfig(full);
|
|
@@ -2873,7 +2950,7 @@ function hasDep(deps, name) {
|
|
|
2873
2950
|
async function detectStack(cwd) {
|
|
2874
2951
|
let pkg = {};
|
|
2875
2952
|
try {
|
|
2876
|
-
const raw = await fs.readFile(
|
|
2953
|
+
const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
|
|
2877
2954
|
pkg = JSON.parse(raw);
|
|
2878
2955
|
} catch {
|
|
2879
2956
|
return {
|
|
@@ -2893,7 +2970,7 @@ async function detectStack(cwd) {
|
|
|
2893
2970
|
const isMonorepo = Boolean(pkg.workspaces);
|
|
2894
2971
|
let tsStrict = null;
|
|
2895
2972
|
try {
|
|
2896
|
-
const tsconfigPath =
|
|
2973
|
+
const tsconfigPath = path4.join(cwd, "tsconfig.json");
|
|
2897
2974
|
const tsRaw = await fs.readFile(tsconfigPath, "utf8");
|
|
2898
2975
|
const ts = JSON.parse(tsRaw);
|
|
2899
2976
|
if (typeof ts.compilerOptions?.strict === "boolean") {
|
|
@@ -2903,15 +2980,15 @@ async function detectStack(cwd) {
|
|
|
2903
2980
|
}
|
|
2904
2981
|
let pm = "unknown";
|
|
2905
2982
|
try {
|
|
2906
|
-
await fs.access(
|
|
2983
|
+
await fs.access(path4.join(cwd, "pnpm-lock.yaml"));
|
|
2907
2984
|
pm = "pnpm";
|
|
2908
2985
|
} catch {
|
|
2909
2986
|
try {
|
|
2910
|
-
await fs.access(
|
|
2987
|
+
await fs.access(path4.join(cwd, "yarn.lock"));
|
|
2911
2988
|
pm = "yarn";
|
|
2912
2989
|
} catch {
|
|
2913
2990
|
try {
|
|
2914
|
-
await fs.access(
|
|
2991
|
+
await fs.access(path4.join(cwd, "package-lock.json"));
|
|
2915
2992
|
pm = "npm";
|
|
2916
2993
|
} catch {
|
|
2917
2994
|
pm = "npm";
|
|
@@ -2931,6 +3008,61 @@ async function detectStack(cwd) {
|
|
|
2931
3008
|
tsStrict
|
|
2932
3009
|
};
|
|
2933
3010
|
}
|
|
3011
|
+
function formatStackOneLiner(s3) {
|
|
3012
|
+
const bits = [];
|
|
3013
|
+
if (s3.hasNext) bits.push("Next.js");
|
|
3014
|
+
if (s3.hasReactNative) bits.push("React Native");
|
|
3015
|
+
else if (s3.hasReact) bits.push("React");
|
|
3016
|
+
if (s3.hasTypeScript) bits.push("TypeScript");
|
|
3017
|
+
if (s3.tsStrict === true) bits.push("strict TS");
|
|
3018
|
+
bits.push(`pkg: ${s3.packageManager}`);
|
|
3019
|
+
return bits.join(" \xB7 ") || "unknown";
|
|
3020
|
+
}
|
|
3021
|
+
function stripFileUrl(p2) {
|
|
3022
|
+
let s3 = p2.trim();
|
|
3023
|
+
if (!/^file:/i.test(s3)) return s3;
|
|
3024
|
+
s3 = s3.replace(/^file:\/\//i, "");
|
|
3025
|
+
if (process.platform === "win32" && s3.startsWith("/")) {
|
|
3026
|
+
const m3 = s3.match(/^\/([A-Za-z]:\/.*)$/);
|
|
3027
|
+
if (m3) return m3[1];
|
|
3028
|
+
}
|
|
3029
|
+
return s3;
|
|
3030
|
+
}
|
|
3031
|
+
function isUnderDir(parent, child) {
|
|
3032
|
+
const rel = path4.relative(parent, child);
|
|
3033
|
+
return rel === "" || !rel.startsWith("..") && !path4.isAbsolute(rel);
|
|
3034
|
+
}
|
|
3035
|
+
function toRepoRelativePath(cwd, filePath) {
|
|
3036
|
+
if (!filePath?.trim()) return void 0;
|
|
3037
|
+
const raw = stripFileUrl(filePath);
|
|
3038
|
+
const resolvedCwd = path4.resolve(cwd);
|
|
3039
|
+
const absFile = path4.isAbsolute(raw) ? path4.resolve(raw) : path4.resolve(resolvedCwd, raw);
|
|
3040
|
+
if (!isUnderDir(resolvedCwd, absFile)) {
|
|
3041
|
+
return raw.split(/[/\\]/g).join("/");
|
|
3042
|
+
}
|
|
3043
|
+
let rel = path4.relative(resolvedCwd, absFile);
|
|
3044
|
+
if (!rel || rel === ".") {
|
|
3045
|
+
return path4.basename(absFile);
|
|
3046
|
+
}
|
|
3047
|
+
return rel.split(path4.sep).join("/");
|
|
3048
|
+
}
|
|
3049
|
+
function stripRepoAbsolutePaths(cwd, text) {
|
|
3050
|
+
if (!text || !cwd.trim()) return text;
|
|
3051
|
+
const resolvedCwd = path4.resolve(cwd);
|
|
3052
|
+
const asPosix = (s3) => s3.replace(/\\/g, "/");
|
|
3053
|
+
const cwdPosix = asPosix(resolvedCwd);
|
|
3054
|
+
let out = asPosix(text);
|
|
3055
|
+
const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path4.sep])].filter(
|
|
3056
|
+
(p2) => p2.length > 1
|
|
3057
|
+
);
|
|
3058
|
+
for (const prefix of prefixes) {
|
|
3059
|
+
const norm = prefix.replace(/\\/g, "/");
|
|
3060
|
+
if (out.includes(norm)) {
|
|
3061
|
+
out = out.split(norm).join("");
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
return out;
|
|
3065
|
+
}
|
|
2934
3066
|
var d2 = (e3, t3) => () => (t3 || e3((t3 = { exports: {} }).exports, t3), t3.exports);
|
|
2935
3067
|
var f3 = /* @__PURE__ */ createRequire(import.meta.url);
|
|
2936
3068
|
var p = /^path$/i;
|
|
@@ -3546,7 +3678,7 @@ async function pathExists(file) {
|
|
|
3546
3678
|
}
|
|
3547
3679
|
}
|
|
3548
3680
|
async function resolveBin(cwd, name) {
|
|
3549
|
-
const local =
|
|
3681
|
+
const local = path4.join(cwd, "node_modules", ".bin", name);
|
|
3550
3682
|
if (await pathExists(local)) return local;
|
|
3551
3683
|
const win = local + ".cmd";
|
|
3552
3684
|
if (await pathExists(win)) return win;
|
|
@@ -3602,7 +3734,7 @@ async function runNpx(cwd, args) {
|
|
|
3602
3734
|
// src/checks/eslint.ts
|
|
3603
3735
|
async function hasEslintDependency(cwd) {
|
|
3604
3736
|
try {
|
|
3605
|
-
const raw = await fs.readFile(
|
|
3737
|
+
const raw = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
|
|
3606
3738
|
const p2 = JSON.parse(raw);
|
|
3607
3739
|
return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
|
|
3608
3740
|
} catch {
|
|
@@ -3621,7 +3753,7 @@ async function hasEslintConfig(cwd) {
|
|
|
3621
3753
|
".eslintrc.yml"
|
|
3622
3754
|
];
|
|
3623
3755
|
for (const c4 of candidates) {
|
|
3624
|
-
if (await pathExists(
|
|
3756
|
+
if (await pathExists(path4.join(cwd, c4))) return true;
|
|
3625
3757
|
}
|
|
3626
3758
|
return false;
|
|
3627
3759
|
}
|
|
@@ -3677,20 +3809,19 @@ async function runEslint(cwd, config, _stack) {
|
|
|
3677
3809
|
}
|
|
3678
3810
|
try {
|
|
3679
3811
|
const rows = JSON.parse(stdout2);
|
|
3680
|
-
let n3 = 0;
|
|
3681
3812
|
for (const row of rows) {
|
|
3813
|
+
const relFile = toRepoRelativePath(cwd, row.filePath);
|
|
3682
3814
|
for (const m3 of row.messages) {
|
|
3683
|
-
if (n3++ >= 40) break;
|
|
3684
3815
|
findings.push({
|
|
3685
3816
|
id: `eslint-${m3.ruleId ?? "unknown"}`,
|
|
3686
3817
|
severity: "warn",
|
|
3687
3818
|
message: m3.message,
|
|
3688
|
-
file:
|
|
3819
|
+
file: relFile,
|
|
3689
3820
|
detail: m3.line ? `line ${m3.line}` : void 0
|
|
3690
3821
|
});
|
|
3691
3822
|
}
|
|
3692
3823
|
}
|
|
3693
|
-
if (
|
|
3824
|
+
if (findings.length === 0) {
|
|
3694
3825
|
findings.push({
|
|
3695
3826
|
id: "eslint-failed",
|
|
3696
3827
|
severity: "warn",
|
|
@@ -3778,7 +3909,7 @@ async function runTypeScript(cwd, config, stack) {
|
|
|
3778
3909
|
skipped: "disabled in config"
|
|
3779
3910
|
};
|
|
3780
3911
|
}
|
|
3781
|
-
const hasTs = stack.hasTypeScript || await pathExists(
|
|
3912
|
+
const hasTs = stack.hasTypeScript || await pathExists(path4.join(cwd, "tsconfig.json"));
|
|
3782
3913
|
if (!hasTs) {
|
|
3783
3914
|
return {
|
|
3784
3915
|
checkId: "typescript",
|
|
@@ -3891,7 +4022,7 @@ async function runSecrets(cwd, config, pr) {
|
|
|
3891
4022
|
});
|
|
3892
4023
|
break;
|
|
3893
4024
|
}
|
|
3894
|
-
const full =
|
|
4025
|
+
const full = path4.join(cwd, rel);
|
|
3895
4026
|
let content;
|
|
3896
4027
|
try {
|
|
3897
4028
|
content = await fs.readFile(full, "utf8");
|
|
@@ -3917,7 +4048,7 @@ async function runSecrets(cwd, config, pr) {
|
|
|
3917
4048
|
};
|
|
3918
4049
|
}
|
|
3919
4050
|
function isProbablyTextFile(rel) {
|
|
3920
|
-
const ext =
|
|
4051
|
+
const ext = path4.extname(rel).toLowerCase();
|
|
3921
4052
|
return TEXT_EXT.has(ext);
|
|
3922
4053
|
}
|
|
3923
4054
|
|
|
@@ -4210,12 +4341,12 @@ async function runCycles(cwd, config, stack) {
|
|
|
4210
4341
|
}
|
|
4211
4342
|
let entry = cfg.entries[0] ?? "src";
|
|
4212
4343
|
for (const e3 of cfg.entries) {
|
|
4213
|
-
if (await pathExists(
|
|
4344
|
+
if (await pathExists(path4.join(cwd, e3))) {
|
|
4214
4345
|
entry = e3;
|
|
4215
4346
|
break;
|
|
4216
4347
|
}
|
|
4217
4348
|
}
|
|
4218
|
-
if (!await pathExists(
|
|
4349
|
+
if (!await pathExists(path4.join(cwd, entry))) {
|
|
4219
4350
|
return {
|
|
4220
4351
|
checkId: "cycles",
|
|
4221
4352
|
findings: [],
|
|
@@ -4339,7 +4470,7 @@ async function sumGlobBytes(cwd, patterns) {
|
|
|
4339
4470
|
});
|
|
4340
4471
|
for (const rel of files) {
|
|
4341
4472
|
try {
|
|
4342
|
-
const st = await fs.stat(
|
|
4473
|
+
const st = await fs.stat(path4.join(cwd, rel));
|
|
4343
4474
|
total += st.size;
|
|
4344
4475
|
} catch {
|
|
4345
4476
|
}
|
|
@@ -4348,7 +4479,7 @@ async function sumGlobBytes(cwd, patterns) {
|
|
|
4348
4479
|
return total;
|
|
4349
4480
|
}
|
|
4350
4481
|
async function readBaseline(cwd, relPath, baseRef) {
|
|
4351
|
-
const disk =
|
|
4482
|
+
const disk = path4.join(cwd, relPath);
|
|
4352
4483
|
try {
|
|
4353
4484
|
const raw = await fs.readFile(disk, "utf8");
|
|
4354
4485
|
return JSON.parse(raw);
|
|
@@ -4523,7 +4654,7 @@ async function runCwv(cwd, config, stack, pr) {
|
|
|
4523
4654
|
const findings = [];
|
|
4524
4655
|
const sev2 = gateSeverity5(cfg.gate);
|
|
4525
4656
|
for (const rel of toScan.slice(0, 400)) {
|
|
4526
|
-
const full =
|
|
4657
|
+
const full = path4.join(cwd, rel);
|
|
4527
4658
|
let text;
|
|
4528
4659
|
try {
|
|
4529
4660
|
text = await fs.readFile(full, "utf8");
|
|
@@ -4608,7 +4739,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
|
|
|
4608
4739
|
});
|
|
4609
4740
|
break;
|
|
4610
4741
|
}
|
|
4611
|
-
const full =
|
|
4742
|
+
const full = path4.join(cwd, rel);
|
|
4612
4743
|
let content;
|
|
4613
4744
|
try {
|
|
4614
4745
|
content = await fs.readFile(full, "utf8");
|
|
@@ -4739,7 +4870,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
|
|
|
4739
4870
|
const gate = cfg.gate;
|
|
4740
4871
|
const findings = [];
|
|
4741
4872
|
for (const rel of files) {
|
|
4742
|
-
const full =
|
|
4873
|
+
const full = path4.join(cwd, rel);
|
|
4743
4874
|
let content;
|
|
4744
4875
|
try {
|
|
4745
4876
|
content = await fs.readFile(full, "utf8");
|
|
@@ -4797,8 +4928,268 @@ function applyAiAssistedEscalation(results, pr, config) {
|
|
|
4797
4928
|
|
|
4798
4929
|
// src/report/builder.ts
|
|
4799
4930
|
var import_picocolors = __toESM(require_picocolors());
|
|
4931
|
+
|
|
4932
|
+
// src/lib/html-escape.ts
|
|
4933
|
+
function escapeHtml(s3) {
|
|
4934
|
+
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
// src/report/html-report.ts
|
|
4938
|
+
function shieldUrl(label, message, color) {
|
|
4939
|
+
const q2 = new URLSearchParams({ label, message, color, style: "for-the-badge" });
|
|
4940
|
+
return `https://img.shields.io/static/v1?${q2}`;
|
|
4941
|
+
}
|
|
4942
|
+
function riskColor(risk) {
|
|
4943
|
+
if (risk === "LOW") return "brightgreen";
|
|
4944
|
+
if (risk === "MEDIUM") return "orange";
|
|
4945
|
+
return "red";
|
|
4946
|
+
}
|
|
4947
|
+
function modeColor(mode) {
|
|
4948
|
+
return mode === "enforce" ? "critical" : "blue";
|
|
4949
|
+
}
|
|
4950
|
+
function countColor(kind, n3) {
|
|
4951
|
+
if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
|
|
4952
|
+
if (kind === "info") return n3 === 0 ? "inactive" : "informational";
|
|
4953
|
+
if (n3 === 0) return "brightgreen";
|
|
4954
|
+
if (n3 <= 10) return "yellow";
|
|
4955
|
+
return "orange";
|
|
4956
|
+
}
|
|
4957
|
+
function parseLineHint(detail) {
|
|
4958
|
+
if (!detail) return 0;
|
|
4959
|
+
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
4960
|
+
return m3 ? Number(m3[1]) : 0;
|
|
4961
|
+
}
|
|
4962
|
+
function normalizeFinding(cwd, f4) {
|
|
4963
|
+
return {
|
|
4964
|
+
file: toRepoRelativePath(cwd, f4.file),
|
|
4965
|
+
message: stripRepoAbsolutePaths(cwd, f4.message),
|
|
4966
|
+
detail: f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4969
|
+
function sortFindings(cwd, items) {
|
|
4970
|
+
return [...items].sort((a3, b3) => {
|
|
4971
|
+
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
4972
|
+
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
4973
|
+
if (af !== bf) return af.localeCompare(bf);
|
|
4974
|
+
const lineA = parseLineHint(a3.f.detail);
|
|
4975
|
+
const lineB = parseLineHint(b3.f.detail);
|
|
4976
|
+
if (lineA !== lineB) return lineA - lineB;
|
|
4977
|
+
return a3.f.message.localeCompare(b3.f.message);
|
|
4978
|
+
});
|
|
4979
|
+
}
|
|
4980
|
+
function formatDuration(ms) {
|
|
4981
|
+
if (ms < 1e3) return `${ms} ms`;
|
|
4982
|
+
const s3 = Math.round(ms / 1e3);
|
|
4983
|
+
if (s3 < 60) return `${s3}s`;
|
|
4984
|
+
const m3 = Math.floor(s3 / 60);
|
|
4985
|
+
const r4 = s3 % 60;
|
|
4986
|
+
return r4 ? `${m3}m ${r4}s` : `${m3}m`;
|
|
4987
|
+
}
|
|
4988
|
+
function renderFindingCard(cwd, r4, f4) {
|
|
4989
|
+
const d3 = normalizeFinding(cwd, f4);
|
|
4990
|
+
const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
|
|
4991
|
+
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>` : "";
|
|
4992
|
+
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>` : "";
|
|
4993
|
+
const detailFence = d3.detail && (d3.detail.includes("\n") || d3.detail.length > 220 || d3.detail.includes("|")) ? `<pre class="code"><code>${escapeHtml(d3.detail)}</code></pre>` : "";
|
|
4994
|
+
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>`;
|
|
4995
|
+
}
|
|
4996
|
+
function buildHtmlReport(p2) {
|
|
4997
|
+
const {
|
|
4998
|
+
cwd,
|
|
4999
|
+
riskScore,
|
|
5000
|
+
mode,
|
|
5001
|
+
stack,
|
|
5002
|
+
pr,
|
|
5003
|
+
results,
|
|
5004
|
+
warns,
|
|
5005
|
+
infos,
|
|
5006
|
+
blocks,
|
|
5007
|
+
lines,
|
|
5008
|
+
llmAppendix
|
|
5009
|
+
} = p2;
|
|
5010
|
+
const modeLabel = mode === "enforce" ? "enforce" : "warn only";
|
|
5011
|
+
const badges = [
|
|
5012
|
+
["risk", riskScore, riskColor(riskScore)],
|
|
5013
|
+
["mode", modeLabel, modeColor(mode)],
|
|
5014
|
+
["blocking", String(blocks), countColor("block", blocks)],
|
|
5015
|
+
["warnings", String(warns), countColor("warn", warns)],
|
|
5016
|
+
["info", String(infos), countColor("info", infos)]
|
|
5017
|
+
];
|
|
5018
|
+
const badgeImgs = badges.map(([l3, m3, c4]) => {
|
|
5019
|
+
const alt = `${l3}: ${m3}`;
|
|
5020
|
+
return `<img class="badge" src="${escapeHtml(shieldUrl(l3, m3, c4))}" alt="${escapeHtml(alt)}" loading="lazy" />`;
|
|
5021
|
+
}).join(" ");
|
|
5022
|
+
const checkRows = results.map((r4) => {
|
|
5023
|
+
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Clean" : `${r4.findings.length} finding(s)`;
|
|
5024
|
+
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>`;
|
|
5025
|
+
}).join("\n");
|
|
5026
|
+
const blockItems = sortFindings(
|
|
5027
|
+
cwd,
|
|
5028
|
+
results.flatMap(
|
|
5029
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5030
|
+
)
|
|
5031
|
+
);
|
|
5032
|
+
const warnItems = sortFindings(
|
|
5033
|
+
cwd,
|
|
5034
|
+
results.flatMap(
|
|
5035
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5036
|
+
)
|
|
5037
|
+
);
|
|
5038
|
+
const infoItems = sortFindings(
|
|
5039
|
+
cwd,
|
|
5040
|
+
results.flatMap(
|
|
5041
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5042
|
+
)
|
|
5043
|
+
);
|
|
5044
|
+
const byCheck = /* @__PURE__ */ new Map();
|
|
5045
|
+
for (const item of warnItems) {
|
|
5046
|
+
const list = byCheck.get(item.r.checkId) ?? [];
|
|
5047
|
+
list.push(item);
|
|
5048
|
+
byCheck.set(item.r.checkId, list);
|
|
5049
|
+
}
|
|
5050
|
+
const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
|
|
5051
|
+
const blockingHtml = blockItems.length === 0 ? '<p class="ok">No blocking findings.</p>' : blockItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5052
|
+
let warningsHtml = "";
|
|
5053
|
+
if (warnItems.length === 0) {
|
|
5054
|
+
warningsHtml = '<p class="ok">No warnings.</p>';
|
|
5055
|
+
} else {
|
|
5056
|
+
for (const cid of checkOrder) {
|
|
5057
|
+
const group = sortFindings(cwd, byCheck.get(cid));
|
|
5058
|
+
warningsHtml += `<h3 class="grp">${escapeHtml(cid)} <span class="count">(${group.length})</span></h3>`;
|
|
5059
|
+
warningsHtml += group.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
const infoHtml = infoItems.length === 0 ? '<p class="muted">No info notes.</p>' : infoItems.map(({ r: r4, f: f4 }) => renderFindingCard(cwd, r4, f4)).join("\n");
|
|
5063
|
+
const prBlock = pr && lines != null ? `<tr><th>PR size</th><td>${lines} LOC (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
|
|
5064
|
+
const appendix = llmAppendix?.trim() ? `<section class="appendix"><h2>AI / manual appendix</h2><pre class="md-raw">${escapeHtml(llmAppendix.trim())}</pre></section>` : "";
|
|
5065
|
+
return `<!DOCTYPE html>
|
|
5066
|
+
<html lang="en">
|
|
5067
|
+
<head>
|
|
5068
|
+
<meta charset="utf-8" />
|
|
5069
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5070
|
+
<title>FrontGuard report</title>
|
|
5071
|
+
<style>
|
|
5072
|
+
:root {
|
|
5073
|
+
--bg: #0f1419;
|
|
5074
|
+
--panel: #1a2332;
|
|
5075
|
+
--text: #e7ecf3;
|
|
5076
|
+
--muted: #8b9aab;
|
|
5077
|
+
--border: #2d3d52;
|
|
5078
|
+
--block: #f87171;
|
|
5079
|
+
--warn: #fbbf24;
|
|
5080
|
+
--info: #38bdf8;
|
|
5081
|
+
--accent: #a78bfa;
|
|
5082
|
+
--ok: #4ade80;
|
|
5083
|
+
}
|
|
5084
|
+
* { box-sizing: border-box; }
|
|
5085
|
+
body {
|
|
5086
|
+
margin: 0; font-family: ui-sans-serif, system-ui, sans-serif;
|
|
5087
|
+
background: var(--bg); color: var(--text); line-height: 1.5;
|
|
5088
|
+
padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem;
|
|
5089
|
+
max-width: 58rem; margin-left: auto; margin-right: auto;
|
|
5090
|
+
}
|
|
5091
|
+
h1 { font-size: 1.5rem; margin: 0 0 1rem; letter-spacing: -0.02em; }
|
|
5092
|
+
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
|
|
5093
|
+
h3.grp { margin: 1.5rem 0 0.5rem; font-size: 1rem; color: var(--warn); }
|
|
5094
|
+
h3.grp .count { color: var(--muted); font-weight: normal; }
|
|
5095
|
+
h4 { font-size: 0.95rem; margin: 0 0 0.5rem; font-weight: 600; }
|
|
5096
|
+
h5 { font-size: 0.8rem; margin: 0 0 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); }
|
|
5097
|
+
.badges { margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
|
5098
|
+
.badge { height: 28px; width: auto; max-width: 100%; image-rendering: crisp-edges; }
|
|
5099
|
+
details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; }
|
|
5100
|
+
details.open-default { border-color: #3d4f6a; }
|
|
5101
|
+
summary {
|
|
5102
|
+
cursor: pointer; padding: 0.65rem 1rem; font-weight: 600;
|
|
5103
|
+
list-style: none; display: flex; align-items: center; gap: 0.5rem;
|
|
5104
|
+
}
|
|
5105
|
+
summary::-webkit-details-marker { display: none; }
|
|
5106
|
+
details[open] > summary { border-bottom: 1px solid var(--border); }
|
|
5107
|
+
.details-body { padding: 0.75rem 1rem 1rem; }
|
|
5108
|
+
table.results { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0.5rem 0 1rem; }
|
|
5109
|
+
table.results th, table.results td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
|
|
5110
|
+
table.results th { background: #243044; color: var(--muted); font-weight: 600; }
|
|
5111
|
+
.snapshot { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 0.5rem 0; }
|
|
5112
|
+
.snapshot th, .snapshot td { border: 1px solid var(--border); padding: 0.5rem 0.65rem; vertical-align: top; }
|
|
5113
|
+
.snapshot th { width: 10rem; background: #243044; color: var(--muted); text-align: left; }
|
|
5114
|
+
.card {
|
|
5115
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem 1rem;
|
|
5116
|
+
margin-bottom: 0.65rem; background: #131c28;
|
|
5117
|
+
}
|
|
5118
|
+
.card.sev-block { border-left: 4px solid var(--block); }
|
|
5119
|
+
.card.sev-warn { border-left: 4px solid var(--warn); }
|
|
5120
|
+
.card.sev-info { border-left: 4px solid var(--info); }
|
|
5121
|
+
table.meta { width: 100%; font-size: 0.8rem; border-collapse: collapse; margin: 0.5rem 0; }
|
|
5122
|
+
table.meta th { text-align: left; color: var(--muted); width: 5.5rem; padding: 0.2rem 0.5rem 0.2rem 0; vertical-align: top; }
|
|
5123
|
+
table.meta td { padding: 0.2rem 0; }
|
|
5124
|
+
.muted { color: var(--muted); }
|
|
5125
|
+
.ok { color: var(--ok); }
|
|
5126
|
+
pre.code {
|
|
5127
|
+
margin: 0.5rem 0 0; padding: 0.65rem 0.75rem; background: #0a0e14; border-radius: 6px;
|
|
5128
|
+
overflow: auto; font-size: 0.78rem; border: 1px solid var(--border);
|
|
5129
|
+
}
|
|
5130
|
+
pre.code code { font-family: ui-monospace, monospace; white-space: pre; }
|
|
5131
|
+
.suggested-fix {
|
|
5132
|
+
margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
|
|
5133
|
+
}
|
|
5134
|
+
.fix-md { font-size: 0.85rem; white-space: pre-wrap; margin: 0.25rem 0 0.5rem; }
|
|
5135
|
+
.tag {
|
|
5136
|
+
font-size: 0.65rem; background: var(--accent); color: var(--bg);
|
|
5137
|
+
padding: 0.1rem 0.35rem; border-radius: 4px; vertical-align: middle;
|
|
5138
|
+
}
|
|
5139
|
+
.disclaimer { font-size: 0.72rem; color: var(--muted); margin: 0.5rem 0 0; }
|
|
5140
|
+
.appendix pre.md-raw {
|
|
5141
|
+
white-space: pre-wrap; font-size: 0.85rem; background: var(--panel);
|
|
5142
|
+
padding: 1rem; border-radius: 8px; border: 1px solid var(--border);
|
|
5143
|
+
}
|
|
5144
|
+
footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--muted); }
|
|
5145
|
+
</style>
|
|
5146
|
+
</head>
|
|
5147
|
+
<body>
|
|
5148
|
+
<h1>FrontGuard review</h1>
|
|
5149
|
+
<div class="badges">${badgeImgs}</div>
|
|
5150
|
+
|
|
5151
|
+
<h2>Snapshot</h2>
|
|
5152
|
+
<table class="snapshot">
|
|
5153
|
+
<tr><th>Risk</th><td><strong>${riskScore}</strong> (heuristic)</td></tr>
|
|
5154
|
+
<tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
|
|
5155
|
+
<tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
|
|
5156
|
+
${prBlock}
|
|
5157
|
+
</table>
|
|
5158
|
+
|
|
5159
|
+
<h2>Check results</h2>
|
|
5160
|
+
<table class="results">
|
|
5161
|
+
<thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
|
|
5162
|
+
<tbody>${checkRows}</tbody>
|
|
5163
|
+
</table>
|
|
5164
|
+
|
|
5165
|
+
<details class="open-default" open>
|
|
5166
|
+
<summary>Blocking (${blocks})</summary>
|
|
5167
|
+
<div class="details-body">${blockingHtml}</div>
|
|
5168
|
+
</details>
|
|
5169
|
+
|
|
5170
|
+
<details class="open-default" open>
|
|
5171
|
+
<summary>Warnings (${warns})</summary>
|
|
5172
|
+
<div class="details-body">${warningsHtml}</div>
|
|
5173
|
+
</details>
|
|
5174
|
+
|
|
5175
|
+
<details>
|
|
5176
|
+
<summary>Info (${infos})</summary>
|
|
5177
|
+
<div class="details-body">${infoHtml}</div>
|
|
5178
|
+
</details>
|
|
5179
|
+
|
|
5180
|
+
${appendix}
|
|
5181
|
+
|
|
5182
|
+
<footer>
|
|
5183
|
+
<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>
|
|
5184
|
+
</footer>
|
|
5185
|
+
</body>
|
|
5186
|
+
</html>`;
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
// src/report/builder.ts
|
|
4800
5190
|
function buildReport(stack, pr, results, options) {
|
|
4801
5191
|
const mode = options?.mode ?? "warn";
|
|
5192
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
4802
5193
|
const allFindings = results.flatMap(
|
|
4803
5194
|
(r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
|
|
4804
5195
|
);
|
|
@@ -4808,6 +5199,7 @@ function buildReport(stack, pr, results, options) {
|
|
|
4808
5199
|
const lines = pr != null ? pr.additions + pr.deletions : null;
|
|
4809
5200
|
const riskScore = scoreRisk(blocks, warns, lines, pr?.changedFiles ?? 0);
|
|
4810
5201
|
const markdown = formatMarkdown({
|
|
5202
|
+
cwd,
|
|
4811
5203
|
riskScore,
|
|
4812
5204
|
mode,
|
|
4813
5205
|
stack,
|
|
@@ -4829,17 +5221,20 @@ function buildReport(stack, pr, results, options) {
|
|
|
4829
5221
|
infos,
|
|
4830
5222
|
blocks
|
|
4831
5223
|
});
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
5224
|
+
const html = options?.emitHtml === true ? buildHtmlReport({
|
|
5225
|
+
cwd,
|
|
5226
|
+
riskScore,
|
|
5227
|
+
mode,
|
|
5228
|
+
stack,
|
|
5229
|
+
pr,
|
|
5230
|
+
results,
|
|
5231
|
+
warns,
|
|
5232
|
+
infos,
|
|
5233
|
+
blocks,
|
|
5234
|
+
lines,
|
|
5235
|
+
llmAppendix: options?.llmAppendix ?? null
|
|
5236
|
+
}) : null;
|
|
5237
|
+
return { riskScore, stack, pr, results, markdown, consoleText, html };
|
|
4843
5238
|
}
|
|
4844
5239
|
function scoreRisk(blocks, warns, lines, files) {
|
|
4845
5240
|
let score = 0;
|
|
@@ -4855,8 +5250,104 @@ function scoreRisk(blocks, warns, lines, files) {
|
|
|
4855
5250
|
if (score >= 2) return "MEDIUM";
|
|
4856
5251
|
return "LOW";
|
|
4857
5252
|
}
|
|
5253
|
+
function shieldImageUrl(label, message, color) {
|
|
5254
|
+
const q2 = new URLSearchParams({
|
|
5255
|
+
label,
|
|
5256
|
+
message,
|
|
5257
|
+
color,
|
|
5258
|
+
style: "for-the-badge"
|
|
5259
|
+
});
|
|
5260
|
+
return `https://img.shields.io/static/v1?${q2}`;
|
|
5261
|
+
}
|
|
5262
|
+
function mdShield(alt, label, message, color) {
|
|
5263
|
+
return `})`;
|
|
5264
|
+
}
|
|
5265
|
+
function riskEmoji(risk) {
|
|
5266
|
+
if (risk === "LOW") return "\u{1F7E2}";
|
|
5267
|
+
if (risk === "MEDIUM") return "\u{1F7E0}";
|
|
5268
|
+
return "\u{1F534}";
|
|
5269
|
+
}
|
|
5270
|
+
function riskShieldColor(risk) {
|
|
5271
|
+
if (risk === "LOW") return "brightgreen";
|
|
5272
|
+
if (risk === "MEDIUM") return "orange";
|
|
5273
|
+
return "red";
|
|
5274
|
+
}
|
|
5275
|
+
function modeShieldColor(mode) {
|
|
5276
|
+
return mode === "enforce" ? "critical" : "blue";
|
|
5277
|
+
}
|
|
5278
|
+
function countShieldColor(kind, n3) {
|
|
5279
|
+
if (kind === "block") return n3 === 0 ? "brightgreen" : "critical";
|
|
5280
|
+
if (kind === "info") return n3 === 0 ? "inactive" : "informational";
|
|
5281
|
+
if (n3 === 0) return "brightgreen";
|
|
5282
|
+
if (n3 <= 10) return "yellow";
|
|
5283
|
+
return "orange";
|
|
5284
|
+
}
|
|
5285
|
+
function formatDuration2(ms) {
|
|
5286
|
+
if (ms < 1e3) return `${ms} ms`;
|
|
5287
|
+
const s3 = Math.round(ms / 1e3);
|
|
5288
|
+
if (s3 < 60) return `${s3}s`;
|
|
5289
|
+
const m3 = Math.floor(s3 / 60);
|
|
5290
|
+
const r4 = s3 % 60;
|
|
5291
|
+
return r4 ? `${m3}m ${r4}s` : `${m3}m`;
|
|
5292
|
+
}
|
|
5293
|
+
function healthEmojiForCheck(r4) {
|
|
5294
|
+
if (r4.skipped) return "\u23ED\uFE0F";
|
|
5295
|
+
if (r4.findings.length === 0) return "\u{1F7E2}";
|
|
5296
|
+
const hasBlock = r4.findings.some((f4) => f4.severity === "block");
|
|
5297
|
+
if (hasBlock) return "\u{1F534}";
|
|
5298
|
+
return "\u{1F7E1}";
|
|
5299
|
+
}
|
|
5300
|
+
function normalizeFindingDisplay(cwd, f4) {
|
|
5301
|
+
const file = toRepoRelativePath(cwd, f4.file);
|
|
5302
|
+
const message = stripRepoAbsolutePaths(cwd, f4.message);
|
|
5303
|
+
const detail = f4.detail ? stripRepoAbsolutePaths(cwd, f4.detail) : void 0;
|
|
5304
|
+
return { file, message, detail };
|
|
5305
|
+
}
|
|
5306
|
+
function sanitizeFence(cwd, s3) {
|
|
5307
|
+
return stripRepoAbsolutePaths(cwd, s3).replace(/\r\n/g, "\n").replace(/```/g, "`\u200B``");
|
|
5308
|
+
}
|
|
5309
|
+
function findingTitleLine(file, message) {
|
|
5310
|
+
const msg = message.length > 200 ? `${message.slice(0, 197).trimEnd()}\u2026` : message;
|
|
5311
|
+
const loc = file ? `\`${file}\`` : "_no file_";
|
|
5312
|
+
return `${loc} \u2014 ${msg}`;
|
|
5313
|
+
}
|
|
5314
|
+
function parseLineHint2(detail) {
|
|
5315
|
+
if (!detail) return 0;
|
|
5316
|
+
const m3 = /^line\s+(\d+)/i.exec(detail.trim());
|
|
5317
|
+
return m3 ? Number(m3[1]) : 0;
|
|
5318
|
+
}
|
|
5319
|
+
function appendDetailAfterTable(sb, cwd, detail) {
|
|
5320
|
+
if (!detail?.trim()) return;
|
|
5321
|
+
const d3 = detail.trim();
|
|
5322
|
+
const oneLine = !d3.includes("\n");
|
|
5323
|
+
const safeForTable = oneLine && d3.length <= 200 && !d3.includes("|") && !d3.includes("`");
|
|
5324
|
+
if (safeForTable) {
|
|
5325
|
+
sb.push(`| **Hint** | ${d3} |`);
|
|
5326
|
+
return;
|
|
5327
|
+
}
|
|
5328
|
+
sb.push("");
|
|
5329
|
+
sb.push("*Detail*");
|
|
5330
|
+
sb.push("");
|
|
5331
|
+
sb.push("```text");
|
|
5332
|
+
sb.push(sanitizeFence(cwd, d3));
|
|
5333
|
+
sb.push("```");
|
|
5334
|
+
}
|
|
5335
|
+
function appendSuggestedFix(sb, cwd, f4) {
|
|
5336
|
+
if (!f4.suggestedFix) return;
|
|
5337
|
+
sb.push("");
|
|
5338
|
+
sb.push("_Suggested fix (LLM \u2014 non-binding):_");
|
|
5339
|
+
sb.push("");
|
|
5340
|
+
sb.push(f4.suggestedFix.summary);
|
|
5341
|
+
if (f4.suggestedFix.code) {
|
|
5342
|
+
sb.push("");
|
|
5343
|
+
sb.push("```text");
|
|
5344
|
+
sb.push(sanitizeFence(cwd, f4.suggestedFix.code));
|
|
5345
|
+
sb.push("```");
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
4858
5348
|
function formatMarkdown(p2) {
|
|
4859
5349
|
const {
|
|
5350
|
+
cwd,
|
|
4860
5351
|
riskScore,
|
|
4861
5352
|
mode,
|
|
4862
5353
|
stack,
|
|
@@ -4869,99 +5360,202 @@ function formatMarkdown(p2) {
|
|
|
4869
5360
|
llmAppendix
|
|
4870
5361
|
} = p2;
|
|
4871
5362
|
const sb = [];
|
|
4872
|
-
|
|
5363
|
+
const sortWithCwd = (items) => [...items].sort((a3, b3) => {
|
|
5364
|
+
const af = toRepoRelativePath(cwd, a3.f.file) ?? "";
|
|
5365
|
+
const bf = toRepoRelativePath(cwd, b3.f.file) ?? "";
|
|
5366
|
+
if (af !== bf) return af.localeCompare(bf);
|
|
5367
|
+
const lineA = parseLineHint2(a3.f.detail);
|
|
5368
|
+
const lineB = parseLineHint2(b3.f.detail);
|
|
5369
|
+
if (lineA !== lineB) return lineA - lineB;
|
|
5370
|
+
return a3.f.message.localeCompare(b3.f.message);
|
|
5371
|
+
});
|
|
5372
|
+
sb.push("## \u2728 FrontGuard review brief");
|
|
4873
5373
|
sb.push("");
|
|
5374
|
+
const modeLabel = mode === "enforce" ? "enforce" : "warn only";
|
|
5375
|
+
const badgeLine = [
|
|
5376
|
+
mdShield("Risk level", "risk", riskScore, riskShieldColor(riskScore)),
|
|
5377
|
+
mdShield("CI mode", "mode", modeLabel, modeShieldColor(mode)),
|
|
5378
|
+
mdShield("Blocking", "blocking", String(blocks), countShieldColor("block", blocks)),
|
|
5379
|
+
mdShield("Warnings", "warnings", String(warns), countShieldColor("warn", warns)),
|
|
5380
|
+
mdShield("Info", "info notes", String(infos), countShieldColor("info", infos))
|
|
5381
|
+
].join(" ");
|
|
5382
|
+
sb.push(badgeLine);
|
|
5383
|
+
sb.push("");
|
|
5384
|
+
sb.push("---");
|
|
5385
|
+
sb.push("");
|
|
5386
|
+
sb.push("### \u{1F4CC} Snapshot");
|
|
5387
|
+
sb.push("");
|
|
5388
|
+
sb.push("| | |");
|
|
5389
|
+
sb.push("|:--|:--|");
|
|
5390
|
+
sb.push(`| **Composite risk** | ${riskEmoji(riskScore)} **${riskScore}** \u2014 heuristic from blocks, warnings, and PR size. |`);
|
|
4874
5391
|
sb.push(
|
|
4875
|
-
|
|
5392
|
+
`| **Gate mode** | ${mode === "enforce" ? "\u{1F512} **Enforce** \u2014 CI fails when a `block` finding is present." : "\u{1F6C8} **Warn only** \u2014 findings are advisory unless you use `--enforce`."} |`
|
|
4876
5393
|
);
|
|
4877
|
-
|
|
4878
|
-
|
|
5394
|
+
sb.push(`| **Stack detected** | ${formatStackOneLiner(stack)} |`);
|
|
5395
|
+
if (pr && lines != null) {
|
|
4879
5396
|
sb.push(
|
|
4880
|
-
|
|
5397
|
+
`| **PR size** | \u{1F4CF} **${lines}** LOC ( +${pr.additions} / \u2212${pr.deletions} ) \xB7 **${pr.changedFiles}** files |`
|
|
4881
5398
|
);
|
|
4882
5399
|
}
|
|
4883
5400
|
sb.push("");
|
|
4884
|
-
|
|
4885
|
-
if (pr && lines != null) {
|
|
5401
|
+
if (pr?.aiAssisted) {
|
|
4886
5402
|
sb.push(
|
|
4887
|
-
|
|
5403
|
+
"> **\u{1F916} AI-assisted PR** \u2014 Stricter static checks run on changed files (security / footguns; secrets & `any` deltas may escalate). This does not replace human review for behavior or product rules."
|
|
4888
5404
|
);
|
|
5405
|
+
sb.push("");
|
|
4889
5406
|
}
|
|
4890
|
-
sb.push("");
|
|
5407
|
+
sb.push("> **How to read this report**");
|
|
5408
|
+
sb.push(">");
|
|
5409
|
+
sb.push("> | Symbol | Meaning |");
|
|
5410
|
+
sb.push("> |:--|:--|");
|
|
5411
|
+
sb.push("> | \u{1F7E2} | Check passed or skipped cleanly |");
|
|
5412
|
+
sb.push("> | \u{1F7E1} | Warnings only \u2014 review recommended |");
|
|
5413
|
+
sb.push("> | \u{1F534} | Blocking (`block`) severity present |");
|
|
5414
|
+
sb.push("> | \u23ED\uFE0F | Check skipped (see reason in table) |");
|
|
5415
|
+
sb.push(">");
|
|
4891
5416
|
sb.push(
|
|
4892
|
-
|
|
5417
|
+
"> Paths in findings are **relative to the repo root**. Each issue below has a small field table and optional detail."
|
|
4893
5418
|
);
|
|
4894
|
-
sb.push(`**Warnings:** ${warns} \xB7 **Info:** ${infos}`);
|
|
4895
5419
|
sb.push("");
|
|
4896
|
-
sb.push("
|
|
5420
|
+
sb.push("---");
|
|
5421
|
+
sb.push("");
|
|
5422
|
+
sb.push("### \u{1F4CB} Check results");
|
|
5423
|
+
sb.push("");
|
|
5424
|
+
sb.push("| | Check | Status | Findings | Duration |");
|
|
5425
|
+
sb.push("|:--:|:--|:--|:-:|--:|");
|
|
4897
5426
|
for (const r4 of results) {
|
|
4898
|
-
const
|
|
4899
|
-
|
|
5427
|
+
const he2 = healthEmojiForCheck(r4);
|
|
5428
|
+
let status;
|
|
5429
|
+
if (r4.skipped) {
|
|
5430
|
+
const why = r4.skipped.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
5431
|
+
const short = why.length > 120 ? `${why.slice(0, 117)}\u2026` : why;
|
|
5432
|
+
status = `\u23ED\uFE0F **Skipped** \u2014 ${short}`;
|
|
5433
|
+
} else if (r4.findings.length === 0) {
|
|
5434
|
+
status = "\u2705 **Clean**";
|
|
5435
|
+
} else {
|
|
5436
|
+
status = "\u26A0\uFE0F **Issues**";
|
|
5437
|
+
}
|
|
5438
|
+
const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
|
|
5439
|
+
sb.push(
|
|
5440
|
+
`| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
|
|
5441
|
+
);
|
|
4900
5442
|
}
|
|
4901
5443
|
sb.push("");
|
|
4902
|
-
const blockFindings =
|
|
4903
|
-
|
|
5444
|
+
const blockFindings = sortWithCwd(
|
|
5445
|
+
results.flatMap(
|
|
5446
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "block").map((f4) => ({ r: r4, f: f4 }))
|
|
5447
|
+
)
|
|
4904
5448
|
);
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
}
|
|
5449
|
+
sb.push(`### \u{1F6D1} Blocking \u2014 ${blocks} issue${blocks === 1 ? "" : "s"}`);
|
|
5450
|
+
sb.push("");
|
|
5451
|
+
if (blockFindings.length === 0) {
|
|
5452
|
+
sb.push("*\u2705 No blocking findings \u2014 nothing mapped to severity `block`.*");
|
|
5453
|
+
} else {
|
|
5454
|
+
for (const { r: r4, f: f4 } of blockFindings) {
|
|
5455
|
+
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5456
|
+
sb.push("---");
|
|
5457
|
+
sb.push("");
|
|
5458
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5459
|
+
sb.push("");
|
|
5460
|
+
sb.push(`| Field | Value |`);
|
|
5461
|
+
sb.push(`|:--|:--|`);
|
|
5462
|
+
sb.push(`| **Check** | \`${r4.checkId}\` |`);
|
|
5463
|
+
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5464
|
+
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5465
|
+
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5466
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5467
|
+
sb.push("");
|
|
4921
5468
|
}
|
|
4922
|
-
sb.push("");
|
|
4923
5469
|
}
|
|
4924
|
-
|
|
4925
|
-
|
|
5470
|
+
sb.push("");
|
|
5471
|
+
const warnFindings = sortWithCwd(
|
|
5472
|
+
results.flatMap(
|
|
5473
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "warn").map((f4) => ({ r: r4, f: f4 }))
|
|
5474
|
+
)
|
|
4926
5475
|
);
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
5476
|
+
sb.push(
|
|
5477
|
+
`### \u26A0\uFE0F Warnings \u2014 ${warns} issue${warns === 1 ? "" : "s"} (by check)`
|
|
5478
|
+
);
|
|
5479
|
+
sb.push("");
|
|
5480
|
+
if (warnFindings.length === 0) {
|
|
5481
|
+
sb.push("*\u{1F389} No warnings \u2014 nice work.*");
|
|
5482
|
+
} else {
|
|
5483
|
+
const byCheck = /* @__PURE__ */ new Map();
|
|
5484
|
+
for (const item of warnFindings) {
|
|
5485
|
+
const list = byCheck.get(item.r.checkId) ?? [];
|
|
5486
|
+
list.push(item);
|
|
5487
|
+
byCheck.set(item.r.checkId, list);
|
|
5488
|
+
}
|
|
5489
|
+
const checkOrder = [...byCheck.keys()].sort((a3, b3) => a3.localeCompare(b3));
|
|
5490
|
+
for (const checkId of checkOrder) {
|
|
5491
|
+
const group = sortWithCwd(byCheck.get(checkId));
|
|
5492
|
+
sb.push(`#### \u{1F4C2} \`${checkId}\` \xB7 ${group.length} finding${group.length === 1 ? "" : "s"}`);
|
|
5493
|
+
sb.push("");
|
|
5494
|
+
for (const { r: r4, f: f4 } of group) {
|
|
5495
|
+
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5496
|
+
sb.push("---");
|
|
5497
|
+
sb.push("");
|
|
5498
|
+
sb.push(`##### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5499
|
+
sb.push("");
|
|
5500
|
+
sb.push(`| Field | Value |`);
|
|
5501
|
+
sb.push(`|:--|:--|`);
|
|
5502
|
+
sb.push(`| **Check** | \`${r4.checkId}\` |`);
|
|
5503
|
+
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5504
|
+
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5505
|
+
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5506
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5507
|
+
sb.push("");
|
|
4942
5508
|
}
|
|
4943
5509
|
}
|
|
4944
|
-
if (warnFindings.length > 30) {
|
|
4945
|
-
sb.push(`- _\u2026and ${warnFindings.length - 30} more_`);
|
|
4946
|
-
}
|
|
4947
|
-
sb.push("");
|
|
4948
5510
|
}
|
|
4949
|
-
|
|
4950
|
-
|
|
5511
|
+
sb.push("");
|
|
5512
|
+
const infoFindings = sortWithCwd(
|
|
5513
|
+
results.flatMap(
|
|
5514
|
+
(r4) => r4.findings.filter((f4) => f4.severity === "info").map((f4) => ({ r: r4, f: f4 }))
|
|
5515
|
+
)
|
|
4951
5516
|
);
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
5517
|
+
sb.push(`### \u2139\uFE0F Info & notes \u2014 ${infos} item${infos === 1 ? "" : "s"}`);
|
|
5518
|
+
sb.push("");
|
|
5519
|
+
if (infoFindings.length === 0) {
|
|
5520
|
+
sb.push("*No info-level notes.*");
|
|
5521
|
+
} else {
|
|
5522
|
+
for (const { r: r4, f: f4 } of infoFindings) {
|
|
5523
|
+
const d3 = normalizeFindingDisplay(cwd, f4);
|
|
5524
|
+
sb.push("---");
|
|
5525
|
+
sb.push("");
|
|
5526
|
+
sb.push(`#### ${findingTitleLine(d3.file, d3.message)}`);
|
|
5527
|
+
sb.push("");
|
|
5528
|
+
sb.push(`| Field | Value |`);
|
|
5529
|
+
sb.push(`|:--|:--|`);
|
|
5530
|
+
sb.push(`| **Check** | \`${r4.checkId}\` |`);
|
|
5531
|
+
sb.push(`| **Rule / id** | \`${f4.id}\` |`);
|
|
5532
|
+
if (d3.file) sb.push(`| **File** | \`${d3.file}\` |`);
|
|
5533
|
+
appendDetailAfterTable(sb, cwd, d3.detail);
|
|
5534
|
+
appendSuggestedFix(sb, cwd, f4);
|
|
5535
|
+
sb.push("");
|
|
4956
5536
|
}
|
|
4957
|
-
sb.push("");
|
|
4958
5537
|
}
|
|
5538
|
+
sb.push("");
|
|
4959
5539
|
if (llmAppendix?.trim()) {
|
|
5540
|
+
sb.push("### \u{1F916} AI / manual appendix");
|
|
5541
|
+
sb.push("");
|
|
4960
5542
|
sb.push(llmAppendix.trim());
|
|
4961
5543
|
sb.push("");
|
|
4962
5544
|
}
|
|
4963
5545
|
sb.push("---");
|
|
4964
|
-
sb.push("
|
|
5546
|
+
sb.push("");
|
|
5547
|
+
sb.push(
|
|
5548
|
+
mdShield(
|
|
5549
|
+
"Generated by",
|
|
5550
|
+
"report",
|
|
5551
|
+
"FrontGuard",
|
|
5552
|
+
"blueviolet"
|
|
5553
|
+
)
|
|
5554
|
+
);
|
|
5555
|
+
sb.push("");
|
|
5556
|
+
sb.push(
|
|
5557
|
+
"_Configure checks in `frontguard.config.js` \xB7 [Shields.io](https://shields.io) badge images load in Bitbucket PR comments (HTML tags like `<details>` are not supported there)._"
|
|
5558
|
+
);
|
|
4965
5559
|
return sb.join("\n");
|
|
4966
5560
|
}
|
|
4967
5561
|
function formatConsole(p2) {
|
|
@@ -4988,6 +5582,185 @@ function formatConsole(p2) {
|
|
|
4988
5582
|
return lines.join("\n");
|
|
4989
5583
|
}
|
|
4990
5584
|
|
|
5585
|
+
// src/llm/ollama.ts
|
|
5586
|
+
async function callOllamaChat(opts) {
|
|
5587
|
+
const fetch = getFetch();
|
|
5588
|
+
const base = opts.baseUrl.replace(/\/$/, "");
|
|
5589
|
+
const controller = new AbortController();
|
|
5590
|
+
const t3 = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
5591
|
+
try {
|
|
5592
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
5593
|
+
method: "POST",
|
|
5594
|
+
signal: controller.signal,
|
|
5595
|
+
headers: { "Content-Type": "application/json" },
|
|
5596
|
+
body: JSON.stringify({
|
|
5597
|
+
model: opts.model,
|
|
5598
|
+
messages: [{ role: "user", content: opts.prompt }],
|
|
5599
|
+
stream: false,
|
|
5600
|
+
options: { temperature: 0.2 }
|
|
5601
|
+
})
|
|
5602
|
+
});
|
|
5603
|
+
if (!res.ok) {
|
|
5604
|
+
const body = await res.text();
|
|
5605
|
+
throw new Error(`Ollama HTTP ${res.status}: ${body.slice(0, 400)}`);
|
|
5606
|
+
}
|
|
5607
|
+
const data = await res.json();
|
|
5608
|
+
const text = data.message?.content?.trim();
|
|
5609
|
+
if (!text) throw new Error("Ollama returned empty content");
|
|
5610
|
+
return text;
|
|
5611
|
+
} finally {
|
|
5612
|
+
clearTimeout(t3);
|
|
5613
|
+
}
|
|
5614
|
+
}
|
|
5615
|
+
|
|
5616
|
+
// src/llm/finding-fixes.ts
|
|
5617
|
+
async function safeReadRepoFile(cwd, rel, maxChars) {
|
|
5618
|
+
const root = path4.resolve(cwd);
|
|
5619
|
+
const abs = path4.resolve(root, rel);
|
|
5620
|
+
const relToRoot = path4.relative(root, abs);
|
|
5621
|
+
if (relToRoot.startsWith("..") || path4.isAbsolute(relToRoot)) return null;
|
|
5622
|
+
try {
|
|
5623
|
+
let t3 = await fs.readFile(abs, "utf8");
|
|
5624
|
+
if (t3.length > maxChars) {
|
|
5625
|
+
t3 = t3.slice(0, maxChars) + `
|
|
5626
|
+
|
|
5627
|
+
/* \u2026 truncated after ${maxChars} chars (FrontGuard context limit) \u2026 */
|
|
5628
|
+
`;
|
|
5629
|
+
}
|
|
5630
|
+
return t3;
|
|
5631
|
+
} catch {
|
|
5632
|
+
return null;
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
function parseFixResponse(raw) {
|
|
5636
|
+
const codeMatch = /```(?:\w+)?\n([\s\S]*?)```/m.exec(raw);
|
|
5637
|
+
const code = codeMatch?.[1]?.trim();
|
|
5638
|
+
let summary = codeMatch ? raw.replace(codeMatch[0], "").trim() : raw.trim();
|
|
5639
|
+
summary = summary.replace(/^#{1,6}\s+Fix\s*$/m, "").trim();
|
|
5640
|
+
return { summary: summary || raw.trim(), code };
|
|
5641
|
+
}
|
|
5642
|
+
async function enrichFindingsWithOllamaFixes(opts) {
|
|
5643
|
+
const { cwd, config, stack, results } = opts;
|
|
5644
|
+
const cfg = config.checks.llm;
|
|
5645
|
+
if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
|
|
5646
|
+
return results;
|
|
5647
|
+
}
|
|
5648
|
+
let pkgSnippet = "";
|
|
5649
|
+
try {
|
|
5650
|
+
const pj = await fs.readFile(path4.join(cwd, "package.json"), "utf8");
|
|
5651
|
+
pkgSnippet = pj.slice(0, 4e3);
|
|
5652
|
+
} catch {
|
|
5653
|
+
pkgSnippet = "";
|
|
5654
|
+
}
|
|
5655
|
+
const stackLabel = formatStackOneLiner(stack);
|
|
5656
|
+
const out = results.map((r4) => ({
|
|
5657
|
+
...r4,
|
|
5658
|
+
findings: r4.findings.map((f4) => ({ ...f4 }))
|
|
5659
|
+
}));
|
|
5660
|
+
let budget = cfg.maxFixSuggestions;
|
|
5661
|
+
for (let ri = 0; ri < out.length && budget > 0; ri++) {
|
|
5662
|
+
const r4 = out[ri];
|
|
5663
|
+
for (let fi = 0; fi < r4.findings.length && budget > 0; fi++) {
|
|
5664
|
+
const f4 = r4.findings[fi];
|
|
5665
|
+
if (f4.severity !== "warn" && f4.severity !== "block") continue;
|
|
5666
|
+
if (!f4.file) continue;
|
|
5667
|
+
budget -= 1;
|
|
5668
|
+
const fileContent = await safeReadRepoFile(
|
|
5669
|
+
cwd,
|
|
5670
|
+
f4.file,
|
|
5671
|
+
cfg.maxFileContextChars
|
|
5672
|
+
);
|
|
5673
|
+
const prompt2 = [
|
|
5674
|
+
"You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
|
|
5675
|
+
"Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
|
|
5676
|
+
"If context is insufficient, say what is missing instead of guessing.",
|
|
5677
|
+
"",
|
|
5678
|
+
"Reply in Markdown with exactly these sections:",
|
|
5679
|
+
"### Why",
|
|
5680
|
+
"(1\u20133 short sentences: root cause and product/engineering risk.)",
|
|
5681
|
+
"### Fix",
|
|
5682
|
+
"(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)",
|
|
5683
|
+
"",
|
|
5684
|
+
`Repo stack: ${stackLabel}`,
|
|
5685
|
+
"",
|
|
5686
|
+
"package.json excerpt:",
|
|
5687
|
+
"```json",
|
|
5688
|
+
pkgSnippet || "{}",
|
|
5689
|
+
"```",
|
|
5690
|
+
"",
|
|
5691
|
+
`check: ${r4.checkId}`,
|
|
5692
|
+
`rule/id: ${f4.id}`,
|
|
5693
|
+
`severity: ${f4.severity}`,
|
|
5694
|
+
`message: ${f4.message}`,
|
|
5695
|
+
f4.detail ? `detail: ${f4.detail}` : "",
|
|
5696
|
+
"",
|
|
5697
|
+
f4.file ? `file (repo-relative): ${f4.file}` : "",
|
|
5698
|
+
"",
|
|
5699
|
+
fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
|
|
5700
|
+
].filter(Boolean).join("\n");
|
|
5701
|
+
try {
|
|
5702
|
+
const raw = await callOllamaChat({
|
|
5703
|
+
baseUrl: cfg.ollamaUrl,
|
|
5704
|
+
model: cfg.model,
|
|
5705
|
+
prompt: prompt2,
|
|
5706
|
+
timeoutMs: Math.min(cfg.timeoutMs, 12e4)
|
|
5707
|
+
});
|
|
5708
|
+
const parsed = parseFixResponse(raw);
|
|
5709
|
+
r4.findings[fi] = {
|
|
5710
|
+
...f4,
|
|
5711
|
+
suggestedFix: {
|
|
5712
|
+
summary: parsed.summary,
|
|
5713
|
+
...parsed.code ? { code: parsed.code } : {}
|
|
5714
|
+
}
|
|
5715
|
+
};
|
|
5716
|
+
} catch {
|
|
5717
|
+
r4.findings[fi] = {
|
|
5718
|
+
...f4,
|
|
5719
|
+
suggestedFix: {
|
|
5720
|
+
summary: "_Could not reach Ollama or the model timed out. Is `ollama serve` running and `checks.llm.model` installed?_"
|
|
5721
|
+
}
|
|
5722
|
+
};
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
return out;
|
|
5727
|
+
}
|
|
5728
|
+
var MAX_CHARS = 2e5;
|
|
5729
|
+
async function loadManualAppendix(opts) {
|
|
5730
|
+
const { cwd, filePath } = opts;
|
|
5731
|
+
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
5732
|
+
const resolvedPath = filePath?.trim() || envFile;
|
|
5733
|
+
if (resolvedPath) {
|
|
5734
|
+
const abs = path4.isAbsolute(resolvedPath) ? resolvedPath : path4.join(cwd, resolvedPath);
|
|
5735
|
+
try {
|
|
5736
|
+
let text = await fs.readFile(abs, "utf8");
|
|
5737
|
+
if (text.length > MAX_CHARS) {
|
|
5738
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5739
|
+
}
|
|
5740
|
+
const t3 = text.trim();
|
|
5741
|
+
if (t3) {
|
|
5742
|
+
return `### Contributed review notes
|
|
5743
|
+
|
|
5744
|
+
_Pasted or file-based (no CI API key)._
|
|
5745
|
+
|
|
5746
|
+
${t3}`;
|
|
5747
|
+
}
|
|
5748
|
+
} catch {
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
5752
|
+
if (inline) {
|
|
5753
|
+
let text = inline;
|
|
5754
|
+
if (text.length > MAX_CHARS) {
|
|
5755
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5756
|
+
}
|
|
5757
|
+
return `### Contributed review notes
|
|
5758
|
+
|
|
5759
|
+
${text.trim()}`;
|
|
5760
|
+
}
|
|
5761
|
+
return null;
|
|
5762
|
+
}
|
|
5763
|
+
|
|
4991
5764
|
// src/llm/review.ts
|
|
4992
5765
|
function safeGetEnv(name) {
|
|
4993
5766
|
const v3 = process.env[name];
|
|
@@ -4997,21 +5770,23 @@ async function runLlmReview(opts) {
|
|
|
4997
5770
|
const { cwd, config, pr, results } = opts;
|
|
4998
5771
|
const cfg = config.checks.llm;
|
|
4999
5772
|
if (!cfg.enabled) return null;
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
if (
|
|
5003
|
-
|
|
5773
|
+
if (cfg.provider !== "ollama") {
|
|
5774
|
+
const apiKey2 = safeGetEnv(cfg.apiKeyEnv);
|
|
5775
|
+
if (!apiKey2) {
|
|
5776
|
+
if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
|
|
5777
|
+
return null;
|
|
5778
|
+
}
|
|
5779
|
+
return [
|
|
5780
|
+
"### AI review (automated CI)",
|
|
5781
|
+
"",
|
|
5782
|
+
"_No API key in this environment._ IDE credentials do not reach CI runners.",
|
|
5783
|
+
"",
|
|
5784
|
+
"**Options:**",
|
|
5785
|
+
"1. **Manual** \u2014 Paste notes into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
5786
|
+
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
5787
|
+
'3. **Local Ollama** \u2014 Set `checks.llm.provider` to `"ollama"` (no API key; see docs).'
|
|
5788
|
+
].join("\n");
|
|
5004
5789
|
}
|
|
5005
|
-
return [
|
|
5006
|
-
"### AI review (automated CI)",
|
|
5007
|
-
"",
|
|
5008
|
-
"_No API key in this environment._ IDE credentials do not reach GitHub Actions.",
|
|
5009
|
-
"",
|
|
5010
|
-
"**Options:**",
|
|
5011
|
-
"1. **Manual** \u2014 Paste notes from Cursor/ChatGPT/Claude into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
5012
|
-
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
5013
|
-
"3. **Docs in PR** \u2014 Rely on the PR template \u201CAI assistance\u201D section for reviewer context."
|
|
5014
|
-
].join("\n");
|
|
5015
5790
|
}
|
|
5016
5791
|
if (!await gitOk(cwd)) {
|
|
5017
5792
|
return "_LLM review skipped: not a git repository_";
|
|
@@ -5035,7 +5810,7 @@ async function runLlmReview(opts) {
|
|
|
5035
5810
|
"",
|
|
5036
5811
|
pr ? `PR title: ${pr.title}
|
|
5037
5812
|
PR body excerpt:
|
|
5038
|
-
${pr.body.slice(0, 2e3)}` : "No
|
|
5813
|
+
${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
|
|
5039
5814
|
"",
|
|
5040
5815
|
"Existing automated findings (may be incomplete):",
|
|
5041
5816
|
summaryLines || "(none)",
|
|
@@ -5045,6 +5820,23 @@ ${pr.body.slice(0, 2e3)}` : "No GitHub PR context (local run).",
|
|
|
5045
5820
|
diff,
|
|
5046
5821
|
"```"
|
|
5047
5822
|
].join("\n");
|
|
5823
|
+
if (cfg.provider === "ollama") {
|
|
5824
|
+
try {
|
|
5825
|
+
const text = await callOllamaChat({
|
|
5826
|
+
baseUrl: cfg.ollamaUrl,
|
|
5827
|
+
model: cfg.model,
|
|
5828
|
+
prompt: prompt2,
|
|
5829
|
+
timeoutMs: cfg.timeoutMs
|
|
5830
|
+
});
|
|
5831
|
+
return `### AI review (non-binding, Ollama)
|
|
5832
|
+
|
|
5833
|
+
${text}`;
|
|
5834
|
+
} catch (e3) {
|
|
5835
|
+
const msg = e3 instanceof Error ? e3.message : String(e3);
|
|
5836
|
+
return `_Ollama request failed: ${msg}_`;
|
|
5837
|
+
}
|
|
5838
|
+
}
|
|
5839
|
+
const apiKey = safeGetEnv(cfg.apiKeyEnv);
|
|
5048
5840
|
const controller = new AbortController();
|
|
5049
5841
|
const t3 = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
5050
5842
|
try {
|
|
@@ -5121,41 +5913,6 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
|
|
|
5121
5913
|
if (!text) throw new Error("Anthropic returned empty content");
|
|
5122
5914
|
return text;
|
|
5123
5915
|
}
|
|
5124
|
-
var MAX_CHARS = 2e5;
|
|
5125
|
-
async function loadManualAppendix(opts) {
|
|
5126
|
-
const { cwd, filePath } = opts;
|
|
5127
|
-
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
5128
|
-
const resolvedPath = filePath?.trim() || envFile;
|
|
5129
|
-
if (resolvedPath) {
|
|
5130
|
-
const abs = path.isAbsolute(resolvedPath) ? resolvedPath : path.join(cwd, resolvedPath);
|
|
5131
|
-
try {
|
|
5132
|
-
let text = await fs.readFile(abs, "utf8");
|
|
5133
|
-
if (text.length > MAX_CHARS) {
|
|
5134
|
-
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5135
|
-
}
|
|
5136
|
-
const t3 = text.trim();
|
|
5137
|
-
if (t3) {
|
|
5138
|
-
return `### Contributed review notes
|
|
5139
|
-
|
|
5140
|
-
_Pasted or file-based (no CI API key)._
|
|
5141
|
-
|
|
5142
|
-
${t3}`;
|
|
5143
|
-
}
|
|
5144
|
-
} catch {
|
|
5145
|
-
}
|
|
5146
|
-
}
|
|
5147
|
-
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
5148
|
-
if (inline) {
|
|
5149
|
-
let text = inline;
|
|
5150
|
-
if (text.length > MAX_CHARS) {
|
|
5151
|
-
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
5152
|
-
}
|
|
5153
|
-
return `### Contributed review notes
|
|
5154
|
-
|
|
5155
|
-
${text.trim()}`;
|
|
5156
|
-
}
|
|
5157
|
-
return null;
|
|
5158
|
-
}
|
|
5159
5916
|
|
|
5160
5917
|
// src/commands/run.ts
|
|
5161
5918
|
async function runFrontGuard(opts) {
|
|
@@ -5190,7 +5947,7 @@ async function runFrontGuard(opts) {
|
|
|
5190
5947
|
const bundle = await runBundle(opts.cwd, config, stack);
|
|
5191
5948
|
const prHygiene = runPrHygiene(config, pr);
|
|
5192
5949
|
const prSize = runPrSize(config, pr);
|
|
5193
|
-
|
|
5950
|
+
let results = [
|
|
5194
5951
|
eslint,
|
|
5195
5952
|
prettier,
|
|
5196
5953
|
typescript,
|
|
@@ -5206,6 +5963,12 @@ async function runFrontGuard(opts) {
|
|
|
5206
5963
|
prSize
|
|
5207
5964
|
];
|
|
5208
5965
|
applyAiAssistedEscalation(results, pr, config);
|
|
5966
|
+
results = await enrichFindingsWithOllamaFixes({
|
|
5967
|
+
cwd: opts.cwd,
|
|
5968
|
+
config,
|
|
5969
|
+
stack,
|
|
5970
|
+
results
|
|
5971
|
+
});
|
|
5209
5972
|
const manualAppendix = await loadManualAppendix({
|
|
5210
5973
|
cwd: opts.cwd,
|
|
5211
5974
|
filePath: opts.append ?? null
|
|
@@ -5217,7 +5980,19 @@ async function runFrontGuard(opts) {
|
|
|
5217
5980
|
results
|
|
5218
5981
|
});
|
|
5219
5982
|
const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
|
|
5220
|
-
const report = buildReport(stack, pr, results, {
|
|
5983
|
+
const report = buildReport(stack, pr, results, {
|
|
5984
|
+
mode,
|
|
5985
|
+
llmAppendix,
|
|
5986
|
+
cwd: opts.cwd,
|
|
5987
|
+
emitHtml: Boolean(opts.htmlOut)
|
|
5988
|
+
});
|
|
5989
|
+
if (opts.htmlOut && report.html) {
|
|
5990
|
+
await fs.writeFile(opts.htmlOut, report.html, "utf8");
|
|
5991
|
+
}
|
|
5992
|
+
if (opts.prCommentOut) {
|
|
5993
|
+
const snippet = formatBitbucketPrSnippet(report);
|
|
5994
|
+
await fs.writeFile(opts.prCommentOut, snippet, "utf8");
|
|
5995
|
+
}
|
|
5221
5996
|
if (opts.markdown) {
|
|
5222
5997
|
g.stdout.write(report.markdown + "\n");
|
|
5223
5998
|
} else {
|
|
@@ -5269,6 +6044,14 @@ var run = defineCommand({
|
|
|
5269
6044
|
append: {
|
|
5270
6045
|
type: "string",
|
|
5271
6046
|
description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
|
|
6047
|
+
},
|
|
6048
|
+
htmlOut: {
|
|
6049
|
+
type: "string",
|
|
6050
|
+
description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
|
|
6051
|
+
},
|
|
6052
|
+
prCommentOut: {
|
|
6053
|
+
type: "string",
|
|
6054
|
+
description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
|
|
5272
6055
|
}
|
|
5273
6056
|
},
|
|
5274
6057
|
run: async ({ args }) => {
|
|
@@ -5277,7 +6060,9 @@ var run = defineCommand({
|
|
|
5277
6060
|
ci: Boolean(args.ci),
|
|
5278
6061
|
markdown: Boolean(args.markdown),
|
|
5279
6062
|
enforce: Boolean(args.enforce),
|
|
5280
|
-
append: typeof args.append === "string" ? args.append : null
|
|
6063
|
+
append: typeof args.append === "string" ? args.append : null,
|
|
6064
|
+
htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
|
|
6065
|
+
prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
|
|
5281
6066
|
});
|
|
5282
6067
|
}
|
|
5283
6068
|
});
|