@cleartrip/frontguard 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,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);
@@ -4767,11 +5130,12 @@ function tokenizeCommand(cmd) {
4767
5130
  }
4768
5131
  function npmScriptFromBuildCommand(cmd) {
4769
5132
  const t3 = cmd.trim();
4770
- if (/^yarn\s+build\b/i.test(t3)) return "build";
4771
5133
  const np = /^(?:npm|pnpm)\s+run\s+(\S+)/i.exec(t3);
4772
5134
  if (np?.[1]) return np[1];
4773
5135
  const yr = /^yarn\s+run\s+(\S+)/i.exec(t3);
4774
5136
  if (yr?.[1]) return yr[1];
5137
+ const yPlain = /^yarn\s+(?!run\b)(\S+)/i.exec(t3);
5138
+ if (yPlain?.[1]) return yPlain[1];
4775
5139
  return null;
4776
5140
  }
4777
5141
  async function bundleBuildPrecheck(cwd, buildCommand) {
@@ -4779,7 +5143,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4779
5143
  if (!script) return { run: true };
4780
5144
  let scripts;
4781
5145
  try {
4782
- const raw = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
5146
+ const raw = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
4783
5147
  const pkg = JSON.parse(raw);
4784
5148
  scripts = pkg.scripts;
4785
5149
  } catch {
@@ -4793,7 +5157,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
4793
5157
  return {
4794
5158
  run: false,
4795
5159
  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`)."
5160
+ 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
5161
  };
4798
5162
  }
4799
5163
  return { run: true };
@@ -4817,7 +5181,9 @@ async function runBundle(cwd, config, stack) {
4817
5181
  skipped: "skipped for React Native (configure web artifacts if needed)"
4818
5182
  };
4819
5183
  }
5184
+ const strategy = resolveStrategy(cfg.bundleSizeStrategy, stack);
4820
5185
  const preFindings = [];
5186
+ let buildStdout = "";
4821
5187
  if (cfg.runBuild) {
4822
5188
  const parts = tokenizeCommand(cfg.buildCommand);
4823
5189
  if (parts.length === 0) {
@@ -4858,9 +5224,56 @@ async function runBundle(cwd, config, stack) {
4858
5224
  durationMs: Math.round(performance.now() - t0)
4859
5225
  };
4860
5226
  }
5227
+ buildStdout = (res.stdout ?? "") + "\n" + (res.stderr ?? "");
5228
+ }
5229
+ }
5230
+ let sizeResult = null;
5231
+ switch (strategy) {
5232
+ case "next": {
5233
+ sizeResult = parseNextBuildOutput(buildStdout);
5234
+ if (!sizeResult) {
5235
+ sizeResult = (await sumGlobBytes(cwd, [".next/static/**/*.js"])).bytes > 0 ? await sumGlobBytes(cwd, [".next/static/**/*.js"]) : null;
5236
+ }
5237
+ break;
5238
+ }
5239
+ case "cra": {
5240
+ sizeResult = parseCraBuildOutput(buildStdout);
5241
+ if (!sizeResult) {
5242
+ const g4 = await sumGlobBytes(cwd, ["build/static/js/**/*.js"]);
5243
+ if (g4.bytes > 0) sizeResult = g4;
5244
+ }
5245
+ break;
5246
+ }
5247
+ case "vite": {
5248
+ sizeResult = await measureViteBundle(cwd, cfg.measureGlobs);
5249
+ break;
5250
+ }
5251
+ case "custom": {
5252
+ if (!cfg.bundleSizeCommand) {
5253
+ return {
5254
+ checkId: "bundle",
5255
+ findings: [
5256
+ {
5257
+ id: "bundle-custom-missing",
5258
+ severity: "warn",
5259
+ message: "bundleSizeStrategy is `custom` but `bundleSizeCommand` is not set"
5260
+ }
5261
+ ],
5262
+ durationMs: Math.round(performance.now() - t0)
5263
+ };
5264
+ }
5265
+ sizeResult = await runCustomSizeCommand(cwd, cfg.bundleSizeCommand);
5266
+ break;
5267
+ }
5268
+ case "glob":
5269
+ default: {
5270
+ const g4 = await sumGlobBytes(cwd, cfg.measureGlobs);
5271
+ if (g4.bytes > 0) sizeResult = g4;
5272
+ break;
4861
5273
  }
4862
5274
  }
4863
- const total = await sumGlobBytes(cwd, cfg.measureGlobs);
5275
+ const total = sizeResult?.bytes ?? 0;
5276
+ const sizeLabel = sizeResult?.label ?? `(no bundle output detected for strategy "${strategy}")`;
4864
5277
  if (total === 0) {
4865
5278
  return {
4866
5279
  checkId: "bundle",
@@ -4869,7 +5282,9 @@ async function runBundle(cwd, config, stack) {
4869
5282
  {
4870
5283
  id: "bundle-empty",
4871
5284
  severity: "info",
4872
- message: "No bundle artifacts matched `measureGlobs` \u2014 configure paths or run a web build"
5285
+ message: `No bundle size detected (strategy: ${strategy})`,
5286
+ detail: `${sizeLabel}
5287
+ Ensure the build produces artifacts, or switch to a different bundleSizeStrategy.`
4873
5288
  }
4874
5289
  ],
4875
5290
  durationMs: Math.round(performance.now() - t0)
@@ -4879,7 +5294,9 @@ async function runBundle(cwd, config, stack) {
4879
5294
  const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
4880
5295
  const findings = [...preFindings];
4881
5296
  const infoLines = [
4882
- `Measured ${total} bytes (${(total / 1024 / 1024).toFixed(2)} MiB)`,
5297
+ `Strategy: ${strategy}`,
5298
+ sizeLabel,
5299
+ `Measured ${total} bytes (${(total / 1024).toFixed(1)} kB)`,
4883
5300
  baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
4884
5301
  ].join("\n");
4885
5302
  if (cfg.maxTotalBytes != null && total > cfg.maxTotalBytes) {
@@ -4955,7 +5372,7 @@ async function runCoreWebVitals(cwd, config, stack, pr) {
4955
5372
  const findings = [];
4956
5373
  const sev2 = gateSeverity5(cfg.gate);
4957
5374
  for (const rel of toScan.slice(0, 400)) {
4958
- const full = path5.join(cwd, rel);
5375
+ const full = path6.join(cwd, rel);
4959
5376
  let text;
4960
5377
  try {
4961
5378
  text = await fs.readFile(full, "utf8");
@@ -5040,7 +5457,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
5040
5457
  });
5041
5458
  break;
5042
5459
  }
5043
- const full = path5.join(cwd, rel);
5460
+ const full = path6.join(cwd, rel);
5044
5461
  let content;
5045
5462
  try {
5046
5463
  content = await fs.readFile(full, "utf8");
@@ -5485,7 +5902,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
5485
5902
  const byRel = /* @__PURE__ */ new Map();
5486
5903
  let anyDecoratorInPr = false;
5487
5904
  for (const rel of files) {
5488
- const full = path5.join(cwd, rel);
5905
+ const full = path6.join(cwd, rel);
5489
5906
  try {
5490
5907
  const content = await fs.readFile(full, "utf8");
5491
5908
  if (content.length > 5e5) continue;
@@ -5573,204 +5990,6 @@ function applyAiAssistedEscalation(results, pr, config) {
5573
5990
  }
5574
5991
  }
5575
5992
  }
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
5993
 
5775
5994
  // src/report/builder.ts
5776
5995
  var import_picocolors = __toESM(require_picocolors());
@@ -5788,9 +6007,9 @@ var CHECK_DESCRIPTIONS = {
5788
6007
  secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
5789
6008
  cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
5790
6009
  "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.",
6010
+ 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
6011
  "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).",
6012
+ "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
6013
  "pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
5795
6014
  "pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
5796
6015
  "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 +6043,7 @@ function sortFindings(cwd, items) {
5824
6043
  return a3.f.message.localeCompare(b3.f.message);
5825
6044
  });
5826
6045
  }
5827
- function formatDuration2(ms) {
6046
+ function formatDuration(ms) {
5828
6047
  if (ms < 1e3) return `${ms} ms`;
5829
6048
  const s3 = Math.round(ms / 1e3);
5830
6049
  if (s3 < 60) return `${s3}s`;
@@ -5953,119 +6172,15 @@ var CHECKS_TABLE_STYLES = `
5953
6172
  .dot-block { background: var(--block); }
5954
6173
  .dot-skip { background: #cbd5e1; }
5955
6174
  `;
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
6175
  function renderCheckTableRows(results) {
5983
6176
  return results.map((r4) => {
5984
6177
  const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
5985
6178
  const help = escapeHtml(getCheckDescription(r4.checkId));
5986
6179
  const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
5987
6180
  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>`;
6181
+ 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
6182
  }).join("\n");
5990
6183
  }
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
6184
  function renderFindingCard(cwd, r4, f4) {
6070
6185
  const d3 = normalizeFinding(cwd, f4);
6071
6186
  const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
@@ -6369,6 +6484,7 @@ function buildHtmlReport(p2) {
6369
6484
  function buildReport(stack, pr, results, options) {
6370
6485
  const mode = options?.mode ?? "warn";
6371
6486
  const cwd = options?.cwd ?? process.cwd();
6487
+ const showAiAssistedBanner = options?.showAiAssistedBanner ?? false;
6372
6488
  const allFindings = results.flatMap(
6373
6489
  (r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
6374
6490
  );
@@ -6388,7 +6504,8 @@ function buildReport(stack, pr, results, options) {
6388
6504
  infos,
6389
6505
  blocks,
6390
6506
  lines,
6391
- llmAppendix: options?.llmAppendix ?? null
6507
+ llmAppendix: options?.llmAppendix ?? null,
6508
+ showAiAssistedBanner
6392
6509
  });
6393
6510
  const consoleText = formatConsole({
6394
6511
  riskScore,
@@ -6413,21 +6530,6 @@ function buildReport(stack, pr, results, options) {
6413
6530
  lines,
6414
6531
  llmAppendix: options?.llmAppendix ?? null
6415
6532
  }) : 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
6533
  return {
6432
6534
  riskScore,
6433
6535
  stack,
@@ -6435,9 +6537,7 @@ function buildReport(stack, pr, results, options) {
6435
6537
  results,
6436
6538
  markdown,
6437
6539
  consoleText,
6438
- html,
6439
- checksSnapshotHtml,
6440
- checksSnapshotInput
6540
+ html
6441
6541
  };
6442
6542
  }
6443
6543
  function scoreRisk(blocks, warns, lines, files) {
@@ -6486,7 +6586,7 @@ function countShieldColor(kind, n3) {
6486
6586
  if (n3 <= 10) return "yellow";
6487
6587
  return "orange";
6488
6588
  }
6489
- function formatDuration3(ms) {
6589
+ function formatDuration2(ms) {
6490
6590
  if (ms < 1e3) return `${ms} ms`;
6491
6591
  const s3 = Math.round(ms / 1e3);
6492
6592
  if (s3 < 60) return `${s3}s`;
@@ -6561,7 +6661,8 @@ function formatMarkdown(p2) {
6561
6661
  infos,
6562
6662
  blocks,
6563
6663
  lines,
6564
- llmAppendix
6664
+ llmAppendix,
6665
+ showAiAssistedBanner
6565
6666
  } = p2;
6566
6667
  const sb = [];
6567
6668
  const sortWithCwd = (items) => [...items].sort((a3, b3) => {
@@ -6602,7 +6703,7 @@ function formatMarkdown(p2) {
6602
6703
  );
6603
6704
  }
6604
6705
  sb.push("");
6605
- if (pr?.aiAssisted) {
6706
+ if (showAiAssistedBanner && pr?.aiAssisted) {
6606
6707
  sb.push(
6607
6708
  "> **\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
6709
  );
@@ -6641,7 +6742,7 @@ function formatMarkdown(p2) {
6641
6742
  }
6642
6743
  const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
6643
6744
  sb.push(
6644
- `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration3(r4.durationMs)} |`
6745
+ `| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
6645
6746
  );
6646
6747
  }
6647
6748
  sb.push("");
@@ -6830,10 +6931,10 @@ async function callOllamaChat(opts) {
6830
6931
 
6831
6932
  // src/llm/finding-fixes.ts
6832
6933
  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;
6934
+ const root = path6.resolve(cwd);
6935
+ const abs = path6.resolve(root, rel);
6936
+ const relToRoot = path6.relative(root, abs);
6937
+ if (relToRoot.startsWith("..") || path6.isAbsolute(relToRoot)) return null;
6837
6938
  try {
6838
6939
  let t3 = await fs.readFile(abs, "utf8");
6839
6940
  if (t3.length > maxChars) {
@@ -6855,14 +6956,14 @@ function parseFixResponse(raw) {
6855
6956
  return { summary: summary || raw.trim(), code };
6856
6957
  }
6857
6958
  async function enrichFindingsWithOllamaFixes(opts) {
6858
- const { cwd, config, stack, results } = opts;
6959
+ const { cwd, config, stack, results, cursorRules } = opts;
6859
6960
  const cfg = config.checks.llm;
6860
6961
  if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
6861
6962
  return results;
6862
6963
  }
6863
6964
  let pkgSnippet = "";
6864
6965
  try {
6865
- const pj = await fs.readFile(path5.join(cwd, "package.json"), "utf8");
6966
+ const pj = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
6866
6967
  pkgSnippet = pj.slice(0, 4e3);
6867
6968
  } catch {
6868
6969
  pkgSnippet = "";
@@ -6885,7 +6986,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
6885
6986
  f4.file,
6886
6987
  cfg.maxFileContextChars
6887
6988
  );
6888
- const prompt2 = [
6989
+ const promptParts = [
6889
6990
  "You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
6890
6991
  "Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
6891
6992
  "If context is insufficient, say what is missing instead of guessing.",
@@ -6894,7 +6995,16 @@ async function enrichFindingsWithOllamaFixes(opts) {
6894
6995
  "### Why",
6895
6996
  "(1\u20133 short sentences: root cause and product/engineering risk.)",
6896
6997
  "### Fix",
6897
- "(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)",
6998
+ "(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)"
6999
+ ];
7000
+ if (cursorRules?.text) {
7001
+ promptParts.push(
7002
+ "",
7003
+ "The repo has coding rules/conventions. Ensure the fix follows them:",
7004
+ cursorRules.text.slice(0, 8e3)
7005
+ );
7006
+ }
7007
+ promptParts.push(
6898
7008
  "",
6899
7009
  `Repo stack: ${stackLabel}`,
6900
7010
  "",
@@ -6912,7 +7022,8 @@ async function enrichFindingsWithOllamaFixes(opts) {
6912
7022
  f4.file ? `file (repo-relative): ${f4.file}` : "",
6913
7023
  "",
6914
7024
  fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
6915
- ].filter(Boolean).join("\n");
7025
+ );
7026
+ const prompt2 = promptParts.filter(Boolean).join("\n");
6916
7027
  try {
6917
7028
  const raw = await callOllamaChat({
6918
7029
  baseUrl: cfg.ollamaUrl,
@@ -6946,7 +7057,7 @@ async function loadManualAppendix(opts) {
6946
7057
  const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
6947
7058
  const resolvedPath = filePath?.trim() || envFile;
6948
7059
  if (resolvedPath) {
6949
- const abs = path5.isAbsolute(resolvedPath) ? resolvedPath : path5.join(cwd, resolvedPath);
7060
+ const abs = path6.isAbsolute(resolvedPath) ? resolvedPath : path6.join(cwd, resolvedPath);
6950
7061
  try {
6951
7062
  let text = await fs.readFile(abs, "utf8");
6952
7063
  if (text.length > MAX_CHARS) {
@@ -6982,7 +7093,7 @@ function safeGetEnv(name) {
6982
7093
  return v3 && v3.trim() ? v3 : void 0;
6983
7094
  }
6984
7095
  async function runLlmReview(opts) {
6985
- const { cwd, config, pr, results } = opts;
7096
+ const { cwd, config, pr, results, cursorRules } = opts;
6986
7097
  const cfg = config.checks.llm;
6987
7098
  if (!cfg.enabled) return null;
6988
7099
  if (cfg.provider !== "ollama") {
@@ -7012,21 +7123,42 @@ async function runLlmReview(opts) {
7012
7123
  return "_LLM review skipped: empty diff vs base_";
7013
7124
  }
7014
7125
  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:",
7126
+ const rulesBlock = cursorRules?.text ? [
7127
+ "",
7128
+ "The repository has the following coding rules and conventions that MUST be enforced during review.",
7129
+ "Flag any violations of these rules as specific findings:",
7130
+ "",
7131
+ cursorRules.text,
7132
+ ""
7133
+ ].join("\n") : "";
7134
+ const sections = [
7017
7135
  "### Summary",
7018
7136
  "### Risk hotspots (files / themes)",
7019
- "### Logic / correctness smells",
7020
- "### Tests & regressions",
7137
+ "### Logic / correctness smells"
7138
+ ];
7139
+ if (cursorRules?.text) sections.push("### Coding standards violations");
7140
+ sections.push("### Tests & regressions");
7141
+ const promptParts = [
7142
+ "You are a senior frontend reviewer. Respond in Markdown with short sections:",
7143
+ ...sections,
7021
7144
  "",
7022
7145
  "Constraints:",
7023
7146
  "- Be specific to this diff; avoid generic advice.",
7024
- "- If uncertain, say what you need to verify manually.",
7147
+ "- If uncertain, say what you need to verify manually."
7148
+ ];
7149
+ if (cursorRules?.text) {
7150
+ promptParts.push(
7151
+ `- The repo has ${cursorRules.fileCount} coding rule file(s). Check the diff against ALL rules below and report violations.`
7152
+ );
7153
+ }
7154
+ promptParts.push(
7025
7155
  "",
7026
7156
  pr ? `PR title: ${pr.title}
7027
7157
  PR body excerpt:
7028
- ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
7029
- "",
7158
+ ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run)."
7159
+ );
7160
+ if (rulesBlock) promptParts.push(rulesBlock);
7161
+ promptParts.push(
7030
7162
  "Existing automated findings (may be incomplete):",
7031
7163
  summaryLines || "(none)",
7032
7164
  "",
@@ -7034,7 +7166,8 @@ ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
7034
7166
  "```diff",
7035
7167
  diff,
7036
7168
  "```"
7037
- ].join("\n");
7169
+ );
7170
+ const prompt2 = promptParts.join("\n");
7038
7171
  if (cfg.provider === "ollama") {
7039
7172
  try {
7040
7173
  const text = await callOllamaChat({
@@ -7133,7 +7266,17 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
7133
7266
  async function runFrontGuard(opts) {
7134
7267
  const config = await loadConfig(opts.cwd);
7135
7268
  const mode = opts.enforce ? "enforce" : config.mode;
7136
- const stack = await detectStack(opts.cwd);
7269
+ const llmOn = config.checks.llm.enabled;
7270
+ const [stack, cursorRules] = await Promise.all([
7271
+ detectStack(opts.cwd),
7272
+ llmOn ? loadCursorRules(opts.cwd) : Promise.resolve({ text: "", fileCount: 0, files: [] })
7273
+ ]);
7274
+ if (llmOn && cursorRules.fileCount > 0) {
7275
+ g.stderr.write(
7276
+ `FrontGuard: loaded ${cursorRules.fileCount} cursor rule file(s): ${cursorRules.files.join(", ")}
7277
+ `
7278
+ );
7279
+ }
7137
7280
  const pr = readPrContext(opts.cwd);
7138
7281
  const restrictFiles = pr?.files?.length ? pr.files : null;
7139
7282
  const [
@@ -7182,7 +7325,8 @@ async function runFrontGuard(opts) {
7182
7325
  cwd: opts.cwd,
7183
7326
  config,
7184
7327
  stack,
7185
- results
7328
+ results,
7329
+ cursorRules
7186
7330
  });
7187
7331
  const manualAppendix = await loadManualAppendix({
7188
7332
  cwd: opts.cwd,
@@ -7192,7 +7336,8 @@ async function runFrontGuard(opts) {
7192
7336
  cwd: opts.cwd,
7193
7337
  config,
7194
7338
  pr,
7195
- results
7339
+ results,
7340
+ cursorRules
7196
7341
  });
7197
7342
  const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
7198
7343
  const report = buildReport(stack, pr, results, {
@@ -7200,49 +7345,20 @@ async function runFrontGuard(opts) {
7200
7345
  llmAppendix,
7201
7346
  cwd: opts.cwd,
7202
7347
  emitHtml: Boolean(opts.htmlOut),
7203
- emitChecksSnapshot: Boolean(opts.checksSnapshotOut || opts.checksPngOut)
7348
+ showAiAssistedBanner: config.checks.aiAssistedReview.enabled
7204
7349
  });
7205
7350
  if (opts.htmlOut && report.html) {
7206
7351
  await fs.writeFile(opts.htmlOut, report.html, "utf8");
7207
7352
  }
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
7353
  if (opts.prCommentOut) {
7233
- if (embedPngPath) {
7234
- g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH = embedPngPath;
7235
- }
7236
7354
  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);
7355
+ const abs = path6.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path6.join(opts.cwd, opts.prCommentOut);
7239
7356
  await fs.writeFile(abs, snippet, "utf8");
7240
7357
  g.stderr.write(
7241
7358
  `
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).
7359
+ FrontGuard: wrote Bitbucket PR comment Markdown to ${abs} (${snippet.length} bytes).
7360
+ POST \u2026/pullrequests/{id}/comments with content.raw from this file (after replacing __FRONTGUARD_REPORT_URL__ if used).
7244
7361
  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
7362
 
7247
7363
  `
7248
7364
  );
@@ -7261,25 +7377,29 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
7261
7377
  var init2 = defineCommand({
7262
7378
  meta: {
7263
7379
  name: "init",
7264
- description: "Add Bitbucket pipeline example, pull_request_template.md, and frontguard.config.js"
7380
+ description: "Set up FrontGuard: create config, PR template, and merge pipeline step into bitbucket-pipelines.yml"
7265
7381
  },
7266
7382
  run: async () => {
7267
- const cwd = g.cwd();
7268
- await initFrontGuard(cwd);
7383
+ const actions = await initFrontGuard(g.cwd());
7384
+ g.stdout.write("\nFrontGuard init complete:\n");
7385
+ for (const a3 of actions) {
7386
+ g.stdout.write(` \u2022 ${a3}
7387
+ `);
7388
+ }
7269
7389
  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"
7390
+ "\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
7391
  );
7272
7392
  }
7273
7393
  });
7274
7394
  var run = defineCommand({
7275
7395
  meta: {
7276
7396
  name: "run",
7277
- description: "Run checks and print the review brief"
7397
+ description: "Run all checks and print the review brief"
7278
7398
  },
7279
7399
  args: {
7280
7400
  markdown: {
7281
7401
  type: "boolean",
7282
- description: "Print markdown only",
7402
+ description: "Print markdown only (no console box)",
7283
7403
  default: false
7284
7404
  },
7285
7405
  enforce: {
@@ -7289,23 +7409,15 @@ var run = defineCommand({
7289
7409
  },
7290
7410
  append: {
7291
7411
  type: "string",
7292
- description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
7412
+ description: "Append markdown from a file (e.g. IDE/ChatGPT paste)"
7293
7413
  },
7294
7414
  htmlOut: {
7295
7415
  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"
7416
+ description: "Write interactive HTML report to this path"
7305
7417
  },
7306
7418
  prCommentOut: {
7307
7419
  type: "string",
7308
- description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
7420
+ description: "Write Bitbucket PR comment Markdown to this path"
7309
7421
  }
7310
7422
  },
7311
7423
  run: async ({ args }) => {
@@ -7315,8 +7427,6 @@ var run = defineCommand({
7315
7427
  enforce: Boolean(args.enforce),
7316
7428
  append: typeof args.append === "string" ? args.append : null,
7317
7429
  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
7430
  prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
7321
7431
  });
7322
7432
  }