@cleartrip/frontguard 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,17 +4,16 @@ 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 path5, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
- import fs, { writeFile, readFile } from 'fs/promises';
9
- import { fileURLToPath, pathToFileURL } from 'url';
10
- import fs2, { existsSync, statSync, readFileSync } from 'fs';
7
+ import path6, { sep, normalize, delimiter, resolve, dirname } from 'path';
8
+ import fs from 'fs/promises';
11
9
  import { execFileSync, spawn } from 'child_process';
12
10
  import { createRequire } from 'module';
11
+ import fs2 from 'fs';
12
+ import { pathToFileURL } from 'url';
13
+ import fg from 'fast-glob';
13
14
  import { pipeline } from 'stream/promises';
14
15
  import { PassThrough } from 'stream';
15
- import fg from 'fast-glob';
16
16
  import * as ts from 'typescript';
17
- import { Resvg } from '@resvg/resvg-js';
18
17
 
19
18
  var __create = Object.create;
20
19
  var __defProp = Object.defineProperty;
@@ -2400,50 +2399,177 @@ async function runMain(cmd, opts = {}) {
2400
2399
  process.exit(1);
2401
2400
  }
2402
2401
  }
2403
- function packageRoot() {
2404
- return path5.resolve(path5.dirname(fileURLToPath(import.meta.url)), "..");
2405
- }
2406
- var CONFIG = `import { defineConfig } from '@cleartrip/frontguard'
2402
+ var CONFIG_TEMPLATE = `import { defineConfig } from '@cleartrip/frontguard'
2407
2403
 
2408
2404
  export default defineConfig({
2409
- mode: 'warn', // or FrontGuard run with \`--enforce\`
2405
+ // \u2500\u2500\u2500 Mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2406
+ // 'warn' \u2014 findings are advisory; CI always passes (default).
2407
+ // 'enforce' \u2014 CI exits non-zero when any finding has severity 'block'.
2408
+ // Same as running \`frontguard run --enforce\`.
2409
+ mode: 'warn',
2410
+
2411
+ // \u2500\u2500\u2500 Shared org config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2412
+ // Inherit from an installed npm package; fields are deep-merged.
2410
2413
  // extends: '@your-org/frontguard-config/base',
2411
2414
 
2412
- // Example custom rules (return true when violated):
2415
+ // \u2500\u2500\u2500 Custom rules \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2416
+ // Inline pattern checks \u2014 \`check(file)\` returns true when violated.
2413
2417
  // rules: {
2414
2418
  // 'no-inline-style': {
2415
2419
  // severity: 'warn',
2416
- // message: 'Avoid inline style objects',
2417
- // check: (file) => file.content.includes('style={{'),
2420
+ // message: 'Avoid inline style objects in JSX',
2421
+ // check: (file) => /\\.tsx$/.test(file.path) && file.content.includes('style={{'),
2418
2422
  // },
2419
- // },
2420
-
2421
- // checks: {
2422
- // bundle: { baselineRef: 'main', maxDeltaBytes: 50_000 },
2423
- // coreWebVitals: { scanGlobs: ['src/**/*.{tsx,jsx}'] },
2424
- // prSize: {
2425
- // tiers: [
2426
- // { minLines: 1000, severity: 'warn', message: 'Very large PR (\${lines} lines; \u2265 \${min})' },
2427
- // { minLines: 500, severity: 'info', message: 'Consider splitting (\${lines} lines)' },
2428
- // ],
2423
+ // 'no-console-log': {
2424
+ // severity: 'info',
2425
+ // message: 'Remove console.log before merging',
2426
+ // check: (file) => /\\.(ts|tsx|js|jsx)$/.test(file.path) && /console\\.log\\(/.test(file.content),
2429
2427
  // },
2430
- // // AI strict: only // @frontguard-ai:start \u2026 :end (or // written by AI: start \u2026 :end) in PR files
2431
- // // aiAssistedReview: { strictScanMode: 'decorator' },
2432
- // cycles: { enabled: true },
2433
- // deadCode: { enabled: true, gate: 'info' },
2434
- // // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
2435
- // // ollama serve && ollama pull llama3.2
2436
- // // Paste workflow (no key): frontguard run --append ./.frontguard/review-notes.md
2437
- // llm: {
2438
- // enabled: true,
2439
- // provider: 'ollama',
2440
- // model: 'llama3.2',
2441
- // ollamaUrl: 'http://127.0.0.1:11434',
2442
- // perFindingFixes: true,
2443
- // maxFixSuggestions: 8,
2444
- // },
2445
- // // llm: { enabled: true, provider: 'openai', apiKeyEnv: 'OPENAI_API_KEY' },
2446
2428
  // },
2429
+
2430
+ checks: {
2431
+ // \u2500\u2500\u2500 ESLint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2432
+ // Runs ESLint using your project config on PR-scoped files.
2433
+ // eslint: {
2434
+ // enabled: true,
2435
+ // glob: '**/*.{js,cjs,mjs,jsx,ts,tsx}', // files to lint
2436
+ // },
2437
+
2438
+ // \u2500\u2500\u2500 Prettier \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2439
+ // Checks that files match Prettier formatting.
2440
+ // prettier: {
2441
+ // enabled: true,
2442
+ // glob: '**/*.{js,cjs,mjs,jsx,ts,tsx,json,md,css,scss,yml,yaml}',
2443
+ // },
2444
+
2445
+ // \u2500\u2500\u2500 TypeScript \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2446
+ // Runs \`tsc --noEmit\` to surface type errors before merge.
2447
+ // typescript: {
2448
+ // enabled: true,
2449
+ // tscArgs: ['--noEmit'], // extra tsc flags
2450
+ // },
2451
+
2452
+ // \u2500\u2500\u2500 Secrets \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2453
+ // Scans changed files for patterns that look like leaked secrets.
2454
+ // secrets: { enabled: true },
2455
+
2456
+ // \u2500\u2500\u2500 PR Hygiene \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2457
+ // Validates PR metadata: description length, sections, AI disclosure.
2458
+ // prHygiene: {
2459
+ // enabled: true,
2460
+ // minBodyLength: 80,
2461
+ // requireSections: false,
2462
+ // sectionHints: ['what', 'why', 'test', 'screenshot'],
2463
+ // requireAiDisclosureSection: true,
2464
+ // gateWhenAiDisclosureAmbiguous: 'warn', // 'info' | 'warn' | 'block'
2465
+ // },
2466
+
2467
+ // \u2500\u2500\u2500 PR Size \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2468
+ // Flags oversized PRs based on total changed lines.
2469
+ // prSize: {
2470
+ // enabled: true,
2471
+ // warnLines: 400,
2472
+ // softBlockLines: 800,
2473
+ // // Custom tiers (overrides warnLines/softBlockLines when set):
2474
+ // // tiers: [
2475
+ // // { minLines: 1000, severity: 'block', message: 'PR too large (\${lines} lines)' },
2476
+ // // { minLines: 500, severity: 'warn', message: 'Consider splitting (\${lines} lines)' },
2477
+ // // ],
2478
+ // },
2479
+
2480
+ // \u2500\u2500\u2500 TS any delta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2481
+ // Counts new \`any\` usage introduced in the diff vs merge-base.
2482
+ // tsAnyDelta: {
2483
+ // enabled: true,
2484
+ // gate: 'warn', // 'info' | 'warn' | 'block'
2485
+ // baseRef: 'main', // fallback when BITBUCKET_PR_DESTINATION_BRANCH unset
2486
+ // maxAdded: 0, // 0 = report only; >0 = trigger gate when exceeded
2487
+ // },
2488
+
2489
+ // \u2500\u2500\u2500 Circular dependencies \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2490
+ // Runs \`madge\` to find import cycles in your source.
2491
+ // cycles: {
2492
+ // enabled: true,
2493
+ // gate: 'warn',
2494
+ // entries: ['src'], // entry directories for madge
2495
+ // extraArgs: [],
2496
+ // },
2497
+
2498
+ // \u2500\u2500\u2500 Dead code \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2499
+ // Runs \`ts-prune\` to find unused exports in the TypeScript project.
2500
+ // deadCode: {
2501
+ // enabled: true,
2502
+ // gate: 'info',
2503
+ // extraArgs: [],
2504
+ // maxReportLines: 80,
2505
+ // },
2506
+
2507
+ // \u2500\u2500\u2500 Bundle size \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2508
+ // Measures bundle after build and compares to a baseline.
2509
+ //
2510
+ // bundleSizeStrategy options:
2511
+ // 'auto' \u2014 auto-detect from package.json (Next\u2192next, Vite\u2192vite, CRA\u2192cra, else glob)
2512
+ // 'next' \u2014 parse "First Load JS shared by all" from \`next build\` stdout
2513
+ // 'vite' \u2014 sum dist/assets/*.js after \`vite build\`
2514
+ // 'cra' \u2014 parse gzipped JS total from \`react-scripts build\` stdout
2515
+ // 'glob' \u2014 sum raw bytes under measureGlobs (generic fallback)
2516
+ // 'custom' \u2014 run bundleSizeCommand, read a single number (bytes) from stdout
2517
+ //
2518
+ // bundle: {
2519
+ // enabled: true,
2520
+ // gate: 'warn',
2521
+ // runBuild: true, // false = skip build, measure existing files
2522
+ // buildCommand: 'npm run build', // your production build command
2523
+ // bundleSizeStrategy: 'auto',
2524
+ // bundleSizeCommand: null, // only for strategy 'custom', e.g. 'node scripts/bundle-size.js'
2525
+ // measureGlobs: ['dist/**/*', 'build/static/**/*', '.next/static/**/*'],
2526
+ // baselinePath: '.frontguard/bundle-baseline.json',
2527
+ // baselineRef: 'main',
2528
+ // maxDeltaBytes: 50_000, // max allowed growth vs baseline; null = no limit
2529
+ // maxTotalBytes: null, // absolute cap; null = no limit
2530
+ // },
2531
+
2532
+ // \u2500\u2500\u2500 Core Web Vitals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2533
+ // Static hints for LCP-friendly images, main-thread hygiene, etc.
2534
+ // coreWebVitals: {
2535
+ // enabled: true,
2536
+ // gate: 'warn',
2537
+ // scanGlobs: ['app/**/*.{tsx,jsx}', 'pages/**/*.{tsx,jsx}', 'src/**/*.{tsx,jsx}'],
2538
+ // maxFileBytes: 400_000,
2539
+ // },
2540
+
2541
+ // \u2500\u2500\u2500 AI-assisted review (UNDER DEVELOPMENT \u2014 off by default) \u2500
2542
+ // Static heuristics on @frontguard-ai regions or AI-disclosed PRs. Not used in CI until you enable.
2543
+ // aiAssistedReview: {
2544
+ // enabled: true,
2545
+ // gate: 'warn',
2546
+ // // 'decorator' \u2014 only scan @frontguard-ai:start \u2026 :end regions
2547
+ // // 'pr-disclosure' \u2014 only when PR body discloses AI use
2548
+ // // 'both' \u2014 scan decorators when present; otherwise PR disclosure triggers full scan
2549
+ // strictScanMode: 'both',
2550
+ // escalate: {
2551
+ // secretFindingsToBlock: true, // promote secret findings to 'block' in AI PRs
2552
+ // tsAnyDeltaToBlock: true, // promote any-delta findings to 'block' in AI PRs
2553
+ // },
2554
+ // },
2555
+
2556
+ // \u2500\u2500\u2500 LLM review (UNDER DEVELOPMENT \u2014 off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
2557
+ // When enabled: loads .cursor/rules, AGENTS.md for prompt context. Cloud keys or Ollama.
2558
+ // ollama: ollama serve && ollama pull llama3.2
2559
+ // paste: frontguard run --append ./.frontguard/review-notes.md
2560
+ // llm: {
2561
+ // enabled: true,
2562
+ // provider: 'ollama', // 'openai' | 'anthropic' | 'ollama'
2563
+ // model: 'llama3.2',
2564
+ // ollamaUrl: 'http://127.0.0.1:11434',
2565
+ // apiKeyEnv: 'OPENAI_API_KEY', // env var name for OpenAI/Anthropic
2566
+ // maxDiffChars: 48_000,
2567
+ // timeoutMs: 60_000,
2568
+ // perFindingFixes: false, // ask model for per-finding fix hints (slow)
2569
+ // maxFixSuggestions: 12,
2570
+ // maxFileContextChars: 24_000,
2571
+ // },
2572
+ },
2447
2573
  })
2448
2574
  `;
2449
2575
  var PR_TEMPLATE = `## Summary
