@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/README.md +268 -0
- package/dist/cli.js +749 -639
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +28 -8
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -6
- package/templates/bitbucket-pipelines.yml +16 -44
- package/templates/checks-snapshot-bitbucket-snippet.yml +0 -1
- package/templates/freekit-ci-setup.md +0 -33
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
|
|
8
|
-
import fs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2422
|
-
//
|
|
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
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
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
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
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
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
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
|
|
2508
|
-
|
|
2509
|
-
|
|
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
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
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
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
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
|
|
2814
|
+
return actions;
|
|
2524
2815
|
}
|
|
2525
|
-
function
|
|
2816
|
+
async function fileExists(p2) {
|
|
2526
2817
|
try {
|
|
2527
|
-
|
|
2528
|
-
|
|
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
|
|
2821
|
+
return false;
|
|
2534
2822
|
}
|
|
2535
2823
|
}
|
|
2536
2824
|
|
|
2537
2825
|
// src/ci/bitbucket-pr-snippet.ts
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
if (
|
|
2545
|
-
|
|
2546
|
-
}
|
|
2547
|
-
|
|
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
|
-
|
|
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
|
|
2561
|
-
|
|
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
|
|
2575
|
-
|
|
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
|
|
2584
|
-
const downloadsPage = bitbucketDownloadsPageUrl();
|
|
2585
|
-
const pipeline = bitbucketPipelineResultsUrl();
|
|
2853
|
+
const reportUrl = publicReport || FRONTGUARD_REPORT_URL_PLACEHOLDER;
|
|
2586
2854
|
const { riskScore, results } = report;
|
|
2587
|
-
const
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
);
|
|
2591
|
-
|
|
2592
|
-
(
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
);
|
|
2645
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
3260
|
+
await fs.access(path6.join(cwd, "pnpm-lock.yaml"));
|
|
3029
3261
|
pm = "pnpm";
|
|
3030
3262
|
} catch {
|
|
3031
3263
|
try {
|
|
3032
|
-
await fs.access(
|
|
3264
|
+
await fs.access(path6.join(cwd, "yarn.lock"));
|
|
3033
3265
|
pm = "yarn";
|
|
3034
3266
|
} catch {
|
|
3035
3267
|
try {
|
|
3036
|
-
await fs.access(
|
|
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 (
|
|
3080
|
-
rel = normalizePrPath(
|
|
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(
|
|
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 =
|
|
3137
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
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 =
|
|
3143
|
-
const absFile =
|
|
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 =
|
|
3432
|
+
let rel = path6.relative(resolvedCwd, absFile);
|
|
3148
3433
|
if (!rel || rel === ".") {
|
|
3149
|
-
return
|
|
3434
|
+
return path6.basename(absFile);
|
|
3150
3435
|
}
|
|
3151
|
-
return rel.split(
|
|
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 =
|
|
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 +
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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: "
|
|
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(
|
|
4882
|
+
if (await pathExists(path6.join(cwd, e3))) {
|
|
4598
4883
|
entry = e3;
|
|
4599
4884
|
break;
|
|
4600
4885
|
}
|
|
4601
4886
|
}
|
|
4602
|
-
if (!await pathExists(
|
|
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
|
|
4716
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
`
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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": "
|
|
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
|
|
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">${
|
|
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
|
|
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}** | ${
|
|
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 =
|
|
6834
|
-
const abs =
|
|
6835
|
-
const relToRoot =
|
|
6836
|
-
if (relToRoot.startsWith("..") ||
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
7016
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7243
|
-
|
|
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: "
|
|
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
|
|
7268
|
-
|
|
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
|
-
"
|
|
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 (
|
|
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
|
|
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
|
|
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
|
}
|