@@ -2466,183 +2592,285 @@ If **Yes**, list tools and what they touched (helps reviewers run a stricter fir
2466
2592
  ## AI assistance (optional detail)
2467
2593
  - [ ] I have reviewed every AI-suggested line for security, auth, and product correctness
2468
2594
  `;
2469
- async function initFrontGuard(cwd) {
2470
- const root = packageRoot();
2471
- const tplPath = path5.join(root, "templates", "bitbucket-pipelines.yml");
2472
- const outPipeline = path5.join(cwd, "bitbucket-pipelines.frontguard.example.yml");
2473
- try {
2474
- await fs.access(outPipeline);
2475
- } catch {
2476
- try {
2477
- const yml = await fs.readFile(tplPath, "utf8");
2478
- await fs.writeFile(outPipeline, yml, "utf8");
2479
- } catch {
2480
- await fs.writeFile(
2481
- outPipeline,
2482
- "# Copy bitbucket-pipelines.yml from @cleartrip/frontguard/templates in node_modules\n",
2483
- "utf8"
2484
- );
2485
- }
2595
+ var FRONTGUARD_STEP_YAML = ` - step:
2596
+ name: FrontGuard \u2014 report + PR comment
2597
+ caches:
2598
+ - node
2599
+ artifacts:
2600
+ - frontguard-report.html
2601
+ - frontguard-report.md
2602
+ - frontguard-pr-comment.partial.md
2603
+ script:
2604
+ - corepack enable
2605
+ - yarn install --immutable || yarn install
2606
+ - |
2607
+ yarn frontguard run --markdown \\
2608
+ --htmlOut frontguard-report.html \\
2609
+ --prCommentOut frontguard-pr-comment.partial.md \\
2610
+ > frontguard-report.md
2611
+ - test -n "\${BITBUCKET_ACCESS_TOKEN:-}" || { echo "Missing secured var BITBUCKET_ACCESS_TOKEN"; exit 1; }
2612
+ - |
2613
+ python3 << 'PY'
2614
+ import json
2615
+ import os
2616
+ from urllib.error import HTTPError
2617
+ from urllib.request import Request, urlopen
2618
+
2619
+ PLACEHOLDER = "__FRONTGUARD_REPORT_URL__"
2620
+
2621
+ base = os.environ.get("FREEKIT_BASE_URL", "https://freekit.dev").rstrip("/")
2622
+ with open("frontguard-report.html", encoding="utf-8") as f:
2623
+ html = f.read()
2624
+
2625
+ req = Request(
2626
+ f"{base}/api/v1/sites",
2627
+ data=json.dumps({"html": html}).encode("utf-8"),
2628
+ headers={"Content-Type": "application/json"},
2629
+ method="POST",
2630
+ )
2631
+ try:
2632
+ with urlopen(req, timeout=180) as resp:
2633
+ parsed = json.load(resp)
2634
+ except HTTPError as e:
2635
+ raise SystemExit(
2636
+ f"FreeKit HTTP {e.code}: {(e.read() or b'').decode()[:4000]}"
2637
+ )
2638
+
2639
+ if parsed.get("status") != "success":
2640
+ raise SystemExit(f"FreeKit error: {json.dumps(parsed)[:4000]}")
2641
+
2642
+ data = parsed.get("data") or {}
2643
+ report_url = (data.get("url") or "").strip()
2644
+ if not report_url:
2645
+ raise SystemExit(f"FreeKit: missing data.url in {json.dumps(parsed)[:2000]}")
2646
+
2647
+ with open("frontguard-pr-comment.partial.md", encoding="utf-8") as f:
2648
+ body = f.read()
2649
+ if PLACEHOLDER not in body:
2650
+ raise SystemExit(
2651
+ f"Expected {PLACEHOLDER!r} in frontguard-pr-comment.partial.md \u2014 regenerate with current FrontGuard."
2652
+ )
2653
+ body = body.replace(PLACEHOLDER, report_url, 1)
2654
+
2655
+ with open("frontguard-pr-comment.md", "w", encoding="utf-8") as out:
2656
+ out.write(body)
2657
+ with open("frontguard-payload.json", "w", encoding="utf-8") as out:
2658
+ json.dump({"content": {"raw": body}}, out, ensure_ascii=False)
2659
+ PY
2660
+ - |
2661
+ curl --silent --show-error --fail --request POST \\
2662
+ --url "https://api.bitbucket.org/2.0/repositories/\${BITBUCKET_REPO_FULL_NAME}/pullrequests/\${BITBUCKET_PR_ID}/comments" \\
2663
+ --header 'Accept: application/json' \\
2664
+ --header 'Content-Type: application/json' \\
2665
+ --header "Authorization: Bearer \${BITBUCKET_ACCESS_TOKEN}" \\
2666
+ --data @frontguard-payload.json`;
2667
+ var FRONTGUARD_MARKER = "FrontGuard";
2668
+ function hasFrontGuardStep(content) {
2669
+ return content.includes(FRONTGUARD_MARKER);
2670
+ }
2671
+ function mergePipelineStep(existing) {
2672
+ if (hasFrontGuardStep(existing)) {
2673
+ return existing;
2674
+ }
2675
+ const lines = existing.split("\n");
2676
+ const prSectionIdx = findPullRequestsSection(lines);
2677
+ if (prSectionIdx !== null) {
2678
+ return injectStepIntoPrSection(lines, prSectionIdx);
2679
+ }
2680
+ return appendPrSection(lines);
2681
+ }
2682
+ function findPullRequestsSection(lines) {
2683
+ for (let i3 = 0; i3 < lines.length; i3++) {
2684
+ if (/^\s{2,}pull-requests:\s*$/.test(lines[i3])) return i3;
2486
2685
  }
2487
- const cfgPath = path5.join(cwd, "frontguard.config.js");
2488
- try {
2489
- await fs.access(cfgPath);
2490
- } catch {
2491
- await fs.writeFile(cfgPath, CONFIG, "utf8");
2686
+ return null;
2687
+ }
2688
+ function findBranchPattern(lines, afterLine) {
2689
+ for (let i3 = afterLine + 1; i3 < lines.length; i3++) {
2690
+ const line = lines[i3];
2691
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
2692
+ if (/^\s{4,}'[^']+':/.test(line) || /^\s{4,}"[^"]+":/.test(line)) return i3;
2693
+ break;
2492
2694
  }
2493
- const tplPr = path5.join(cwd, "pull_request_template.md");
2494
- try {
2495
- await fs.access(tplPr);
2496
- } catch {
2497
- await fs.writeFile(tplPr, PR_TEMPLATE, "utf8");
2695
+ return null;
2696
+ }
2697
+ function findLastStepEnd(lines, afterBranch) {
2698
+ let lastContentLine = afterBranch;
2699
+ const branchIndent = (lines[afterBranch].match(/^(\s*)/)?.[1] ?? "").length;
2700
+ for (let i3 = afterBranch + 1; i3 < lines.length; i3++) {
2701
+ const line = lines[i3];
2702
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
2703
+ const indent = (line.match(/^(\s*)/)?.[1] ?? "").length;
2704
+ if (indent <= branchIndent) break;
2705
+ lastContentLine = i3;
2706
+ }
2707
+ return lastContentLine;
2708
+ }
2709
+ function injectStepIntoPrSection(lines, prIdx) {
2710
+ const branchIdx = findBranchPattern(lines, prIdx);
2711
+ if (branchIdx !== null) {
2712
+ const insertAfter = findLastStepEnd(lines, branchIdx);
2713
+ const before = lines.slice(0, insertAfter + 1);
2714
+ const after = lines.slice(insertAfter + 1);
2715
+ return [...before, FRONTGUARD_STEP_YAML, ...after].join("\n");
2716
+ }
2717
+ const result = [...lines];
2718
+ result.splice(prIdx + 1, 0, " '**':", FRONTGUARD_STEP_YAML);
2719
+ return result.join("\n");
2720
+ }
2721
+ function appendPrSection(lines) {
2722
+ const pipelinesIdx = lines.findIndex((l3) => /^pipelines:\s*$/.test(l3));
2723
+ if (pipelinesIdx === -1) {
2724
+ return [
2725
+ ...lines,
2726
+ "",
2727
+ "pipelines:",
2728
+ " pull-requests:",
2729
+ " '**':",
2730
+ FRONTGUARD_STEP_YAML
2731
+ ].join("\n");
2732
+ }
2733
+ let insertAt = pipelinesIdx + 1;
2734
+ for (let i3 = pipelinesIdx + 1; i3 < lines.length; i3++) {
2735
+ if (lines[i3].trim() === "" || lines[i3].trim().startsWith("#")) {
2736
+ insertAt = i3 + 1;
2737
+ continue;
2738
+ }
2739
+ const indent = (lines[i3].match(/^(\s*)/)?.[1] ?? "").length;
2740
+ if (indent >= 2) {
2741
+ insertAt = i3 + 1;
2742
+ } else {
2743
+ break;
2744
+ }
2498
2745
  }
2746
+ const lastSectionEnd = findEndOfLastSection(lines, pipelinesIdx);
2747
+ const actualInsert = Math.max(insertAt, lastSectionEnd + 1);
2748
+ const before = lines.slice(0, actualInsert);
2749
+ const after = lines.slice(actualInsert);
2750
+ return [
2751
+ ...before,
2752
+ " pull-requests:",
2753
+ " '**':",
2754
+ FRONTGUARD_STEP_YAML,
2755
+ ...after
2756
+ ].join("\n");
2499
2757
  }
2500
- var BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT = "FrontGuard checks summary";
2501
- var MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH = 3e5;
2502
- var MAX_PNG_BYTES_BITBUCKET_INLINE = 21e4;
2503
- var MARKDOWN_LINE_PREFIX = `![${BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT}](data:image/png;base64,`;
2504
- function isPngBuffer(buf) {
2505
- return buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71 && buf[4] === 13 && buf[5] === 10 && buf[6] === 26 && buf[7] === 10;
2758
+ function findEndOfLastSection(lines, pipelinesIdx) {
2759
+ let last = pipelinesIdx;
2760
+ for (let i3 = pipelinesIdx + 1; i3 < lines.length; i3++) {
2761
+ const line = lines[i3];
2762
+ if (line.trim() === "") continue;
2763
+ const indent = (line.match(/^(\s*)/)?.[1] ?? "").length;
2764
+ if (indent === 0 && !line.trim().startsWith("#")) break;
2765
+ last = i3;
2766
+ }
2767
+ return last;
2506
2768
  }
2507
- function pngBufferToBitbucketImageMarkdownLine(png) {
2508
- if (!isPngBuffer(png)) {
2509
- throw new TypeError("Buffer is not a PNG (missing IHDR signature)");
2769
+ async function initFrontGuard(cwd) {
2770
+ const actions = [];
2771
+ const cfgPath = path6.join(cwd, "frontguard.config.js");
2772
+ if (!await fileExists(cfgPath)) {
2773
+ await fs.writeFile(cfgPath, CONFIG_TEMPLATE, "utf8");
2774
+ actions.push("created frontguard.config.js");
2775
+ } else {
2776
+ actions.push("frontguard.config.js already exists (skipped)");
2510
2777
  }
2511
- if (png.length > MAX_PNG_BYTES_BITBUCKET_INLINE) {
2512
- throw new RangeError(
2513
- `PNG too large for Bitbucket inline markdown (${png.length} bytes; max ${MAX_PNG_BYTES_BITBUCKET_INLINE}). Host the image and use an HTTPS URL instead.`
2514
- );
2778
+ const prTplPath = path6.join(cwd, "pull_request_template.md");
2779
+ if (!await fileExists(prTplPath)) {
2780
+ await fs.writeFile(prTplPath, PR_TEMPLATE, "utf8");
2781
+ actions.push("created pull_request_template.md");
2782
+ } else {
2783
+ actions.push("pull_request_template.md already exists (skipped)");
2515
2784
  }
2516
- const b64 = png.toString("base64");
2517
- const line = `${MARKDOWN_LINE_PREFIX}${b64})`;
2518
- if (line.length > MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH) {
2519
- throw new RangeError(
2520
- `Inline markdown line too long (${line.length} chars; max ${MAX_BITBUCKET_INLINE_IMAGE_MARKDOWN_LINE_LENGTH})`
2785
+ const pipelinePath = path6.join(cwd, "bitbucket-pipelines.yml");
2786
+ if (await fileExists(pipelinePath)) {
2787
+ const existing = await fs.readFile(pipelinePath, "utf8");
2788
+ if (hasFrontGuardStep(existing)) {
2789
+ actions.push("bitbucket-pipelines.yml already has FrontGuard step (skipped)");
2790
+ } else {
2791
+ const merged = mergePipelineStep(existing);
2792
+ await fs.writeFile(pipelinePath, merged, "utf8");
2793
+ actions.push("merged FrontGuard step into bitbucket-pipelines.yml pull-requests");
2794
+ }
2795
+ } else {
2796
+ await fs.writeFile(
2797
+ pipelinePath,
2798
+ [
2799
+ "image: node:20",
2800
+ "",
2801
+ "clone:",
2802
+ " depth: 50",
2803
+ "",
2804
+ "pipelines:",
2805
+ " pull-requests:",
2806
+ " '**':",
2807
+ FRONTGUARD_STEP_YAML,
2808
+ ""
2809
+ ].join("\n"),
2810
+ "utf8"
2521
2811
  );
2812
+ actions.push("created bitbucket-pipelines.yml with FrontGuard step");
2522
2813
  }
2523
- return line;
2814
+ return actions;
2524
2815
  }
2525
- function tryPngFileToBitbucketImageMarkdownLine(filePath) {
2816
+ async function fileExists(p2) {
2526
2817
  try {
2527
- if (!existsSync(filePath)) return null;
2528
- const st = statSync(filePath);
2529
- if (!st.isFile() || st.size > MAX_PNG_BYTES_BITBUCKET_INLINE) return null;
2530
- const buf = readFileSync(filePath);
2531
- return pngBufferToBitbucketImageMarkdownLine(buf);
2818
+ await fs.access(p2);
2819
+ return true;
2532
2820
  } catch {
2533
- return null;
2821
+ return false;
2534
2822
  }
2535
2823
  }
2536
2824
 
2537
2825
  // src/ci/bitbucket-pr-snippet.ts
2538
- function checksImageMarkdown() {
2539
- const embedPath = process.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH?.trim();
2540
- if (embedPath) {
2541
- return tryPngFileToBitbucketImageMarkdownLine(embedPath);
2542
- }
2543
- const u4 = process.env.FRONTGUARD_CHECKS_IMAGE_URL?.trim();
2544
- if (!u4) return null;
2545
- return `![${BITBUCKET_CHECKS_IMAGE_MARKDOWN_ALT}](${u4})`;
2546
- }
2547
- function bitbucketPipelineResultsUrl() {
2548
- const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
2549
- const bn = process.env.BITBUCKET_BUILD_NUMBER?.trim();
2550
- if (full && bn) {
2551
- return `https://bitbucket.org/${full}/pipelines/results/${bn}`;
2552
- }
2553
- const ws = process.env.BITBUCKET_WORKSPACE?.trim();
2554
- const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
2555
- if (ws && slug && bn) {
2556
- return `https://bitbucket.org/${ws}/${slug}/pipelines/results/${bn}`;
2826
+ var FRONTGUARD_REPORT_URL_PLACEHOLDER = "__FRONTGUARD_REPORT_URL__";
2827
+ var DETAILED_REPORT_LINE = "For detailed check analysis, please open the full interactive report:";
2828
+ function mdTableCell(s3) {
2829
+ return s3.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
2830
+ }
2831
+ function statusForPrTable(r4) {
2832
+ if (r4.skipped) {
2833
+ const t3 = r4.skipped.replace(/\s+/g, " ").trim();
2834
+ const short = t3.length > 120 ? `${t3.slice(0, 117)}\u2026` : t3;
2835
+ return `Skipped \u2014 ${short}`;
2557
2836
  }
2558
- return null;
2837
+ const n3 = r4.findings.length;
2838
+ const blocks = r4.findings.filter((f4) => f4.severity === "block").length;
2839
+ if (blocks > 0) return `${n3} issue(s), ${blocks} blocking`;
2840
+ return `${n3} issue(s)`;
2559
2841
  }
2560
- function bitbucketDownloadsPageUrl() {
2561
- const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
2562
- if (full) return `https://bitbucket.org/${full}/downloads/`;
2563
- const ws = process.env.BITBUCKET_WORKSPACE?.trim();
2564
- const slug = process.env.BITBUCKET_REPO_SLUG?.trim();
2565
- if (ws && slug) return `https://bitbucket.org/${ws}/${slug}/downloads/`;
2566
- return null;
2842
+ function checkNeedsRow(r4) {
2843
+ return Boolean(r4.skipped) || r4.findings.length > 0;
2567
2844
  }
2568
- var DETAILED_REPORT_LINE = "For detailed check analysis, please open the full interactive report:";
2569
2845
  function formatBitbucketPrSnippet(report) {
2570
2846
  const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
2571
2847
  const linkOnly = process.env.FRONTGUARD_BITBUCKET_COMMENT_LINK_ONLY === "1";
2572
- const imgMd = checksImageMarkdown();
2573
2848
  if (linkOnly && publicReport) {
2574
- const parts = [];
2575
- if (imgMd) {
2576
- parts.push(imgMd, "");
2577
- parts.push(DETAILED_REPORT_LINE);
2578
- }
2579
- parts.push(publicReport.endsWith("\n") ? publicReport.slice(0, -1) : publicReport);
2580
- return `${parts.join("\n")}
2849
+ const u4 = publicReport.endsWith("\n") ? publicReport.slice(0, -1) : publicReport;
2850
+ return `${u4}
2581
2851
  `;
2582
2852
  }
2583
- const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
2584
- const downloadsPage = bitbucketDownloadsPageUrl();
2585
- const pipeline = bitbucketPipelineResultsUrl();
2853
+ const reportUrl = publicReport || FRONTGUARD_REPORT_URL_PLACEHOLDER;
2586
2854
  const { riskScore, results } = report;
2587
- const blocks = results.reduce(
2588
- (n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "block").length,
2589
- 0
2590
- );
2591
- const warns = results.reduce(
2592
- (n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
2593
- 0
2594
- );
2595
- const out = [];
2596
- if (imgMd) {
2597
- out.push(imgMd);
2598
- out.push("");
2599
- }
2600
- out.push(
2601
- "FrontGuard report (short summary)",
2602
- "",
2603
- `Risk: ${riskScore} | Blocking: ${blocks} | Warnings: ${warns}`,
2604
- ""
2605
- );
2606
- if (publicReport) {
2607
- if (imgMd) {
2608
- out.push(DETAILED_REPORT_LINE);
2609
- } else {
2610
- out.push("Full interactive report (open in browser):");
2611
- }
2612
- out.push(publicReport);
2613
- out.push("");
2614
- } else if (downloadsName && downloadsPage) {
2615
- out.push("HTML report is in Repository \u2192 Downloads. Open this page while logged in:");
2616
- out.push(downloadsPage);
2617
- out.push(`File name: ${downloadsName}`);
2618
- out.push("Download the file, then open it in a browser.");
2619
- out.push("");
2620
- } else if (pipeline) {
2621
- out.push(
2622
- "There is no direct \u201CHTML URL\u201D for pipeline artifacts in Bitbucket. Use this pipeline run (log in), then Artifacts \u2192 frontguard-report.html:"
2623
- );
2624
- out.push(pipeline);
2625
- out.push("");
2626
- out.push(
2627
- "Steps: open the link \u2192 scroll to Artifacts \u2192 download frontguard-report.html \u2192 open the file on your machine."
2628
- );
2629
- out.push("");
2855
+ const lines = [];
2856
+ lines.push(`#### RISK score: ${riskScore}`);
2857
+ lines.push("");
2858
+ const failing = results.filter(checkNeedsRow);
2859
+ if (failing.length > 0) {
2860
+ lines.push("| Check name | Status |");
2861
+ lines.push("| --- | --- |");
2862
+ for (const r4 of failing) {
2863
+ lines.push(`| ${mdTableCell(r4.checkId)} | ${mdTableCell(statusForPrTable(r4))} |`);
2864
+ }
2630
2865
  } else {
2631
- out.push(
2632
- "Add a link: run FrontGuard inside Bitbucket Pipelines, or set FRONTGUARD_PUBLIC_REPORT_URL after uploading the HTML somewhere HTTPS."
2633
- );
2634
- out.push("");
2635
- }
2636
- out.push("Checks:");
2637
- for (const r4 of results) {
2638
- const status = r4.skipped ? `skipped (${r4.skipped.slice(0, 100)}${r4.skipped.length > 100 ? "\u2026" : ""})` : r4.findings.length === 0 ? "clean" : `${r4.findings.length} finding(s)`;
2639
- out.push(`- ${r4.checkId}: ${status}`);
2866
+ lines.push("_All checks passed for this run._");
2640
2867
  }
2641
- out.push("");
2642
- out.push(
2643
- "Do not paste the long frontguard-report.md into PR comments. Full text output is in that file / pipeline artifacts only."
2644
- );
2645
- return out.join("\n");
2868
+ lines.push("");
2869
+ lines.push(DETAILED_REPORT_LINE);
2870
+ lines.push("");
2871
+ lines.push(reportUrl.endsWith("\n") ? reportUrl.slice(0, -1) : reportUrl);
2872
+ lines.push("");
2873
+ return lines.join("\n");
2646
2874
  }
2647
2875
 
2648
2876
  // src/ci/parse-ai-disclosure.ts
@@ -2727,13 +2955,13 @@ function parseNumstat(output) {
2727
2955
  if (tab2 < 0) continue;
2728
2956
  const aStr = t3.slice(0, tab);
2729
2957
  const dStr = t3.slice(tab + 1, tab2);
2730
- const path18 = t3.slice(tab2 + 1);
2958
+ const path20 = t3.slice(tab2 + 1);
2731
2959
  const a3 = aStr === "-" ? 0 : Number(aStr);
2732
2960
  const d3 = dStr === "-" ? 0 : Number(dStr);
2733
2961
  if (!Number.isFinite(a3) || !Number.isFinite(d3)) continue;
2734
2962
  additions += a3;
2735
2963
  deletions += d3;
2736
- if (path18) files.push(path18);
2964
+ if (path20) files.push(path20);
2737
2965
  }
2738
2966
  return { additions, deletions, files };
2739
2967
  }
@@ -2853,7 +3081,7 @@ var defaultConfig = {
2853
3081
  gateWhenAiDisclosureAmbiguous: "warn"
2854
3082
  },
2855
3083
  aiAssistedReview: {
2856
- enabled: true,
3084
+ enabled: false,
2857
3085
  gate: "warn",
2858
3086
  strictScanMode: "both",
2859
3087
  escalate: {
@@ -2885,6 +3113,8 @@ var defaultConfig = {
2885
3113
  gate: "warn",
2886
3114
  runBuild: true,
2887
3115
  buildCommand: "npm run build",
3116
+ bundleSizeStrategy: "auto",
3117
+ bundleSizeCommand: null,
2888
3118
  measureGlobs: ["dist/**/*", "build/static/**/*", ".next/static/**/*"],
2889
3119
  baselinePath: ".frontguard/bundle-baseline.json",
2890
3120
  baselineRef: "main",
@@ -2944,7 +3174,7 @@ function stripExtends(c4) {
2944
3174
  }
2945
3175
  async function loadExtendsLayer(cwd, spec) {
2946
3176
  if (!spec) return {};
2947
- const req = createRequire(path5.join(cwd, "package.json"));
3177
+ const req = createRequire(path6.join(cwd, "package.json"));
2948
3178
  const specs = Array.isArray(spec) ? spec : [spec];
2949
3179
  let merged = {};
2950
3180
  for (const s3 of specs) {
@@ -2963,7 +3193,7 @@ async function loadExtendsLayer(cwd, spec) {
2963
3193
  async function loadConfig(cwd) {
2964
3194
  let userFile = null;
2965
3195
  for (const name of CONFIG_NAMES) {
2966
- const full = path5.join(cwd, name);
3196
+ const full = path6.join(cwd, name);
2967
3197
  if (!fs2.existsSync(full)) continue;
2968
3198
  try {
2969
3199
  const mod = await importConfig(full);
@@ -2995,7 +3225,7 @@ function hasDep(deps, name) {
2995
3225
  async function detectStack(cwd) {
2996
3226
  let pkg = {};
2997
3227
  try {
2998
- const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
3228
+ const raw = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
2999
3229
  pkg = JSON.parse(raw);
3000
3230
  } catch {
3001
3231
  return {
@@ -3007,6 +3237,8 @@ async function detectStack(cwd) {
3007
3237
  hasJest: false,
3008
3238
  hasVitest: false,
3009
3239
  hasPlaywright: false,
3240
+ hasVite: false,
3241
+ hasCRA: false,
3010
3242
  packageManager: "unknown",
3011
3243
  tsStrict: null
3012
3244
  };
@@ -3015,7 +3247,7 @@ async function detectStack(cwd) {
3015
3247
  const isMonorepo = Boolean(pkg.workspaces);
3016
3248
  let tsStrict = null;
3017
3249
  try {
3018
- const tsconfigPath = path5.join(cwd, "tsconfig.json");
3250
+ const tsconfigPath = path6.join(cwd, "tsconfig.json");
3019
3251
  const tsRaw = await fs.readFile(tsconfigPath, "utf8");
3020
3252
  const ts2 = JSON.parse(tsRaw);
3021
3253
  if (typeof ts2.compilerOptions?.strict === "boolean") {
@@ -3025,15 +3257,15 @@ async function detectStack(cwd) {
3025
3257
  }
3026
3258
  let pm = "unknown";
3027
3259
  try {
3028
- await fs.access(path5.join(cwd, "pnpm-lock.yaml"));
3260
+ await fs.access(path6.join(cwd, "pnpm-lock.yaml"));
3029
3261
  pm = "pnpm";
3030
3262
  } catch {
3031
3263
  try {
3032
- await fs.access(path5.join(cwd, "yarn.lock"));
3264
+ await fs.access(path6.join(cwd, "yarn.lock"));
3033
3265
  pm = "yarn";
3034
3266
  } catch {
3035
3267
  try {
3036
- await fs.access(path5.join(cwd, "package-lock.json"));
3268
+ await fs.access(path6.join(cwd, "package-lock.json"));
3037
3269
  pm = "npm";
3038
3270
  } catch {
3039
3271
  pm = "npm";
@@ -3049,6 +3281,8 @@ async function detectStack(cwd) {
3049
3281
  hasJest: hasDep(deps, "jest"),
3050
3282
  hasVitest: hasDep(deps, "vitest"),
3051
3283
  hasPlaywright: hasDep(deps, "@playwright/test"),
3284
+ hasVite: hasDep(deps, "vite"),
3285
+ hasCRA: hasDep(deps, "react-scripts"),
3052
3286
  packageManager: pm,
3053
3287
  tsStrict
3054
3288
  };
@@ -3058,11 +3292,62 @@ function formatStackOneLiner(s3) {
3058
3292
  if (s3.hasNext) bits.push("Next.js");
3059
3293
  if (s3.hasReactNative) bits.push("React Native");
3060
3294
  else if (s3.hasReact) bits.push("React");
3295
+ if (s3.hasVite) bits.push("Vite");
3296
+ if (s3.hasCRA) bits.push("CRA");
3061
3297
  if (s3.hasTypeScript) bits.push("TypeScript");
3062
3298
  if (s3.tsStrict === true) bits.push("strict TS");
3063
3299
  bits.push(`pkg: ${s3.packageManager}`);
3064
3300
  return bits.join(" \xB7 ") || "unknown";
3065
3301
  }
3302
+ var MAX_TOTAL_CHARS = 32e3;
3303
+ var MAX_PER_FILE_CHARS = 8e3;
3304
+ async function loadCursorRules(cwd) {
3305
+ const empty = { text: "", fileCount: 0, files: [] };
3306
+ const parts = [];
3307
+ let totalChars = 0;
3308
+ const ruleFiles = await fg(
3309
+ [".cursor/rules/**/*.{md,mdc}", ".cursorrules", "AGENTS.md"],
3310
+ {
3311
+ cwd,
3312
+ onlyFiles: true,
3313
+ dot: true,
3314
+ ignore: ["**/node_modules/**"]
3315
+ }
3316
+ );
3317
+ if (ruleFiles.length === 0) return empty;
3318
+ ruleFiles.sort();
3319
+ for (const rel of ruleFiles) {
3320
+ if (totalChars >= MAX_TOTAL_CHARS) break;
3321
+ try {
3322
+ let content = await fs.readFile(path6.join(cwd, rel), "utf8");
3323
+ content = stripFrontmatter(content).trim();
3324
+ if (!content) continue;
3325
+ const budget = Math.min(MAX_PER_FILE_CHARS, MAX_TOTAL_CHARS - totalChars);
3326
+ if (content.length > budget) {
3327
+ content = content.slice(0, budget) + "\n[\u2026 truncated]";
3328
+ }
3329
+ parts.push({ rel, content });
3330
+ totalChars += content.length;
3331
+ } catch {
3332
+ continue;
3333
+ }
3334
+ }
3335
+ if (parts.length === 0) return empty;
3336
+ const text = parts.map((p2) => `### Rule: ${p2.rel}
3337
+
3338
+ ${p2.content}`).join("\n\n---\n\n");
3339
+ return {
3340
+ text,
3341
+ fileCount: parts.length,
3342
+ files: parts.map((p2) => p2.rel)
3343
+ };
3344
+ }
3345
+ function stripFrontmatter(content) {
3346
+ if (!content.startsWith("---")) return content;
3347
+ const endIdx = content.indexOf("---", 3);
3348
+ if (endIdx === -1) return content;
3349
+ return content.slice(endIdx + 3).trim();
3350
+ }
3066
3351
  function normalizePrPath(p2) {
3067
3352
  return p2.replace(/\\/g, "/").replace(/^\.\//, "");
3068
3353
  }
@@ -3076,8 +3361,8 @@ function prPathSet(pr) {
3076
3361
  function isPathInPrScope(relOrAbs, cwd, prSet) {
3077
3362
  const raw = relOrAbs.replace(/^file:\/\//, "");
3078
3363
  let rel = normalizePrPath(raw);
3079
- if (path5.isAbsolute(raw)) {
3080
- rel = normalizePrPath(path5.relative(cwd, raw));
3364
+ if (path6.isAbsolute(raw)) {
3365
+ rel = normalizePrPath(path6.relative(cwd, raw));
3081
3366
  }
3082
3367
  if (prSet.has(rel)) return true;
3083
3368
  const trimmed = rel.replace(/^\.\//, "");
@@ -3103,7 +3388,7 @@ async function existingRepoPaths(cwd, rels) {
3103
3388
  const out = [];
3104
3389
  for (const rel of rels) {
3105
3390
  try {
3106
- await fs.access(path5.join(cwd, rel));
3391
+ await fs.access(path6.join(cwd, rel));
3107
3392
  out.push(rel);
3108
3393
  } catch {
3109
3394
  }
@@ -3133,30 +3418,30 @@ function stripFileUrl(p2) {
3133
3418
  return s3;
3134
3419
  }
3135
3420
  function isUnderDir(parent, child) {
3136
- const rel = path5.relative(parent, child);
3137
- return rel === "" || !rel.startsWith("..") && !path5.isAbsolute(rel);
3421
+ const rel = path6.relative(parent, child);
3422
+ return rel === "" || !rel.startsWith("..") && !path6.isAbsolute(rel);
3138
3423
  }
3139
3424
  function toRepoRelativePath(cwd, filePath) {
3140
3425
  if (!filePath?.trim()) return void 0;
3141
3426
  const raw = stripFileUrl(filePath);
3142
- const resolvedCwd = path5.resolve(cwd);
3143
- const absFile = path5.isAbsolute(raw) ? path5.resolve(raw) : path5.resolve(resolvedCwd, raw);
3427
+ const resolvedCwd = path6.resolve(cwd);
3428
+ const absFile = path6.isAbsolute(raw) ? path6.resolve(raw) : path6.resolve(resolvedCwd, raw);
3144
3429
  if (!isUnderDir(resolvedCwd, absFile)) {
3145
3430
  return raw.split(/[/\\]/g).join("/");
3146
3431
  }
3147
- let rel = path5.relative(resolvedCwd, absFile);
3432
+ let rel = path6.relative(resolvedCwd, absFile);
3148
3433
  if (!rel || rel === ".") {
3149
- return path5.basename(absFile);
3434
+ return path6.basename(absFile);
3150
3435
  }
3151
- return rel.split(path5.sep).join("/");
3436
+ return rel.split(path6.sep).join("/");
3152
3437
  }
3153
3438
  function stripRepoAbsolutePaths(cwd, text) {
3154
3439
  if (!text || !cwd.trim()) return text;
3155
- const resolvedCwd = path5.resolve(cwd);
3440
+ const resolvedCwd = path6.resolve(cwd);
3156
3441
  const asPosix = (s3) => s3.replace(/\\/g, "/");
3157
3442
  const cwdPosix = asPosix(resolvedCwd);
3158
3443
  let out = asPosix(text);
3159
- const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path5.sep])].filter(
3444
+ const prefixes = [.../* @__PURE__ */ new Set([cwdPosix + "/", resolvedCwd + path6.sep])].filter(
3160
3445
  (p2) => p2.length > 1
3161
3446
  );
3162
3447
  for (const prefix of prefixes) {
@@ -3782,7 +4067,7 @@ async function pathExists(file) {
3782
4067
  }
3783
4068
  }
3784
4069
  async function resolveBin(cwd, name) {
3785
- const local = path5.join(cwd, "node_modules", ".bin", name);
4070
+ const local = path6.join(cwd, "node_modules", ".bin", name);
3786
4071
  if (await pathExists(local)) return local;
3787
4072
  const win = local + ".cmd";
3788
4073
  if (await pathExists(win)) return win;
@@ -3838,7 +4123,7 @@ async function runNpx(cwd, args) {
3838
4123
  // src/checks/eslint.ts
3839
4124
  async function hasEslintDependency(cwd) {
3840
4125
  try {
3841
- const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
4126
+ const raw = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
3842
4127
  const p2 = JSON.parse(raw);
3843
4128
  return Boolean(p2.devDependencies?.eslint || p2.dependencies?.eslint);
3844
4129
  } catch {
@@ -3858,7 +4143,7 @@ async function hasEslintConfig(cwd) {
3858
4143
  ".eslintrc.yml"
3859
4144
  ];
3860
4145
  for (const c4 of candidates) {
3861
- if (await pathExists(path5.join(cwd, c4))) return true;
4146
+ if (await pathExists(path6.join(cwd, c4))) return true;
3862
4147
  }
3863
4148
  return false;
3864
4149
  }
@@ -4114,7 +4399,7 @@ async function runTypeScript(cwd, config, stack, pr) {
4114
4399
  skipped: "disabled in config"
4115
4400
  };
4116
4401
  }
4117
- const hasTs = stack.hasTypeScript || await pathExists(path5.join(cwd, "tsconfig.json"));
4402
+ const hasTs = stack.hasTypeScript || await pathExists(path6.join(cwd, "tsconfig.json"));
4118
4403
  if (!hasTs) {
4119
4404
  return {
4120
4405
  checkId: "typescript",
@@ -4125,7 +4410,7 @@ async function runTypeScript(cwd, config, stack, pr) {
4125
4410
  }
4126
4411
  const extra = config.checks.typescript.tscArgs ?? [];
4127
4412
  const hasProject = extra.some((a3) => a3 === "-p" || a3 === "--project");
4128
- const tsconfigPath = path5.join(cwd, "tsconfig.json");
4413
+ const tsconfigPath = path6.join(cwd, "tsconfig.json");
4129
4414
  const args = [
4130
4415
  "--noEmit",
4131
4416
  ...hasProject || !await pathExists(tsconfigPath) ? [] : ["-p", "tsconfig.json"],
@@ -4238,7 +4523,7 @@ async function runSecrets(cwd, config, pr) {
4238
4523
  });
4239
4524
  break;
4240
4525
  }
4241
- const full = path5.join(cwd, rel);
4526
+ const full = path6.join(cwd, rel);
4242
4527
  let content;
4243
4528
  try {
4244
4529
  content = await fs.readFile(full, "utf8");
@@ -4264,7 +4549,7 @@ async function runSecrets(cwd, config, pr) {
4264
4549
  };
4265
4550
  }
4266
4551
  function isProbablyTextFile(rel) {
4267
- const ext = path5.extname(rel).toLowerCase();
4552
+ const ext = path6.extname(rel).toLowerCase();
4268
4553
  return TEXT_EXT.has(ext);
4269
4554
  }
4270
4555
 
@@ -4328,8 +4613,8 @@ function runPrHygiene(config, pr) {
4328
4613
  if (pr.aiAssisted) {
4329
4614
  findings.push({
4330
4615
  id: "pr-ai-flag",
4331
- severity: "warn",
4332
- message: "**AI-assisted PR:** FrontGuard is applying stricter static checks and may escalate secrets / `any` deltas. Verify business logic, auth, and data handling manually."
4616
+ severity: "info",
4617
+ message: config.checks.aiAssistedReview.enabled ? "**AI-assisted PR:** FrontGuard is applying stricter static checks and may escalate secrets / `any` deltas. Verify business logic, auth, and data handling manually." : "**AI-assisted PR:** Disclosure recorded. The optional **AI-assisted review** check is off by default (under development); enable `checks.aiAssistedReview.enabled` when you want decorator / static heuristics."
4333
4618
  });
4334
4619
  }
4335
4620
  if (pr.aiExplicitNo) {
@@ -4594,12 +4879,12 @@ async function runCycles(cwd, config, stack, pr) {
4594
4879
  }
4595
4880
  let entry = cfg.entries[0] ?? "src";
4596
4881
  for (const e3 of cfg.entries) {
4597
- if (await pathExists(path5.join(cwd, e3))) {
4882
+ if (await pathExists(path6.join(cwd, e3))) {
4598
4883
  entry = e3;
4599
4884
  break;
4600
4885
  }
4601
4886
  }
4602
- if (!await pathExists(path5.join(cwd, entry))) {
4887
+ if (!await pathExists(path6.join(cwd, entry))) {
4603
4888
  return {
4604
4889
  checkId: "cycles",
4605
4890
  findings: [],
@@ -4712,11 +4997,62 @@ async function runDeadCode(cwd, config, stack, pr) {
4712
4997
  durationMs: Math.round(performance.now() - t0)
4713
4998
  };
4714
4999
  }
4715
- function gateSeverity4(g4) {
4716
- return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
5000
+ function resolveStrategy(configured, stack) {
5001
+ if (configured !== "auto") return configured;
5002
+ if (stack.hasNext) return "next";
5003
+ if (stack.hasCRA) return "cra";
5004
+ if (stack.hasVite) return "vite";
5005
+ return "glob";
5006
+ }
5007
+ function parseNextBuildOutput(stdout2) {
5008
+ for (const line of stdout2.split("\n")) {
5009
+ if (!line.includes("First Load JS shared by all")) continue;
5010
+ const m3 = /([\d.]+)\s*(kB|MB|B)\s*$/.exec(line.trim());
5011
+ if (!m3) continue;
5012
+ const num = parseFloat(m3[1]);
5013
+ const unit = m3[2];
5014
+ let bytes;
5015
+ if (unit === "kB") bytes = Math.round(num * 1024);
5016
+ else if (unit === "MB") bytes = Math.round(num * 1024 * 1024);
5017
+ else bytes = Math.round(num);
5018
+ return { bytes, label: `First Load JS shared by all: ${m3[1]} ${unit}` };
5019
+ }
5020
+ return null;
5021
+ }
5022
+ function parseCraBuildOutput(stdout2) {
5023
+ const lines = stdout2.split("\n");
5024
+ let inSection = false;
5025
+ let totalBytes = 0;
5026
+ let count = 0;
5027
+ for (const line of lines) {
5028
+ if (line.includes("File sizes after gzip")) {
5029
+ inSection = true;
5030
+ continue;
5031
+ }
5032
+ if (!inSection) continue;
5033
+ if (line.trim() === "") {
5034
+ if (count > 0) break;
5035
+ continue;
5036
+ }
5037
+ if (!line.includes(".js")) continue;
5038
+ const m3 = /([\d.]+)\s*(KB|kB|MB|B)/i.exec(line);
5039
+ if (!m3) continue;
5040
+ const num = parseFloat(m3[1]);
5041
+ const unit = m3[2].toLowerCase();
5042
+ if (unit === "kb") totalBytes += Math.round(num * 1024);
5043
+ else if (unit === "mb") totalBytes += Math.round(num * 1024 * 1024);
5044
+ else totalBytes += Math.round(num);
5045
+ count++;
5046
+ }
5047
+ if (count === 0) return null;
5048
+ return {
5049
+ bytes: totalBytes,
5050
+ label: `CRA gzipped JS total from ${count} file(s): ${(totalBytes / 1024).toFixed(1)} kB`
5051
+ };
4717
5052
  }
4718
5053
  async function sumGlobBytes(cwd, patterns) {
4719
5054
  let total = 0;
5055
+ let fileCount = 0;
4720
5056
  for (const pattern of patterns) {
4721
5057
  const files = await fg(pattern, {
4722
5058
  cwd,
@@ -4726,16 +5062,43 @@ async function sumGlobBytes(cwd, patterns) {
4726
5062
  });
4727
5063
  for (const rel of files) {
4728
5064
  try {
4729
- const st = await fs.stat(path5.join(cwd, rel));
5065
+ const st = await fs.stat(path6.join(cwd, rel));
4730
5066
  total += st.size;
5067
+ fileCount++;
4731
5068
  } catch {
4732
5069
  }
4733
5070
  }
4734
5071
  }
4735
- return total;
5072
+ return {
5073
+ bytes: total,
5074
+ label: `Glob sum: ${fileCount} file(s), ${(total / 1024).toFixed(1)} kB`
5075
+ };
5076
+ }
5077
+ async function measureViteBundle(cwd, measureGlobs) {
5078
+ const jsGlobs = measureGlobs.length > 0 ? measureGlobs : ["dist/assets/**/*.js"];
5079
+ return sumGlobBytes(cwd, jsGlobs);
5080
+ }
5081
+ async function runCustomSizeCommand(cwd, command) {
5082
+ const parts = command.trim().split(/\s+/).filter(Boolean);
5083
+ if (parts.length === 0) return null;
5084
+ const [bin, ...args] = parts;
5085
+ try {
5086
+ const r4 = await W2(bin, args, { nodeOptions: { cwd } });
5087
+ const out = (r4.stdout ?? "").trim();
5088
+ const num = parseInt(out, 10);
5089
+ if (!Number.isFinite(num) || num < 0) return null;
5090
+ return { bytes: num, label: `Custom command reported: ${(num / 1024).toFixed(1)} kB` };
5091
+ } catch {
5092
+ return null;
5093
+ }
5094
+ }
5095
+
5096
+ // src/checks/bundle.ts
5097
+ function gateSeverity4(g4) {
5098
+ return g4 === "block" ? "block" : g4 === "info" ? "info" : "warn";
4736
5099
  }
4737
5100
  async function readBaseline(cwd, relPath, baseRef) {
4738
- const disk = path5.join(cwd, relPath);
5101
+ const disk = path6.join(cwd, relPath);
4739
5102
  try {
4740
5103
  const raw = await fs.readFile(disk, "utf8");
4741
5104
  return JSON.parse(raw);
@@ -4779,7 +5142,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4779
5142
  if (!script) return { run: true };
4780
5143
  let scripts;
4781
5144
  try {
4782
- const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
5145
+ const raw = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
4783
5146
  const pkg = JSON.parse(raw);
4784
5147
  scripts = pkg.scripts;
4785
5148
  } catch {
@@ -4793,7 +5156,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4793
5156
  return {
4794
5157
  run: false,
4795
5158
  message: `Skipped bundle build \u2014 no scripts.${script} in package.json`,
4796
- detail: "The bundle check runs a production build, then sums file sizes under checks.bundle.measureGlobs (dist/, build/static/, .next/static/, etc.). Libraries and non-web repos often have no `build` script \u2014 set checks.bundle.runBuild to false and optionally tune measureGlobs, or set checks.bundle.buildCommand to whatever produces your artifacts (e.g. `pnpm run compile`, `npx vite build`)."
5159
+ detail: "The bundle check runs a production build, then measures the output. Libraries and non-web repos often have no `build` script \u2014 set checks.bundle.runBuild to false, or set checks.bundle.buildCommand to whatever produces your artifacts."
4797
5160
  };
4798
5161
  }
4799
5162
  return { run: true };
@@ -4817,7 +5180,9 @@ async function runBundle(cwd, config, stack) {
4817
5180
  skipped: "skipped for React Native (configure web artifacts if needed)"
4818
5181
  };
4819
5182
  }
5183
+ const strategy = resolveStrategy(cfg.bundleSizeStrategy, stack);
4820
5184
  const preFindings = [];
5185
+ let buildStdout = "";
4821
5186
  if (cfg.runBuild) {
4822
5187
  const parts = tokenizeCommand(cfg.buildCommand);
4823
5188
  if (parts.length === 0) {
@@ -4858,9 +5223,56 @@ async function runBundle(cwd, config, stack) {
4858
5223
  durationMs: Math.round(performance.now() - t0)
4859
5224
  };
4860
5225
  }
5226
+ buildStdout = (res.stdout ?? "") + "\n" + (res.stderr ?? "");
5227
+ }
5228
+ }
5229
+ let sizeResult = null;
5230
+ switch (strategy) {
5231
+ case "next": {
5232
+ sizeResult = parseNextBuildOutput(buildStdout);
5233
+ if (!sizeResult) {
5234
+ sizeResult = (await sumGlobBytes(cwd, [".next/static/**/*.js"])).bytes > 0 ? await sumGlobBytes(cwd, [".next/static/**/*.js"]) : null;
5235
+ }
5236
+ break;
5237
+ }
5238
+ case "cra": {
5239
+ sizeResult = parseCraBuildOutput(buildStdout);
5240
+ if (!sizeResult) {
5241
+ const g4 = await sumGlobBytes(cwd, ["build/static/js/**/*.js"]);
5242
+ if (g4.bytes > 0) sizeResult = g4;
5243
+ }
5244
+ break;
5245
+ }
5246
+ case "vite": {
5247
+ sizeResult = await measureViteBundle(cwd, cfg.measureGlobs);
5248
+ break;
5249
+ }
5250
+ case "custom": {
5251
+ if (!cfg.bundleSizeCommand) {
5252
+ return {
5253
+ checkId: "bundle",
5254
+ findings: [
5255
+ {
5256
+ id: "bundle-custom-missing",
5257
+ severity: "warn",
5258
+ message: "bundleSizeStrategy is `custom` but `bundleSizeCommand` is not set"
5259
+ }
5260
+ ],
5261
+ durationMs: Math.round(performance.now() - t0)
5262
+ };
5263
+ }
5264
+ sizeResult = await runCustomSizeCommand(cwd, cfg.bundleSizeCommand);
5265
+ break;
5266
+ }
5267
+ case "glob":
5268
+ default: {
5269
+ const g4 = await sumGlobBytes(cwd, cfg.measureGlobs);
5270
+ if (g4.bytes > 0) sizeResult = g4;
5271
+ break;
4861
5272
  }
4862
5273
  }
4863
- const total = await sumGlobBytes(cwd, cfg.measureGlobs);
5274
+ const total = sizeResult?.bytes ?? 0;
5275
+ const sizeLabel = sizeResult?.label ?? `(no bundle output detected for strategy "${strategy}")`;
4864
5276
  if (total === 0) {
4865
5277
  return {
4866
5278
  checkId: "bundle",
@@ -4869,7 +5281,9 @@ async function runBundle(cwd, config, stack) {
4869
5281
  {
4870
5282
  id: "bundle-empty",
4871
5283
  severity: "info",
4872
- message: "No bundle artifacts matched `measureGlobs` \u2014 configure paths or run a web build"
5284
+ message: `No bundle size detected (strategy: ${strategy})`,
5285
+ detail: `${sizeLabel}
5286
+ Ensure the build produces artifacts, or switch to a different bundleSizeStrategy.`
4873
5287
  }
4874
5288
  ],
4875
5289
  durationMs: Math.round(performance.now() - t0)
@@ -4879,7 +5293,9 @@ async function runBundle(cwd, config, stack) {
4879
5293
  const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
4880
5294
  const findings = [...preFindings];
4881
5295
  const infoLines = [
4882
- `Measured ${total} bytes (${(total / 1024 / 1024).toFixed(2)} MiB)`,
5296
+ `Strategy: ${strategy}`,
5297
+ sizeLabel,
5298
+ `Measured ${total} bytes (${(total / 1024).toFixed(1)} kB)`,
4883
5299
  baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
4884
5300
  ].join("\n");
4885
5301
  if (cfg.maxTotalBytes != null && total > cfg.maxTotalBytes) {
@@ -4955,7 +5371,7 @@ async function runCoreWebVitals(cwd, config, stack, pr) {
4955
5371
  const findings = [];
4956
5372
  const sev2 = gateSeverity5(cfg.gate);
4957
5373
  for (const rel of toScan.slice(0, 400)) {
4958
- const full = path5.join(cwd, rel);
5374
+ const full = path6.join(cwd, rel);
4959
5375
  let text;
4960
5376
  try {
4961
5377
  text = await fs.readFile(full, "utf8");
@@ -5040,7 +5456,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
5040
5456
  });
5041
5457
  break;
5042
5458
  }
5043
- const full = path5.join(cwd, rel);
5459
+ const full = path6.join(cwd, rel);
5044
5460
  let content;
5045
5461
  try {
5046
5462
  content = await fs.readFile(full, "utf8");
@@ -5485,7 +5901,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
5485
5901
  const byRel = /* @__PURE__ */ new Map();
5486
5902
  let anyDecoratorInPr = false;
5487
5903
  for (const rel of files) {
5488
- const full = path5.join(cwd, rel);
5904
+ const full = path6.join(cwd, rel);
5489
5905
  try {
5490
5906
  const content = await fs.readFile(full, "utf8");
5491
5907
  if (content.length > 5e5) continue;
@@ -5573,204 +5989,6 @@ function applyAiAssistedEscalation(results, pr, config) {
5573
5989
  }
5574
5990
  }
5575
5991
  }
5576
- var W3 = 920;
5577
- var PAD_X = 24;
5578
- var TABLE_X = 20;
5579
- var TABLE_W = 880;
5580
- var HEADER_H = 34;
5581
- var ROW_H = 34;
5582
- var COL_CHECK = 52;
5583
- var ICON_X = 268;
5584
- var COL_STATUS = 400;
5585
- var COL_NUM = 700;
5586
- var COL_TIME = 820;
5587
- var OVER_LABEL_W = 132;
5588
- var OVER_ROW_H = 36;
5589
- var clipSeq = 0;
5590
- function nextClipId() {
5591
- clipSeq += 1;
5592
- return `fgc${clipSeq}_${Math.random().toString(36).slice(2, 8)}`;
5593
- }
5594
- function escapeXml(s3) {
5595
- return s3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5596
- }
5597
- function truncate5(s3, max) {
5598
- const t3 = s3.replace(/\s+/g, " ").trim();
5599
- if (t3.length <= max) return t3;
5600
- return `${t3.slice(0, max - 1)}\u2026`;
5601
- }
5602
- function formatDuration(ms) {
5603
- if (ms < 1e3) return `${ms} ms`;
5604
- const s3 = Math.round(ms / 1e3);
5605
- if (s3 < 60) return `${s3}s`;
5606
- const m3 = Math.floor(s3 / 60);
5607
- const r4 = s3 % 60;
5608
- return r4 ? `${m3}m ${r4}s` : `${m3}m`;
5609
- }
5610
- function statusText(r4) {
5611
- if (r4.skipped) return `Skipped \u2014 ${truncate5(r4.skipped, 42)}`;
5612
- if (r4.findings.length === 0) return "Pass";
5613
- return `${r4.findings.length} issue(s)`;
5614
- }
5615
- function dotFill(r4) {
5616
- if (r4.skipped) return "#cbd5e1";
5617
- if (r4.findings.length === 0) return "#16a34a";
5618
- if (r4.findings.some((x3) => x3.severity === "block")) return "#dc2626";
5619
- return "#d97706";
5620
- }
5621
- function riskFill(risk) {
5622
- if (risk === "LOW") return "#16a34a";
5623
- if (risk === "MEDIUM") return "#d97706";
5624
- return "#dc2626";
5625
- }
5626
- function resvgFontOptions() {
5627
- const base = { loadSystemFonts: true };
5628
- if (process.platform === "linux") {
5629
- return {
5630
- ...base,
5631
- fontDirs: [
5632
- "/usr/share/fonts/truetype/dejavu",
5633
- "/usr/share/fonts/truetype/liberation",
5634
- "/usr/share/fonts/opentype/noto",
5635
- "/usr/share/fonts"
5636
- ]
5637
- };
5638
- }
5639
- return base;
5640
- }
5641
- function buildChecksSnapshotSvg(p2) {
5642
- const { riskScore, mode, stack, pr, results, lines } = p2;
5643
- const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5644
- const stackLine = truncate5(formatStackOneLiner(stack), 96);
5645
- const prLine = pr && lines != null ? truncate5(
5646
- `${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files`,
5647
- 96
5648
- ) : null;
5649
- const nOverview = 3 + (prLine ? 1 : 0);
5650
- const overviewH = nOverview * OVER_ROW_H;
5651
- const overviewY0 = 44;
5652
- const overviewClip = nextClipId();
5653
- const checksTitleY = overviewY0 + overviewH + 22;
5654
- const tableTop = checksTitleY + 22;
5655
- const bodyRows = results.length;
5656
- const cardH = HEADER_H + bodyRows * ROW_H;
5657
- const totalH = tableTop + cardH + 36;
5658
- const headerMid = tableTop + HEADER_H / 2 + 5;
5659
- const rowTextY = (i3) => tableTop + HEADER_H + i3 * ROW_H + ROW_H / 2 + 5;
5660
- const headerCells = [
5661
- { x: COL_CHECK, label: "CHECK", anchor: "start" },
5662
- { x: COL_STATUS, label: "STATUS", anchor: "start" },
5663
- { x: COL_NUM, label: "#", anchor: "end" },
5664
- { x: COL_TIME, label: "TIME", anchor: "end" }
5665
- ];
5666
- const overviewRows = [];
5667
- const ov = [
5668
- { label: "Risk score", kind: "risk" },
5669
- { label: "Mode", kind: "text", text: modeLabel },
5670
- { label: "Stack", kind: "text", text: stackLine }
5671
- ];
5672
- if (prLine) ov.push({ label: "Pull request", kind: "text", text: prLine });
5673
- for (let i3 = 0; i3 < nOverview; i3++) {
5674
- const row = ov[i3];
5675
- const y4 = overviewY0 + i3 * OVER_ROW_H;
5676
- const mid = y4 + OVER_ROW_H / 2 + 4;
5677
- overviewRows.push(
5678
- `<rect x="${TABLE_X}" y="${y4}" width="${OVER_LABEL_W}" height="${OVER_ROW_H}" fill="#f1f5f9" stroke="none"/>`,
5679
- `<line x1="${TABLE_X}" y1="${y4 + OVER_ROW_H}" x2="${TABLE_X + TABLE_W}" y2="${y4 + OVER_ROW_H}" stroke="#e2e8f0" stroke-width="1"/>`,
5680
- `<text x="${TABLE_X + 10}" y="${mid}" font-size="12" fill="#64748b" font-weight="500" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(row.label)}</text>`
5681
- );
5682
- if (row.kind === "risk") {
5683
- const rs = riskScore;
5684
- overviewRows.push(
5685
- `<text x="${TABLE_X + OVER_LABEL_W + 10}" y="${mid}" font-size="13" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif"><tspan fill="${riskFill(riskScore)}" font-weight="600">${escapeXml(rs)}</tspan><tspan fill="#64748b"> \u2014 heuristic</tspan></text>`
5686
- );
5687
- } else {
5688
- overviewRows.push(
5689
- `<text x="${TABLE_X + OVER_LABEL_W + 10}" y="${mid}" font-size="13" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(row.text)}</text>`
5690
- );
5691
- }
5692
- }
5693
- const rowLines = [];
5694
- for (let i3 = 0; i3 < bodyRows; i3++) {
5695
- const y4 = tableTop + HEADER_H + i3 * ROW_H;
5696
- const fill = i3 % 2 === 1 ? "#f8fafc" : "#ffffff";
5697
- rowLines.push(
5698
- `<rect x="${TABLE_X}" y="${y4}" width="${TABLE_W}" height="${ROW_H}" fill="${fill}" stroke="none"/>`
5699
- );
5700
- }
5701
- const bodyCells = [];
5702
- for (let i3 = 0; i3 < bodyRows; i3++) {
5703
- const r4 = results[i3];
5704
- const cy = rowTextY(i3);
5705
- bodyCells.push(
5706
- `<circle cx="34" cy="${cy - 4}" r="4.5" fill="${dotFill(r4)}"/>`,
5707
- `<text x="${COL_CHECK}" y="${cy}" font-size="13" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(truncate5(r4.checkId, 22))}</text>`,
5708
- `<circle cx="${ICON_X}" cy="${cy - 3}" r="8" fill="#f1f5f9" stroke="#e2e8f0" stroke-width="1"/>`,
5709
- `<text x="${ICON_X}" y="${cy}" font-size="9" font-weight="700" fill="#64748b" text-anchor="middle" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">i</text>`,
5710
- `<text x="${COL_STATUS}" y="${cy}" font-size="13" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">${escapeXml(statusText(r4))}</text>`,
5711
- `<text x="${COL_NUM}" y="${cy}" font-size="13" fill="#64748b" font-family="Liberation Mono, DejaVu Sans Mono, monospace" text-anchor="end">${escapeXml(r4.skipped ? "\u2014" : String(r4.findings.length))}</text>`,
5712
- `<text x="${COL_TIME}" y="${cy}" font-size="13" fill="#64748b" font-family="Liberation Mono, DejaVu Sans Mono, monospace" text-anchor="end">${escapeXml(formatDuration(r4.durationMs))}</text>`
5713
- );
5714
- }
5715
- const gridLines = [];
5716
- for (let i3 = 0; i3 <= bodyRows; i3++) {
5717
- const y4 = tableTop + HEADER_H + i3 * ROW_H;
5718
- gridLines.push(
5719
- `<line x1="${TABLE_X}" y1="${y4}" x2="${TABLE_X + TABLE_W}" y2="${y4}" stroke="#e2e8f0" stroke-width="1"/>`
5720
- );
5721
- }
5722
- const checksClip = nextClipId();
5723
- return `<?xml version="1.0" encoding="UTF-8"?>
5724
- <svg xmlns="http://www.w3.org/2000/svg" width="${W3}" height="${totalH}" viewBox="0 0 ${W3} ${totalH}">
5725
- <rect width="${W3}" height="${totalH}" fill="#f8fafc"/>
5726
- <text x="${PAD_X}" y="18" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.12em" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">FRONTGUARD</text>
5727
- <text x="${PAD_X}" y="36" font-size="16" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">Overview</text>
5728
- <defs>
5729
- <clipPath id="${overviewClip}">
5730
- <rect x="${TABLE_X}" y="${overviewY0}" width="${TABLE_W}" height="${overviewH}" rx="10" ry="10"/>
5731
- </clipPath>
5732
- <clipPath id="${checksClip}">
5733
- <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10"/>
5734
- </clipPath>
5735
- </defs>
5736
- <rect x="${TABLE_X}" y="${overviewY0}" width="${TABLE_W}" height="${overviewH}" rx="10" ry="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="1"/>
5737
- <g clip-path="url(#${overviewClip})">
5738
- ${overviewRows.join("\n ")}
5739
- </g>
5740
- <text x="${PAD_X}" y="${checksTitleY}" font-size="16" font-weight="600" fill="#0f172a" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif">Checks</text>
5741
- <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${cardH}" rx="10" ry="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="1"/>
5742
- <g clip-path="url(#${checksClip})">
5743
- <rect x="${TABLE_X}" y="${tableTop}" width="${TABLE_W}" height="${HEADER_H}" fill="#f1f5f9"/>
5744
- ${headerCells.map(
5745
- (c4) => `<text x="${c4.x}" y="${headerMid}" font-size="11" font-weight="600" fill="#64748b" letter-spacing="0.04em" font-family="DejaVu Sans, Liberation Sans, system-ui, sans-serif" text-anchor="${c4.anchor}">${c4.label}</text>`
5746
- ).join("\n ")}
5747
- ${rowLines.join("\n ")}
5748
- ${gridLines.join("\n ")}
5749
- ${bodyCells.join("\n ")}
5750
- </g>
5751
- </svg>`;
5752
- }
5753
- async function renderChecksSnapshotPng(pngPath, input) {
5754
- const svg = buildChecksSnapshotSvg(input);
5755
- const resvg = new Resvg(svg, {
5756
- background: "#f8fafc",
5757
- fitTo: { mode: "width", value: W3 },
5758
- font: resvgFontOptions()
5759
- });
5760
- const img = resvg.render();
5761
- const png = img.asPng();
5762
- await writeFile(pngPath, png);
5763
- const written = await readFile(pngPath);
5764
- if (!isPngBuffer(written) || written.length < 200) {
5765
- throw new Error(
5766
- "FrontGuard: checks PNG is missing or invalid after render. Install fonts on the runner (e.g. apt install fonts-dejavu-core fonts-liberation) and ensure @resvg/resvg-js native binary loads."
5767
- );
5768
- }
5769
- const { width, height } = img;
5770
- if (width < 16 || height < 16) {
5771
- throw new Error(`FrontGuard: checks PNG has invalid dimensions ${width}x${height}.`);
5772
- }
5773
- }
5774
5992
 
5775
5993
  // src/report/builder.ts
5776
5994
  var import_picocolors = __toESM(require_picocolors());
@@ -5788,9 +6006,9 @@ var CHECK_DESCRIPTIONS = {
5788
6006
  secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
5789
6007
  cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
5790
6008
  "dead-code": "Runs ts-prune to find unused exports in the TypeScript project. Helps trim dead surface area.",
5791
- bundle: "Measures total size of configured build artifacts (glob) and compares to a checked-in baseline. Flags large regressions in shipped JS/CSS.",
6009
+ bundle: "Measures bundle size using a stack-aware strategy (Next.js build output, Vite/CRA file sizes, or custom command) and compares to a checked-in baseline.",
5792
6010
  "core-web-vitals": "Static hints in JSX/TSX related to Core Web Vitals (e.g. LCP-friendly images, main-thread hygiene). Not a substitute for real field metrics.",
5793
- "ai-assisted-strict": "When the PR is AI-assisted or code is marked with @frontguard-ai decorators, scans those regions for risky patterns (eval, XSS sinks, etc.) and a few AST heuristics (e.g. discarded hot loops).",
6011
+ "ai-assisted-strict": "Under development \u2014 off by default. When enabled: scans @frontguard-ai regions or AI-disclosed PR files for risky patterns (eval, XSS sinks, etc.) and AST heuristics.",
5794
6012
  "pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
5795
6013
  "pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
5796
6014
  "ts-any-delta": "Diffs the branch against a base ref and counts newly added uses of the TypeScript any type. Helps stop gradual loss of type safety.",
@@ -5824,7 +6042,7 @@ function sortFindings(cwd, items) {
5824
6042
  return a3.f.message.localeCompare(b3.f.message);
5825
6043
  });
5826
6044
  }
5827
- function formatDuration2(ms) {
6045
+ function formatDuration(ms) {
5828
6046
  if (ms < 1e3) return `${ms} ms`;
5829
6047
  const s3 = Math.round(ms / 1e3);
5830
6048
  if (s3 < 60) return `${s3}s`;
@@ -5953,119 +6171,15 @@ var CHECKS_TABLE_STYLES = `
5953
6171
  .dot-block { background: var(--block); }
5954
6172
  .dot-skip { background: #cbd5e1; }
5955
6173
  `;
5956
- var SNAPSHOT_OVERVIEW_STYLES = `
5957
- .snapshot {
5958
- width: 100%;
5959
- border-collapse: collapse;
5960
- font-size: 0.9rem;
5961
- background: var(--surface);
5962
- border-radius: var(--radius);
5963
- overflow: hidden;
5964
- border: 1px solid var(--border);
5965
- box-shadow: var(--shadow);
5966
- margin: 0 0 1.25rem;
5967
- }
5968
- .snapshot th, .snapshot td {
5969
- padding: 0.65rem 1rem;
5970
- text-align: left;
5971
- border-bottom: 1px solid var(--border);
5972
- }
5973
- .snapshot tr:last-child th, .snapshot tr:last-child td { border-bottom: none; }
5974
- .snapshot th {
5975
- width: 9rem;
5976
- color: var(--muted);
5977
- font-weight: 500;
5978
- background: #f1f5f9;
5979
- }
5980
- .muted { color: var(--muted); }
5981
- `;
5982
6174
  function renderCheckTableRows(results) {
5983
6175
  return results.map((r4) => {
5984
6176
  const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5985
6177
  const help = escapeHtml(getCheckDescription(r4.checkId));
5986
6178
  const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
5987
6179
  const checkTitle = `<span class="check-title-cell"><strong class="check-name">${escapeHtml(r4.checkId)}</strong><span class="check-info-wrap"><button type="button" class="check-info" title="${help}" aria-label="${ariaWhat}">i</button><span class="check-tooltip" role="tooltip">${help}</span></span></span>`;
5988
- return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration2(r4.durationMs)}</td></tr>`;
6180
+ return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</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>`;
5989
6181
  }).join("\n");
5990
6182
  }
5991
- function buildChecksSnapshotHtml(p2) {
5992
- const { riskScore, mode, stack, pr, results, lines } = p2;
5993
- const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
5994
- const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
5995
- const checkRows = renderCheckTableRows(results);
5996
- const prRow = pr && lines != null ? `<tr><th>Pull request</th><td>${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
5997
- return `<!DOCTYPE html>
5998
- <html lang="en">
5999
- <head>
6000
- <meta charset="utf-8" />
6001
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6002
- <title>FrontGuard \u2014 Snapshot</title>
6003
- <style>
6004
- :root {
6005
- --bg: #f8fafc;
6006
- --surface: #ffffff;
6007
- --text: #0f172a;
6008
- --muted: #64748b;
6009
- --border: #e2e8f0;
6010
- --accent: #4f46e5;
6011
- --accent-soft: #eef2ff;
6012
- --block: #dc2626;
6013
- --warn: #d97706;
6014
- --info: #0284c7;
6015
- --ok: #16a34a;
6016
- --radius: 10px;
6017
- --shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
6018
- }
6019
- * { box-sizing: border-box; }
6020
- body {
6021
- margin: 0;
6022
- padding: 1.25rem 1.5rem 1.5rem;
6023
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
6024
- background: var(--bg);
6025
- color: var(--text);
6026
- line-height: 1.55;
6027
- font-size: 15px;
6028
- max-width: 920px;
6029
- }
6030
- .brand {
6031
- font-size: 0.75rem;
6032
- font-weight: 600;
6033
- letter-spacing: 0.12em;
6034
- text-transform: uppercase;
6035
- color: var(--muted);
6036
- margin-bottom: 0.35rem;
6037
- }
6038
- .h2 {
6039
- font-size: 1rem;
6040
- font-weight: 600;
6041
- margin: 0 0 0.85rem;
6042
- color: var(--text);
6043
- letter-spacing: -0.02em;
6044
- }
6045
- .risk-low { color: var(--ok); }
6046
- .risk-med { color: var(--warn); }
6047
- .risk-high { color: var(--block); }
6048
- ${SNAPSHOT_OVERVIEW_STYLES}
6049
- ${CHECKS_TABLE_STYLES}
6050
- </style>
6051
- </head>
6052
- <body>
6053
- <div class="brand">FrontGuard</div>
6054
- <h2 class="h2">Overview</h2>
6055
- <table class="snapshot">
6056
- <tr><th>Risk score</th><td><strong class="${riskClass}">${riskScore}</strong> <span class="muted">\u2014 heuristic</span></td></tr>
6057
- <tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
6058
- <tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
6059
- ${prRow}
6060
- </table>
6061
- <h2 class="h2">Checks</h2>
6062
- <table class="results">
6063
- <thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
6064
- <tbody>${checkRows}</tbody>
6065
- </table>
6066
- </body>
6067
- </html>`;
6068
- }
6069
6183
  function renderFindingCard(cwd, r4, f4) {
6070
6184
  const d3 = normalizeFinding(cwd, f4);
6071
6185
  const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
@@ -6369,6 +6483,7 @@ function buildHtmlReport(p2) {
6369
6483
  function buildReport(stack, pr, results, options) {
6370
6484
  const mode = options?.mode ?? "warn";
6371
6485
  const cwd = options?.cwd ?? process.cwd();
6486
+ const showAiAssistedBanner = options?.showAiAssistedBanner ?? false;
6372
6487
  const allFindings = results.flatMap(
6373
6488
  (r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
6374
6489
  );
@@ -6388,7 +6503,8 @@ function buildReport(stack, pr, results, options) {
6388
6503
  infos,
6389
6504
  blocks,
6390
6505
  lines,
6391
- llmAppendix: options?.llmAppendix ?? null
6506
+ llmAppendix: options?.llmAppendix ?? null,
6507
+ showAiAssistedBanner
6392
6508
  });
6393
6509
  const consoleText = formatConsole({
6394
6510
  riskScore,
@@ -6413,21 +6529,6 @@ function buildReport(stack, pr, results, options) {
6413
6529
  lines,
6414
6530
  llmAppendix: options?.llmAppendix ?? null
6415
6531
  }) : null;
6416
- const llmAppendix = options?.llmAppendix ?? null;
6417
- const checksSnapshotInput = options?.emitChecksSnapshot === true ? {
6418
- cwd,
6419
- riskScore,
6420
- mode,
6421
- stack,
6422
- pr,
6423
- results,
6424
- warns,
6425
- infos,
6426
- blocks,
6427
- lines,
6428
- llmAppendix
6429
- } : null;
6430
- const checksSnapshotHtml = checksSnapshotInput != null ? buildChecksSnapshotHtml(checksSnapshotInput) : null;
6431
6532
  return {
6432
6533
  riskScore,
6433
6534
  stack,
@@ -6435,9 +6536,7 @@ function buildReport(stack, pr, results, options) {
6435
6536
  results,
6436
6537
  markdown,
6437
6538
  consoleText,
6438
- html,
6439
- checksSnapshotHtml,
6440
- checksSnapshotInput
6539
+ html
6441
6540
  };
6442
6541
  }
6443
6542
  function scoreRisk(blocks, warns, lines, files) {
@@ -6486,7 +6585,7 @@ function countShieldColor(kind, n3) {
6486
6585
  if (n3 <= 10) return "yellow";
6487
6586
  return "orange";
6488
6587
  }
6489
- function formatDuration3(ms) {
6588
+ function formatDuration2(ms) {
6490
6589
  if (ms < 1e3) return `${ms} ms`;
6491
6590
  const s3 = Math.round(ms / 1e3);
6492
6591
  if (s3 < 60) return `${s3}s`;
@@ -6561,7 +6660,8 @@ function formatMarkdown(p2) {
6561
6660
  infos,
6562
6661
  blocks,
6563
6662
  lines,
6564
- llmAppendix
6663
+ llmAppendix,
6664
+ showAiAssistedBanner
6565
6665
  } = p2;
6566
6666
  const sb = [];
6567
6667
  const sortWithCwd = (items) => [...items].sort((a3, b3) => {
@@ -6602,7 +6702,7 @@ function formatMarkdown(p2) {
6602
6702
  );
6603
6703
  }
6604
6704
  sb.push("");
6605
- if (pr?.aiAssisted) {
6705
+ if (showAiAssistedBanner && pr?.aiAssisted) {
6606
6706
  sb.push(
6607
6707
  "> **\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."
6608
6708
  );
@@ -6641,7 +6741,7 @@ function formatMarkdown(p2) {
6641
6741
  }
6642
6742
  const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
6643
6743
  sb.push(
6644
- `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration3(r4.durationMs)} |`
6744
+ `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
6645
6745
  );
6646
6746
  }
6647
6747
  sb.push("");
@@ -6830,10 +6930,10 @@ async function callOllamaChat(opts) {
6830
6930
 
6831
6931
  // src/llm/finding-fixes.ts
6832
6932
  async function safeReadRepoFile(cwd, rel, maxChars) {
6833
- const root = path5.resolve(cwd);
6834
- const abs = path5.resolve(root, rel);
6835
- const relToRoot = path5.relative(root, abs);
6836
- if (relToRoot.startsWith("..") || path5.isAbsolute(relToRoot)) return null;
6933
+ const root = path6.resolve(cwd);
6934
+ const abs = path6.resolve(root, rel);
6935
+ const relToRoot = path6.relative(root, abs);
6936
+ if (relToRoot.startsWith("..") || path6.isAbsolute(relToRoot)) return null;
6837
6937
  try {
6838
6938
  let t3 = await fs.readFile(abs, "utf8");
6839
6939
  if (t3.length > maxChars) {
@@ -6855,14 +6955,14 @@ function parseFixResponse(raw) {
6855
6955
  return { summary: summary || raw.trim(), code };
6856
6956
  }
6857
6957
  async function enrichFindingsWithOllamaFixes(opts) {
6858
- const { cwd, config, stack, results } = opts;
6958
+ const { cwd, config, stack, results, cursorRules } = opts;
6859
6959
  const cfg = config.checks.llm;
6860
6960
  if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
6861
6961
  return results;
6862
6962
  }
6863
6963
  let pkgSnippet = "";
6864
6964
  try {
6865
- const pj = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
6965
+ const pj = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
6866
6966
  pkgSnippet = pj.slice(0, 4e3);
6867
6967
  } catch {
6868
6968
  pkgSnippet = "";
@@ -6885,7 +6985,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
6885
6985
  f4.file,
6886
6986
  cfg.maxFileContextChars
6887
6987
  );
6888
- const prompt2 = [
6988
+ const promptParts = [
6889
6989
  "You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
6890
6990
  "Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
6891
6991
  "If context is insufficient, say what is missing instead of guessing.",
@@ -6894,7 +6994,16 @@ async function enrichFindingsWithOllamaFixes(opts) {
6894
6994
  "### Why",
6895
6995
  "(1\u20133 short sentences: root cause and product/engineering risk.)",
6896
6996
  "### Fix",
6897
- "(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)",
6997
+ "(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)"
6998
+ ];
6999
+ if (cursorRules?.text) {
7000
+ promptParts.push(
7001
+ "",
7002
+ "The repo has coding rules/conventions. Ensure the fix follows them:",
7003
+ cursorRules.text.slice(0, 8e3)
7004
+ );
7005
+ }
7006
+ promptParts.push(
6898
7007
  "",
6899
7008
  `Repo stack: ${stackLabel}`,
6900
7009
  "",
@@ -6912,7 +7021,8 @@ async function enrichFindingsWithOllamaFixes(opts) {
6912
7021
  f4.file ? `file (repo-relative): ${f4.file}` : "",
6913
7022
  "",
6914
7023
  fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
6915
- ].filter(Boolean).join("\n");
7024
+ );
7025
+ const prompt2 = promptParts.filter(Boolean).join("\n");
6916
7026
  try {
6917
7027
  const raw = await callOllamaChat({
6918
7028
  baseUrl: cfg.ollamaUrl,
@@ -6946,7 +7056,7 @@ async function loadManualAppendix(opts) {
6946
7056
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
6947
7057
  const resolvedPath = filePath?.trim() || envFile;
6948
7058
  if (resolvedPath) {
6949
- const abs = path5.isAbsolute(resolvedPath) ? resolvedPath : path5.join(cwd, resolvedPath);
7059
+ const abs = path6.isAbsolute(resolvedPath) ? resolvedPath : path6.join(cwd, resolvedPath);
6950
7060
  try {
6951
7061
  let text = await fs.readFile(abs, "utf8");
6952
7062
  if (text.length > MAX_CHARS) {
@@ -6982,7 +7092,7 @@ function safeGetEnv(name) {
6982
7092
  return v3 && v3.trim() ? v3 : void 0;
6983
7093
  }
6984
7094
  async function runLlmReview(opts) {
6985
- const { cwd, config, pr, results } = opts;
7095
+ const { cwd, config, pr, results, cursorRules } = opts;
6986
7096
  const cfg = config.checks.llm;
6987
7097
  if (!cfg.enabled) return null;
6988
7098
  if (cfg.provider !== "ollama") {
@@ -7012,21 +7122,42 @@ async function runLlmReview(opts) {
7012
7122
  return "_LLM review skipped: empty diff vs base_";
7013
7123
  }
7014
7124
  const summaryLines = results.flatMap((r4) => r4.findings.map((f4) => `- [${f4.severity}] ${r4.checkId}: ${f4.message}`)).slice(0, 40).join("\n");
7015
- const prompt2 = [
7016
- "You are a senior frontend reviewer. Respond in Markdown with short sections:",
7125
+ const rulesBlock = cursorRules?.text ? [
7126
+ "",
7127
+ "The repository has the following coding rules and conventions that MUST be enforced during review.",
7128
+ "Flag any violations of these rules as specific findings:",
7129
+ "",
7130
+ cursorRules.text,
7131
+ ""
7132
+ ].join("\n") : "";
7133
+ const sections = [
7017
7134
  "### Summary",
7018
7135
  "### Risk hotspots (files / themes)",
7019
- "### Logic / correctness smells",
7020
- "### Tests & regressions",
7136
+ "### Logic / correctness smells"
7137
+ ];
7138
+ if (cursorRules?.text) sections.push("### Coding standards violations");
7139
+ sections.push("### Tests & regressions");
7140
+ const promptParts = [
7141
+ "You are a senior frontend reviewer. Respond in Markdown with short sections:",
7142
+ ...sections,
7021
7143
  "",
7022
7144
  "Constraints:",
7023
7145
  "- Be specific to this diff; avoid generic advice.",
7024
- "- If uncertain, say what you need to verify manually.",
7146
+ "- If uncertain, say what you need to verify manually."
7147
+ ];
7148
+ if (cursorRules?.text) {
7149
+ promptParts.push(
7150
+ `- The repo has ${cursorRules.fileCount} coding rule file(s). Check the diff against ALL rules below and report violations.`
7151
+ );
7152
+ }
7153
+ promptParts.push(
7025
7154
  "",
7026
7155
  pr ? `PR title: ${pr.title}
7027
7156
  PR body excerpt:
7028
- ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
7029
- "",
7157
+ ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run)."
7158
+ );
7159
+ if (rulesBlock) promptParts.push(rulesBlock);
7160
+ promptParts.push(
7030
7161
  "Existing automated findings (may be incomplete):",
7031
7162
  summaryLines || "(none)",
7032
7163
  "",
@@ -7034,7 +7165,8 @@ ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
7034
7165
  "```diff",
7035
7166
  diff,
7036
7167
  "```"
7037
- ].join("\n");
7168
+ );
7169
+ const prompt2 = promptParts.join("\n");
7038
7170
  if (cfg.provider === "ollama") {
7039
7171
  try {
7040
7172
  const text = await callOllamaChat({
@@ -7133,7 +7265,17 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
7133
7265
  async function runFrontGuard(opts) {
7134
7266
  const config = await loadConfig(opts.cwd);
7135
7267
  const mode = opts.enforce ? "enforce" : config.mode;
7136
- const stack = await detectStack(opts.cwd);
7268
+ const llmOn = config.checks.llm.enabled;
7269
+ const [stack, cursorRules] = await Promise.all([
7270
+ detectStack(opts.cwd),
7271
+ llmOn ? loadCursorRules(opts.cwd) : Promise.resolve({ text: "", fileCount: 0, files: [] })
7272
+ ]);
7273
+ if (llmOn && cursorRules.fileCount > 0) {
7274
+ g.stderr.write(
7275
+ `FrontGuard: loaded ${cursorRules.fileCount} cursor rule file(s): ${cursorRules.files.join(", ")}
7276
+ `
7277
+ );
7278
+ }
7137
7279
  const pr = readPrContext(opts.cwd);
7138
7280
  const restrictFiles = pr?.files?.length ? pr.files : null;
7139
7281
  const [
@@ -7182,7 +7324,8 @@ async function runFrontGuard(opts) {
7182
7324
  cwd: opts.cwd,
7183
7325
  config,
7184
7326
  stack,
7185
- results
7327
+ results,
7328
+ cursorRules
7186
7329
  });
7187
7330
  const manualAppendix = await loadManualAppendix({
7188
7331
  cwd: opts.cwd,
@@ -7192,7 +7335,8 @@ async function runFrontGuard(opts) {
7192
7335
  cwd: opts.cwd,
7193
7336
  config,
7194
7337
  pr,
7195
- results
7338
+ results,
7339
+ cursorRules
7196
7340
  });
7197
7341
  const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
7198
7342
  const report = buildReport(stack, pr, results, {
@@ -7200,49 +7344,20 @@ async function runFrontGuard(opts) {
7200
7344
  llmAppendix,
7201
7345
  cwd: opts.cwd,
7202
7346
  emitHtml: Boolean(opts.htmlOut),
7203
- emitChecksSnapshot: Boolean(opts.checksSnapshotOut || opts.checksPngOut)
7347
+ showAiAssistedBanner: config.checks.aiAssistedReview.enabled
7204
7348
  });
7205
7349
  if (opts.htmlOut && report.html) {
7206
7350
  await fs.writeFile(opts.htmlOut, report.html, "utf8");
7207
7351
  }
7208
- let embedPngPath = null;
7209
- if ((opts.checksSnapshotOut || opts.checksPngOut) && report.checksSnapshotHtml && report.checksSnapshotInput) {
7210
- if (opts.checksSnapshotOut) {
7211
- const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
7212
- await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
7213
- g.stderr.write(`
7214
- FrontGuard: wrote checks snapshot HTML to ${snapPath}
7215
- `);
7216
- }
7217
- if (opts.checksPngOut) {
7218
- const pngAbs = path5.isAbsolute(opts.checksPngOut) ? opts.checksPngOut : path5.join(opts.cwd, opts.checksPngOut);
7219
- await renderChecksSnapshotPng(pngAbs, report.checksSnapshotInput);
7220
- embedPngPath = pngAbs;
7221
- g.stderr.write(`FrontGuard: wrote checks PNG to ${pngAbs}.
7222
-
7223
- `);
7224
- } else if (opts.checksSnapshotOut) {
7225
- g.stderr.write(
7226
- ` Tip: add --checksPngOut checks.png to render the checks table to a PNG (no browser).
7227
-
7228
- `
7229
- );
7230
- }
7231
- }
7232
7352
  if (opts.prCommentOut) {
7233
- if (embedPngPath) {
7234
- g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH = embedPngPath;
7235
- }
7236
7353
  const snippet = formatBitbucketPrSnippet(report);
7237
- delete g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH;
7238
- const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
7354
+ const abs = path6.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path6.join(opts.cwd, opts.prCommentOut);
7239
7355
  await fs.writeFile(abs, snippet, "utf8");
7240
7356
  g.stderr.write(
7241
7357
  `
7242
- FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
7243
- Use ONLY this file in your POST \u2026/pullrequests/{id}/comments payload (content.raw).
7358
+ FrontGuard: wrote Bitbucket PR comment Markdown to ${abs} (${snippet.length} bytes).
7359
+ POST \u2026/pullrequests/{id}/comments with content.raw from this file (after replacing __FRONTGUARD_REPORT_URL__ if used).
7244
7360
  Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
7245
- With --checksPngOut, the PNG is inlined (small files only) or set FRONTGUARD_CHECKS_IMAGE_URL.
7246
7361
 
7247
7362
  `
7248
7363
  );
@@ -7261,25 +7376,29 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
7261
7376
  var init2 = defineCommand({
7262
7377
  meta: {
7263
7378
  name: "init",
7264
- description: "Add Bitbucket pipeline example, pull_request_template.md, and frontguard.config.js"
7379
+ description: "Set up FrontGuard: create config, PR template, and merge pipeline step into bitbucket-pipelines.yml"
7265
7380
  },
7266
7381
  run: async () => {
7267
- const cwd = g.cwd();
7268
- await initFrontGuard(cwd);
7382
+ const actions = await initFrontGuard(g.cwd());
7383
+ g.stdout.write("\nFrontGuard init complete:\n");
7384
+ for (const a3 of actions) {
7385
+ g.stdout.write(` \u2022 ${a3}
7386
+ `);
7387
+ }
7269
7388
  g.stdout.write(
7270
- "FrontGuard initialized for Bitbucket.\n\n \u2022 bitbucket-pipelines.frontguard.example.yml \u2014 merge into your pipeline\n \u2022 pull_request_template.md \u2014 PR description template (Bitbucket Cloud)\n \u2022 frontguard.config.js (if missing)\n\nAdd the package as a devDependency so CI matches local runs:\n npm install -D @cleartrip/frontguard\n yarn add -D @cleartrip/frontguard\n"
7389
+ "\nNext steps:\n 1. Review frontguard.config.js \u2014 uncomment and tune the checks you need\n 2. Review bitbucket-pipelines.yml \u2014 check the merged FrontGuard step\n 3. Set BITBUCKET_ACCESS_TOKEN as a secured variable in your repo\n 4. Install the package: yarn add -D @cleartrip/frontguard\n\n"
7271
7390
  );
7272
7391
  }
7273
7392
  });
7274
7393
  var run = defineCommand({
7275
7394
  meta: {
7276
7395
  name: "run",
7277
- description: "Run checks and print the review brief"
7396
+ description: "Run all checks and print the review brief"
7278
7397
  },
7279
7398
  args: {
7280
7399
  markdown: {
7281
7400
  type: "boolean",
7282
- description: "Print markdown only",
7401
+ description: "Print markdown only (no console box)",
7283
7402
  default: false
7284
7403
  },
7285
7404
  enforce: {
@@ -7289,23 +7408,15 @@ var run = defineCommand({
7289
7408
  },
7290
7409
  append: {
7291
7410
  type: "string",
7292
- description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
7411
+ description: "Append markdown from a file (e.g. IDE/ChatGPT paste)"
7293
7412
  },
7294
7413
  htmlOut: {
7295
7414
  type: "string",
7296
- description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
7297
- },
7298
- checksSnapshotOut: {
7299
- type: "string",
7300
- description: "Write HTML with only the Checks table (for screenshots / PR comments)"
7301
- },
7302
- checksPngOut: {
7303
- type: "string",
7304
- description: "Write PNG of the checks table (SVG raster; @resvg/resvg-js, no browser). Pair with --checksSnapshotOut or use alone"
7415
+ description: "Write interactive HTML report to this path"
7305
7416
  },
7306
7417
  prCommentOut: {
7307
7418
  type: "string",
7308
- description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
7419
+ description: "Write Bitbucket PR comment Markdown to this path"
7309
7420
  }
7310
7421
  },
7311
7422
  run: async ({ args }) => {
@@ -7315,8 +7426,6 @@ var run = defineCommand({
7315
7426
  enforce: Boolean(args.enforce),
7316
7427
  append: typeof args.append === "string" ? args.append : null,
7317
7428
  htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
7318
- checksSnapshotOut: typeof args.checksSnapshotOut === "string" ? args.checksSnapshotOut : null,
7319
- checksPngOut: typeof args.checksPngOut === "string" ? args.checksPngOut : null,
7320
7429
  prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
7321
7430
  });
7322
7431
  }