@cleartrip/frontguard 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -0
- package/dist/cli.js +747 -638
- 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);
|
|
@@ -4779,7 +5142,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
|
|
|
4779
5142
|
if (!script) return { run: true };
|
|
4780
5143
|
let scripts;
|
|
4781
5144
|
try {
|
|
4782
|
-
const raw = await fs.readFile(
|
|
5145
|
+
const raw = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
|
|
4783
5146
|
const pkg = JSON.parse(raw);
|
|
4784
5147
|
scripts = pkg.scripts;
|
|
4785
5148
|
} catch {
|
|
@@ -4793,7 +5156,7 @@ async function bundleBuildPrecheck(cwd, buildCommand) {
|
|
|
4793
5156
|
return {
|
|
4794
5157
|
run: false,
|
|
4795
5158
|
message: `Skipped bundle build \u2014 no scripts.${script} in package.json`,
|
|
4796
|
-
detail: "The bundle check runs a production build, then
|
|
5159
|
+
detail: "The bundle check runs a production build, then measures the output. Libraries and non-web repos often have no `build` script \u2014 set checks.bundle.runBuild to false, or set checks.bundle.buildCommand to whatever produces your artifacts."
|
|
4797
5160
|
};
|
|
4798
5161
|
}
|
|
4799
5162
|
return { run: true };
|
|
@@ -4817,7 +5180,9 @@ async function runBundle(cwd, config, stack) {
|
|
|
4817
5180
|
skipped: "skipped for React Native (configure web artifacts if needed)"
|
|
4818
5181
|
};
|
|
4819
5182
|
}
|
|
5183
|
+
const strategy = resolveStrategy(cfg.bundleSizeStrategy, stack);
|
|
4820
5184
|
const preFindings = [];
|
|
5185
|
+
let buildStdout = "";
|
|
4821
5186
|
if (cfg.runBuild) {
|
|
4822
5187
|
const parts = tokenizeCommand(cfg.buildCommand);
|
|
4823
5188
|
if (parts.length === 0) {
|
|
@@ -4858,9 +5223,56 @@ async function runBundle(cwd, config, stack) {
|
|
|
4858
5223
|
durationMs: Math.round(performance.now() - t0)
|
|
4859
5224
|
};
|
|
4860
5225
|
}
|
|
5226
|
+
buildStdout = (res.stdout ?? "") + "\n" + (res.stderr ?? "");
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
let sizeResult = null;
|
|
5230
|
+
switch (strategy) {
|
|
5231
|
+
case "next": {
|
|
5232
|
+
sizeResult = parseNextBuildOutput(buildStdout);
|
|
5233
|
+
if (!sizeResult) {
|
|
5234
|
+
sizeResult = (await sumGlobBytes(cwd, [".next/static/**/*.js"])).bytes > 0 ? await sumGlobBytes(cwd, [".next/static/**/*.js"]) : null;
|
|
5235
|
+
}
|
|
5236
|
+
break;
|
|
5237
|
+
}
|
|
5238
|
+
case "cra": {
|
|
5239
|
+
sizeResult = parseCraBuildOutput(buildStdout);
|
|
5240
|
+
if (!sizeResult) {
|
|
5241
|
+
const g4 = await sumGlobBytes(cwd, ["build/static/js/**/*.js"]);
|
|
5242
|
+
if (g4.bytes > 0) sizeResult = g4;
|
|
5243
|
+
}
|
|
5244
|
+
break;
|
|
5245
|
+
}
|
|
5246
|
+
case "vite": {
|
|
5247
|
+
sizeResult = await measureViteBundle(cwd, cfg.measureGlobs);
|
|
5248
|
+
break;
|
|
5249
|
+
}
|
|
5250
|
+
case "custom": {
|
|
5251
|
+
if (!cfg.bundleSizeCommand) {
|
|
5252
|
+
return {
|
|
5253
|
+
checkId: "bundle",
|
|
5254
|
+
findings: [
|
|
5255
|
+
{
|
|
5256
|
+
id: "bundle-custom-missing",
|
|
5257
|
+
severity: "warn",
|
|
5258
|
+
message: "bundleSizeStrategy is `custom` but `bundleSizeCommand` is not set"
|
|
5259
|
+
}
|
|
5260
|
+
],
|
|
5261
|
+
durationMs: Math.round(performance.now() - t0)
|
|
5262
|
+
};
|
|
5263
|
+
}
|
|
5264
|
+
sizeResult = await runCustomSizeCommand(cwd, cfg.bundleSizeCommand);
|
|
5265
|
+
break;
|
|
5266
|
+
}
|
|
5267
|
+
case "glob":
|
|
5268
|
+
default: {
|
|
5269
|
+
const g4 = await sumGlobBytes(cwd, cfg.measureGlobs);
|
|
5270
|
+
if (g4.bytes > 0) sizeResult = g4;
|
|
5271
|
+
break;
|
|
4861
5272
|
}
|
|
4862
5273
|
}
|
|
4863
|
-
const total =
|
|
5274
|
+
const total = sizeResult?.bytes ?? 0;
|
|
5275
|
+
const sizeLabel = sizeResult?.label ?? `(no bundle output detected for strategy "${strategy}")`;
|
|
4864
5276
|
if (total === 0) {
|
|
4865
5277
|
return {
|
|
4866
5278
|
checkId: "bundle",
|
|
@@ -4869,7 +5281,9 @@ async function runBundle(cwd, config, stack) {
|
|
|
4869
5281
|
{
|
|
4870
5282
|
id: "bundle-empty",
|
|
4871
5283
|
severity: "info",
|
|
4872
|
-
message:
|
|
5284
|
+
message: `No bundle size detected (strategy: ${strategy})`,
|
|
5285
|
+
detail: `${sizeLabel}
|
|
5286
|
+
Ensure the build produces artifacts, or switch to a different bundleSizeStrategy.`
|
|
4873
5287
|
}
|
|
4874
5288
|
],
|
|
4875
5289
|
durationMs: Math.round(performance.now() - t0)
|
|
@@ -4879,7 +5293,9 @@ async function runBundle(cwd, config, stack) {
|
|
|
4879
5293
|
const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
|
|
4880
5294
|
const findings = [...preFindings];
|
|
4881
5295
|
const infoLines = [
|
|
4882
|
-
`
|
|
5296
|
+
`Strategy: ${strategy}`,
|
|
5297
|
+
sizeLabel,
|
|
5298
|
+
`Measured ${total} bytes (${(total / 1024).toFixed(1)} kB)`,
|
|
4883
5299
|
baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
|
|
4884
5300
|
].join("\n");
|
|
4885
5301
|
if (cfg.maxTotalBytes != null && total > cfg.maxTotalBytes) {
|
|
@@ -4955,7 +5371,7 @@ async function runCoreWebVitals(cwd, config, stack, pr) {
|
|
|
4955
5371
|
const findings = [];
|
|
4956
5372
|
const sev2 = gateSeverity5(cfg.gate);
|
|
4957
5373
|
for (const rel of toScan.slice(0, 400)) {
|
|
4958
|
-
const full =
|
|
5374
|
+
const full = path6.join(cwd, rel);
|
|
4959
5375
|
let text;
|
|
4960
5376
|
try {
|
|
4961
5377
|
text = await fs.readFile(full, "utf8");
|
|
@@ -5040,7 +5456,7 @@ async function runCustomRules(cwd, config, restrictToFiles) {
|
|
|
5040
5456
|
});
|
|
5041
5457
|
break;
|
|
5042
5458
|
}
|
|
5043
|
-
const full =
|
|
5459
|
+
const full = path6.join(cwd, rel);
|
|
5044
5460
|
let content;
|
|
5045
5461
|
try {
|
|
5046
5462
|
content = await fs.readFile(full, "utf8");
|
|
@@ -5485,7 +5901,7 @@ async function runAiAssistedStrict(cwd, config, pr) {
|
|
|
5485
5901
|
const byRel = /* @__PURE__ */ new Map();
|
|
5486
5902
|
let anyDecoratorInPr = false;
|
|
5487
5903
|
for (const rel of files) {
|
|
5488
|
-
const full =
|
|
5904
|
+
const full = path6.join(cwd, rel);
|
|
5489
5905
|
try {
|
|
5490
5906
|
const content = await fs.readFile(full, "utf8");
|
|
5491
5907
|
if (content.length > 5e5) continue;
|
|
@@ -5573,204 +5989,6 @@ function applyAiAssistedEscalation(results, pr, config) {
|
|
|
5573
5989
|
}
|
|
5574
5990
|
}
|
|
5575
5991
|
}
|
|
5576
|
-
var W3 = 920;
|
|
5577
|
-
var PAD_X = 24;
|
|
5578
|
-
var TABLE_X = 20;
|
|
5579
|
-
var TABLE_W = 880;
|
|
5580
|
-
var HEADER_H = 34;
|
|
5581
|
-
var ROW_H = 34;
|
|
5582
|
-
var COL_CHECK = 52;
|
|
5583
|
-
var ICON_X = 268;
|
|
5584
|
-
var COL_STATUS = 400;
|
|
5585
|
-
var COL_NUM = 700;
|
|
5586
|
-
var COL_TIME = 820;
|
|
5587
|
-
var OVER_LABEL_W = 132;
|
|
5588
|
-
var OVER_ROW_H = 36;
|
|
5589
|
-
var clipSeq = 0;
|
|
5590
|
-
function nextClipId() {
|
|
5591
|
-
clipSeq += 1;
|
|
5592
|
-
return `fgc${clipSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5593
|
-
}
|
|
5594
|
-
function escapeXml(s3) {
|
|
5595
|
-
return s3.replace(/&/g, "&").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
5992
|
|
|
5775
5993
|
// src/report/builder.ts
|
|
5776
5994
|
var import_picocolors = __toESM(require_picocolors());
|
|
@@ -5788,9 +6006,9 @@ var CHECK_DESCRIPTIONS = {
|
|
|
5788
6006
|
secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
|
|
5789
6007
|
cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
|
|
5790
6008
|
"dead-code": "Runs ts-prune to find unused exports in the TypeScript project. Helps trim dead surface area.",
|
|
5791
|
-
bundle: "Measures
|
|
6009
|
+
bundle: "Measures bundle size using a stack-aware strategy (Next.js build output, Vite/CRA file sizes, or custom command) and compares to a checked-in baseline.",
|
|
5792
6010
|
"core-web-vitals": "Static hints in JSX/TSX related to Core Web Vitals (e.g. LCP-friendly images, main-thread hygiene). Not a substitute for real field metrics.",
|
|
5793
|
-
"ai-assisted-strict": "
|
|
6011
|
+
"ai-assisted-strict": "Under development \u2014 off by default. When enabled: scans @frontguard-ai regions or AI-disclosed PR files for risky patterns (eval, XSS sinks, etc.) and AST heuristics.",
|
|
5794
6012
|
"pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
|
|
5795
6013
|
"pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
|
|
5796
6014
|
"ts-any-delta": "Diffs the branch against a base ref and counts newly added uses of the TypeScript any type. Helps stop gradual loss of type safety.",
|
|
@@ -5824,7 +6042,7 @@ function sortFindings(cwd, items) {
|
|
|
5824
6042
|
return a3.f.message.localeCompare(b3.f.message);
|
|
5825
6043
|
});
|
|
5826
6044
|
}
|
|
5827
|
-
function
|
|
6045
|
+
function formatDuration(ms) {
|
|
5828
6046
|
if (ms < 1e3) return `${ms} ms`;
|
|
5829
6047
|
const s3 = Math.round(ms / 1e3);
|
|
5830
6048
|
if (s3 < 60) return `${s3}s`;
|
|
@@ -5953,119 +6171,15 @@ var CHECKS_TABLE_STYLES = `
|
|
|
5953
6171
|
.dot-block { background: var(--block); }
|
|
5954
6172
|
.dot-skip { background: #cbd5e1; }
|
|
5955
6173
|
`;
|
|
5956
|
-
var SNAPSHOT_OVERVIEW_STYLES = `
|
|
5957
|
-
.snapshot {
|
|
5958
|
-
width: 100%;
|
|
5959
|
-
border-collapse: collapse;
|
|
5960
|
-
font-size: 0.9rem;
|
|
5961
|
-
background: var(--surface);
|
|
5962
|
-
border-radius: var(--radius);
|
|
5963
|
-
overflow: hidden;
|
|
5964
|
-
border: 1px solid var(--border);
|
|
5965
|
-
box-shadow: var(--shadow);
|
|
5966
|
-
margin: 0 0 1.25rem;
|
|
5967
|
-
}
|
|
5968
|
-
.snapshot th, .snapshot td {
|
|
5969
|
-
padding: 0.65rem 1rem;
|
|
5970
|
-
text-align: left;
|
|
5971
|
-
border-bottom: 1px solid var(--border);
|
|
5972
|
-
}
|
|
5973
|
-
.snapshot tr:last-child th, .snapshot tr:last-child td { border-bottom: none; }
|
|
5974
|
-
.snapshot th {
|
|
5975
|
-
width: 9rem;
|
|
5976
|
-
color: var(--muted);
|
|
5977
|
-
font-weight: 500;
|
|
5978
|
-
background: #f1f5f9;
|
|
5979
|
-
}
|
|
5980
|
-
.muted { color: var(--muted); }
|
|
5981
|
-
`;
|
|
5982
6174
|
function renderCheckTableRows(results) {
|
|
5983
6175
|
return results.map((r4) => {
|
|
5984
6176
|
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
|
|
5985
6177
|
const help = escapeHtml(getCheckDescription(r4.checkId));
|
|
5986
6178
|
const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
|
|
5987
6179
|
const checkTitle = `<span class="check-title-cell"><strong class="check-name">${escapeHtml(r4.checkId)}</strong><span class="check-info-wrap"><button type="button" class="check-info" title="${help}" aria-label="${ariaWhat}">i</button><span class="check-tooltip" role="tooltip">${help}</span></span></span>`;
|
|
5988
|
-
return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${
|
|
6180
|
+
return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
|
|
5989
6181
|
}).join("\n");
|
|
5990
6182
|
}
|
|
5991
|
-
function buildChecksSnapshotHtml(p2) {
|
|
5992
|
-
const { riskScore, mode, stack, pr, results, lines } = p2;
|
|
5993
|
-
const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
|
|
5994
|
-
const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
|
|
5995
|
-
const checkRows = renderCheckTableRows(results);
|
|
5996
|
-
const prRow = pr && lines != null ? `<tr><th>Pull request</th><td>${lines} lines changed (+${pr.additions} / \u2212${pr.deletions}) \xB7 ${pr.changedFiles} files</td></tr>` : "";
|
|
5997
|
-
return `<!DOCTYPE html>
|
|
5998
|
-
<html lang="en">
|
|
5999
|
-
<head>
|
|
6000
|
-
<meta charset="utf-8" />
|
|
6001
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6002
|
-
<title>FrontGuard \u2014 Snapshot</title>
|
|
6003
|
-
<style>
|
|
6004
|
-
:root {
|
|
6005
|
-
--bg: #f8fafc;
|
|
6006
|
-
--surface: #ffffff;
|
|
6007
|
-
--text: #0f172a;
|
|
6008
|
-
--muted: #64748b;
|
|
6009
|
-
--border: #e2e8f0;
|
|
6010
|
-
--accent: #4f46e5;
|
|
6011
|
-
--accent-soft: #eef2ff;
|
|
6012
|
-
--block: #dc2626;
|
|
6013
|
-
--warn: #d97706;
|
|
6014
|
-
--info: #0284c7;
|
|
6015
|
-
--ok: #16a34a;
|
|
6016
|
-
--radius: 10px;
|
|
6017
|
-
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
6018
|
-
}
|
|
6019
|
-
* { box-sizing: border-box; }
|
|
6020
|
-
body {
|
|
6021
|
-
margin: 0;
|
|
6022
|
-
padding: 1.25rem 1.5rem 1.5rem;
|
|
6023
|
-
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
6024
|
-
background: var(--bg);
|
|
6025
|
-
color: var(--text);
|
|
6026
|
-
line-height: 1.55;
|
|
6027
|
-
font-size: 15px;
|
|
6028
|
-
max-width: 920px;
|
|
6029
|
-
}
|
|
6030
|
-
.brand {
|
|
6031
|
-
font-size: 0.75rem;
|
|
6032
|
-
font-weight: 600;
|
|
6033
|
-
letter-spacing: 0.12em;
|
|
6034
|
-
text-transform: uppercase;
|
|
6035
|
-
color: var(--muted);
|
|
6036
|
-
margin-bottom: 0.35rem;
|
|
6037
|
-
}
|
|
6038
|
-
.h2 {
|
|
6039
|
-
font-size: 1rem;
|
|
6040
|
-
font-weight: 600;
|
|
6041
|
-
margin: 0 0 0.85rem;
|
|
6042
|
-
color: var(--text);
|
|
6043
|
-
letter-spacing: -0.02em;
|
|
6044
|
-
}
|
|
6045
|
-
.risk-low { color: var(--ok); }
|
|
6046
|
-
.risk-med { color: var(--warn); }
|
|
6047
|
-
.risk-high { color: var(--block); }
|
|
6048
|
-
${SNAPSHOT_OVERVIEW_STYLES}
|
|
6049
|
-
${CHECKS_TABLE_STYLES}
|
|
6050
|
-
</style>
|
|
6051
|
-
</head>
|
|
6052
|
-
<body>
|
|
6053
|
-
<div class="brand">FrontGuard</div>
|
|
6054
|
-
<h2 class="h2">Overview</h2>
|
|
6055
|
-
<table class="snapshot">
|
|
6056
|
-
<tr><th>Risk score</th><td><strong class="${riskClass}">${riskScore}</strong> <span class="muted">\u2014 heuristic</span></td></tr>
|
|
6057
|
-
<tr><th>Mode</th><td>${escapeHtml(modeLabel)}</td></tr>
|
|
6058
|
-
<tr><th>Stack</th><td>${escapeHtml(formatStackOneLiner(stack))}</td></tr>
|
|
6059
|
-
${prRow}
|
|
6060
|
-
</table>
|
|
6061
|
-
<h2 class="h2">Checks</h2>
|
|
6062
|
-
<table class="results">
|
|
6063
|
-
<thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
|
|
6064
|
-
<tbody>${checkRows}</tbody>
|
|
6065
|
-
</table>
|
|
6066
|
-
</body>
|
|
6067
|
-
</html>`;
|
|
6068
|
-
}
|
|
6069
6183
|
function renderFindingCard(cwd, r4, f4) {
|
|
6070
6184
|
const d3 = normalizeFinding(cwd, f4);
|
|
6071
6185
|
const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
|
|
@@ -6369,6 +6483,7 @@ function buildHtmlReport(p2) {
|
|
|
6369
6483
|
function buildReport(stack, pr, results, options) {
|
|
6370
6484
|
const mode = options?.mode ?? "warn";
|
|
6371
6485
|
const cwd = options?.cwd ?? process.cwd();
|
|
6486
|
+
const showAiAssistedBanner = options?.showAiAssistedBanner ?? false;
|
|
6372
6487
|
const allFindings = results.flatMap(
|
|
6373
6488
|
(r4) => r4.findings.map((f4) => ({ ...f4, checkId: r4.checkId }))
|
|
6374
6489
|
);
|
|
@@ -6388,7 +6503,8 @@ function buildReport(stack, pr, results, options) {
|
|
|
6388
6503
|
infos,
|
|
6389
6504
|
blocks,
|
|
6390
6505
|
lines,
|
|
6391
|
-
llmAppendix: options?.llmAppendix ?? null
|
|
6506
|
+
llmAppendix: options?.llmAppendix ?? null,
|
|
6507
|
+
showAiAssistedBanner
|
|
6392
6508
|
});
|
|
6393
6509
|
const consoleText = formatConsole({
|
|
6394
6510
|
riskScore,
|
|
@@ -6413,21 +6529,6 @@ function buildReport(stack, pr, results, options) {
|
|
|
6413
6529
|
lines,
|
|
6414
6530
|
llmAppendix: options?.llmAppendix ?? null
|
|
6415
6531
|
}) : null;
|
|
6416
|
-
const llmAppendix = options?.llmAppendix ?? null;
|
|
6417
|
-
const checksSnapshotInput = options?.emitChecksSnapshot === true ? {
|
|
6418
|
-
cwd,
|
|
6419
|
-
riskScore,
|
|
6420
|
-
mode,
|
|
6421
|
-
stack,
|
|
6422
|
-
pr,
|
|
6423
|
-
results,
|
|
6424
|
-
warns,
|
|
6425
|
-
infos,
|
|
6426
|
-
blocks,
|
|
6427
|
-
lines,
|
|
6428
|
-
llmAppendix
|
|
6429
|
-
} : null;
|
|
6430
|
-
const checksSnapshotHtml = checksSnapshotInput != null ? buildChecksSnapshotHtml(checksSnapshotInput) : null;
|
|
6431
6532
|
return {
|
|
6432
6533
|
riskScore,
|
|
6433
6534
|
stack,
|
|
@@ -6435,9 +6536,7 @@ function buildReport(stack, pr, results, options) {
|
|
|
6435
6536
|
results,
|
|
6436
6537
|
markdown,
|
|
6437
6538
|
consoleText,
|
|
6438
|
-
html
|
|
6439
|
-
checksSnapshotHtml,
|
|
6440
|
-
checksSnapshotInput
|
|
6539
|
+
html
|
|
6441
6540
|
};
|
|
6442
6541
|
}
|
|
6443
6542
|
function scoreRisk(blocks, warns, lines, files) {
|
|
@@ -6486,7 +6585,7 @@ function countShieldColor(kind, n3) {
|
|
|
6486
6585
|
if (n3 <= 10) return "yellow";
|
|
6487
6586
|
return "orange";
|
|
6488
6587
|
}
|
|
6489
|
-
function
|
|
6588
|
+
function formatDuration2(ms) {
|
|
6490
6589
|
if (ms < 1e3) return `${ms} ms`;
|
|
6491
6590
|
const s3 = Math.round(ms / 1e3);
|
|
6492
6591
|
if (s3 < 60) return `${s3}s`;
|
|
@@ -6561,7 +6660,8 @@ function formatMarkdown(p2) {
|
|
|
6561
6660
|
infos,
|
|
6562
6661
|
blocks,
|
|
6563
6662
|
lines,
|
|
6564
|
-
llmAppendix
|
|
6663
|
+
llmAppendix,
|
|
6664
|
+
showAiAssistedBanner
|
|
6565
6665
|
} = p2;
|
|
6566
6666
|
const sb = [];
|
|
6567
6667
|
const sortWithCwd = (items) => [...items].sort((a3, b3) => {
|
|
@@ -6602,7 +6702,7 @@ function formatMarkdown(p2) {
|
|
|
6602
6702
|
);
|
|
6603
6703
|
}
|
|
6604
6704
|
sb.push("");
|
|
6605
|
-
if (pr?.aiAssisted) {
|
|
6705
|
+
if (showAiAssistedBanner && pr?.aiAssisted) {
|
|
6606
6706
|
sb.push(
|
|
6607
6707
|
"> **\u{1F916} AI-assisted PR** \u2014 Stricter static checks run on changed files (security / footguns; secrets & `any` deltas may escalate). This does not replace human review for behavior or product rules."
|
|
6608
6708
|
);
|
|
@@ -6641,7 +6741,7 @@ function formatMarkdown(p2) {
|
|
|
6641
6741
|
}
|
|
6642
6742
|
const nFind = r4.skipped ? "\u2014" : String(r4.findings.length);
|
|
6643
6743
|
sb.push(
|
|
6644
|
-
`| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${
|
|
6744
|
+
`| ${he2} | **${r4.checkId}** | ${status} | **${nFind}** | ${formatDuration2(r4.durationMs)} |`
|
|
6645
6745
|
);
|
|
6646
6746
|
}
|
|
6647
6747
|
sb.push("");
|
|
@@ -6830,10 +6930,10 @@ async function callOllamaChat(opts) {
|
|
|
6830
6930
|
|
|
6831
6931
|
// src/llm/finding-fixes.ts
|
|
6832
6932
|
async function safeReadRepoFile(cwd, rel, maxChars) {
|
|
6833
|
-
const root =
|
|
6834
|
-
const abs =
|
|
6835
|
-
const relToRoot =
|
|
6836
|
-
if (relToRoot.startsWith("..") ||
|
|
6933
|
+
const root = path6.resolve(cwd);
|
|
6934
|
+
const abs = path6.resolve(root, rel);
|
|
6935
|
+
const relToRoot = path6.relative(root, abs);
|
|
6936
|
+
if (relToRoot.startsWith("..") || path6.isAbsolute(relToRoot)) return null;
|
|
6837
6937
|
try {
|
|
6838
6938
|
let t3 = await fs.readFile(abs, "utf8");
|
|
6839
6939
|
if (t3.length > maxChars) {
|
|
@@ -6855,14 +6955,14 @@ function parseFixResponse(raw) {
|
|
|
6855
6955
|
return { summary: summary || raw.trim(), code };
|
|
6856
6956
|
}
|
|
6857
6957
|
async function enrichFindingsWithOllamaFixes(opts) {
|
|
6858
|
-
const { cwd, config, stack, results } = opts;
|
|
6958
|
+
const { cwd, config, stack, results, cursorRules } = opts;
|
|
6859
6959
|
const cfg = config.checks.llm;
|
|
6860
6960
|
if (!cfg.enabled || cfg.provider !== "ollama" || !cfg.perFindingFixes) {
|
|
6861
6961
|
return results;
|
|
6862
6962
|
}
|
|
6863
6963
|
let pkgSnippet = "";
|
|
6864
6964
|
try {
|
|
6865
|
-
const pj = await fs.readFile(
|
|
6965
|
+
const pj = await fs.readFile(path6.join(cwd, "package.json"), "utf8");
|
|
6866
6966
|
pkgSnippet = pj.slice(0, 4e3);
|
|
6867
6967
|
} catch {
|
|
6868
6968
|
pkgSnippet = "";
|
|
@@ -6885,7 +6985,7 @@ async function enrichFindingsWithOllamaFixes(opts) {
|
|
|
6885
6985
|
f4.file,
|
|
6886
6986
|
cfg.maxFileContextChars
|
|
6887
6987
|
);
|
|
6888
|
-
const
|
|
6988
|
+
const promptParts = [
|
|
6889
6989
|
"You are a senior frontend engineer. A static checker flagged an issue in a pull request.",
|
|
6890
6990
|
"Use ONLY the repo context below (stack summary, package.json excerpt, file content).",
|
|
6891
6991
|
"If context is insufficient, say what is missing instead of guessing.",
|
|
@@ -6894,7 +6994,16 @@ async function enrichFindingsWithOllamaFixes(opts) {
|
|
|
6894
6994
|
"### Why",
|
|
6895
6995
|
"(1\u20133 short sentences: root cause and product/engineering risk.)",
|
|
6896
6996
|
"### Fix",
|
|
6897
|
-
"(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)"
|
|
6997
|
+
"(Minimal, concrete change. Put code in a single fenced block with a language tag, e.g. ```ts)"
|
|
6998
|
+
];
|
|
6999
|
+
if (cursorRules?.text) {
|
|
7000
|
+
promptParts.push(
|
|
7001
|
+
"",
|
|
7002
|
+
"The repo has coding rules/conventions. Ensure the fix follows them:",
|
|
7003
|
+
cursorRules.text.slice(0, 8e3)
|
|
7004
|
+
);
|
|
7005
|
+
}
|
|
7006
|
+
promptParts.push(
|
|
6898
7007
|
"",
|
|
6899
7008
|
`Repo stack: ${stackLabel}`,
|
|
6900
7009
|
"",
|
|
@@ -6912,7 +7021,8 @@ async function enrichFindingsWithOllamaFixes(opts) {
|
|
|
6912
7021
|
f4.file ? `file (repo-relative): ${f4.file}` : "",
|
|
6913
7022
|
"",
|
|
6914
7023
|
fileContent ? "File content:\n```\n" + fileContent + "\n```" : "_No file content could be read (binary or path issue)._"
|
|
6915
|
-
|
|
7024
|
+
);
|
|
7025
|
+
const prompt2 = promptParts.filter(Boolean).join("\n");
|
|
6916
7026
|
try {
|
|
6917
7027
|
const raw = await callOllamaChat({
|
|
6918
7028
|
baseUrl: cfg.ollamaUrl,
|
|
@@ -6946,7 +7056,7 @@ async function loadManualAppendix(opts) {
|
|
|
6946
7056
|
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
6947
7057
|
const resolvedPath = filePath?.trim() || envFile;
|
|
6948
7058
|
if (resolvedPath) {
|
|
6949
|
-
const abs =
|
|
7059
|
+
const abs = path6.isAbsolute(resolvedPath) ? resolvedPath : path6.join(cwd, resolvedPath);
|
|
6950
7060
|
try {
|
|
6951
7061
|
let text = await fs.readFile(abs, "utf8");
|
|
6952
7062
|
if (text.length > MAX_CHARS) {
|
|
@@ -6982,7 +7092,7 @@ function safeGetEnv(name) {
|
|
|
6982
7092
|
return v3 && v3.trim() ? v3 : void 0;
|
|
6983
7093
|
}
|
|
6984
7094
|
async function runLlmReview(opts) {
|
|
6985
|
-
const { cwd, config, pr, results } = opts;
|
|
7095
|
+
const { cwd, config, pr, results, cursorRules } = opts;
|
|
6986
7096
|
const cfg = config.checks.llm;
|
|
6987
7097
|
if (!cfg.enabled) return null;
|
|
6988
7098
|
if (cfg.provider !== "ollama") {
|
|
@@ -7012,21 +7122,42 @@ async function runLlmReview(opts) {
|
|
|
7012
7122
|
return "_LLM review skipped: empty diff vs base_";
|
|
7013
7123
|
}
|
|
7014
7124
|
const summaryLines = results.flatMap((r4) => r4.findings.map((f4) => `- [${f4.severity}] ${r4.checkId}: ${f4.message}`)).slice(0, 40).join("\n");
|
|
7015
|
-
const
|
|
7016
|
-
"
|
|
7125
|
+
const rulesBlock = cursorRules?.text ? [
|
|
7126
|
+
"",
|
|
7127
|
+
"The repository has the following coding rules and conventions that MUST be enforced during review.",
|
|
7128
|
+
"Flag any violations of these rules as specific findings:",
|
|
7129
|
+
"",
|
|
7130
|
+
cursorRules.text,
|
|
7131
|
+
""
|
|
7132
|
+
].join("\n") : "";
|
|
7133
|
+
const sections = [
|
|
7017
7134
|
"### Summary",
|
|
7018
7135
|
"### Risk hotspots (files / themes)",
|
|
7019
|
-
"### Logic / correctness smells"
|
|
7020
|
-
|
|
7136
|
+
"### Logic / correctness smells"
|
|
7137
|
+
];
|
|
7138
|
+
if (cursorRules?.text) sections.push("### Coding standards violations");
|
|
7139
|
+
sections.push("### Tests & regressions");
|
|
7140
|
+
const promptParts = [
|
|
7141
|
+
"You are a senior frontend reviewer. Respond in Markdown with short sections:",
|
|
7142
|
+
...sections,
|
|
7021
7143
|
"",
|
|
7022
7144
|
"Constraints:",
|
|
7023
7145
|
"- Be specific to this diff; avoid generic advice.",
|
|
7024
|
-
"- If uncertain, say what you need to verify manually."
|
|
7146
|
+
"- If uncertain, say what you need to verify manually."
|
|
7147
|
+
];
|
|
7148
|
+
if (cursorRules?.text) {
|
|
7149
|
+
promptParts.push(
|
|
7150
|
+
`- The repo has ${cursorRules.fileCount} coding rule file(s). Check the diff against ALL rules below and report violations.`
|
|
7151
|
+
);
|
|
7152
|
+
}
|
|
7153
|
+
promptParts.push(
|
|
7025
7154
|
"",
|
|
7026
7155
|
pr ? `PR title: ${pr.title}
|
|
7027
7156
|
PR body excerpt:
|
|
7028
|
-
${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run)."
|
|
7029
|
-
|
|
7157
|
+
${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run)."
|
|
7158
|
+
);
|
|
7159
|
+
if (rulesBlock) promptParts.push(rulesBlock);
|
|
7160
|
+
promptParts.push(
|
|
7030
7161
|
"Existing automated findings (may be incomplete):",
|
|
7031
7162
|
summaryLines || "(none)",
|
|
7032
7163
|
"",
|
|
@@ -7034,7 +7165,8 @@ ${pr.body.slice(0, 2e3)}` : "No PR context from the event payload (local run).",
|
|
|
7034
7165
|
"```diff",
|
|
7035
7166
|
diff,
|
|
7036
7167
|
"```"
|
|
7037
|
-
|
|
7168
|
+
);
|
|
7169
|
+
const prompt2 = promptParts.join("\n");
|
|
7038
7170
|
if (cfg.provider === "ollama") {
|
|
7039
7171
|
try {
|
|
7040
7172
|
const text = await callOllamaChat({
|
|
@@ -7133,7 +7265,17 @@ async function callAnthropic(model, apiKey, prompt2, signal) {
|
|
|
7133
7265
|
async function runFrontGuard(opts) {
|
|
7134
7266
|
const config = await loadConfig(opts.cwd);
|
|
7135
7267
|
const mode = opts.enforce ? "enforce" : config.mode;
|
|
7136
|
-
const
|
|
7268
|
+
const llmOn = config.checks.llm.enabled;
|
|
7269
|
+
const [stack, cursorRules] = await Promise.all([
|
|
7270
|
+
detectStack(opts.cwd),
|
|
7271
|
+
llmOn ? loadCursorRules(opts.cwd) : Promise.resolve({ text: "", fileCount: 0, files: [] })
|
|
7272
|
+
]);
|
|
7273
|
+
if (llmOn && cursorRules.fileCount > 0) {
|
|
7274
|
+
g.stderr.write(
|
|
7275
|
+
`FrontGuard: loaded ${cursorRules.fileCount} cursor rule file(s): ${cursorRules.files.join(", ")}
|
|
7276
|
+
`
|
|
7277
|
+
);
|
|
7278
|
+
}
|
|
7137
7279
|
const pr = readPrContext(opts.cwd);
|
|
7138
7280
|
const restrictFiles = pr?.files?.length ? pr.files : null;
|
|
7139
7281
|
const [
|
|
@@ -7182,7 +7324,8 @@ async function runFrontGuard(opts) {
|
|
|
7182
7324
|
cwd: opts.cwd,
|
|
7183
7325
|
config,
|
|
7184
7326
|
stack,
|
|
7185
|
-
results
|
|
7327
|
+
results,
|
|
7328
|
+
cursorRules
|
|
7186
7329
|
});
|
|
7187
7330
|
const manualAppendix = await loadManualAppendix({
|
|
7188
7331
|
cwd: opts.cwd,
|
|
@@ -7192,7 +7335,8 @@ async function runFrontGuard(opts) {
|
|
|
7192
7335
|
cwd: opts.cwd,
|
|
7193
7336
|
config,
|
|
7194
7337
|
pr,
|
|
7195
|
-
results
|
|
7338
|
+
results,
|
|
7339
|
+
cursorRules
|
|
7196
7340
|
});
|
|
7197
7341
|
const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
|
|
7198
7342
|
const report = buildReport(stack, pr, results, {
|
|
@@ -7200,49 +7344,20 @@ async function runFrontGuard(opts) {
|
|
|
7200
7344
|
llmAppendix,
|
|
7201
7345
|
cwd: opts.cwd,
|
|
7202
7346
|
emitHtml: Boolean(opts.htmlOut),
|
|
7203
|
-
|
|
7347
|
+
showAiAssistedBanner: config.checks.aiAssistedReview.enabled
|
|
7204
7348
|
});
|
|
7205
7349
|
if (opts.htmlOut && report.html) {
|
|
7206
7350
|
await fs.writeFile(opts.htmlOut, report.html, "utf8");
|
|
7207
7351
|
}
|
|
7208
|
-
let embedPngPath = null;
|
|
7209
|
-
if ((opts.checksSnapshotOut || opts.checksPngOut) && report.checksSnapshotHtml && report.checksSnapshotInput) {
|
|
7210
|
-
if (opts.checksSnapshotOut) {
|
|
7211
|
-
const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
|
|
7212
|
-
await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
|
|
7213
|
-
g.stderr.write(`
|
|
7214
|
-
FrontGuard: wrote checks snapshot HTML to ${snapPath}
|
|
7215
|
-
`);
|
|
7216
|
-
}
|
|
7217
|
-
if (opts.checksPngOut) {
|
|
7218
|
-
const pngAbs = path5.isAbsolute(opts.checksPngOut) ? opts.checksPngOut : path5.join(opts.cwd, opts.checksPngOut);
|
|
7219
|
-
await renderChecksSnapshotPng(pngAbs, report.checksSnapshotInput);
|
|
7220
|
-
embedPngPath = pngAbs;
|
|
7221
|
-
g.stderr.write(`FrontGuard: wrote checks PNG to ${pngAbs}.
|
|
7222
|
-
|
|
7223
|
-
`);
|
|
7224
|
-
} else if (opts.checksSnapshotOut) {
|
|
7225
|
-
g.stderr.write(
|
|
7226
|
-
` Tip: add --checksPngOut checks.png to render the checks table to a PNG (no browser).
|
|
7227
|
-
|
|
7228
|
-
`
|
|
7229
|
-
);
|
|
7230
|
-
}
|
|
7231
|
-
}
|
|
7232
7352
|
if (opts.prCommentOut) {
|
|
7233
|
-
if (embedPngPath) {
|
|
7234
|
-
g.env.FRONTGUARD_EMBED_CHECKS_PNG_PATH = embedPngPath;
|
|
7235
|
-
}
|
|
7236
7353
|
const snippet = formatBitbucketPrSnippet(report);
|
|
7237
|
-
|
|
7238
|
-
const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
|
|
7354
|
+
const abs = path6.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path6.join(opts.cwd, opts.prCommentOut);
|
|
7239
7355
|
await fs.writeFile(abs, snippet, "utf8");
|
|
7240
7356
|
g.stderr.write(
|
|
7241
7357
|
`
|
|
7242
|
-
FrontGuard: wrote Bitbucket PR comment
|
|
7243
|
-
|
|
7358
|
+
FrontGuard: wrote Bitbucket PR comment Markdown to ${abs} (${snippet.length} bytes).
|
|
7359
|
+
POST \u2026/pullrequests/{id}/comments with content.raw from this file (after replacing __FRONTGUARD_REPORT_URL__ if used).
|
|
7244
7360
|
Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
|
|
7245
|
-
With --checksPngOut, the PNG is inlined (small files only) or set FRONTGUARD_CHECKS_IMAGE_URL.
|
|
7246
7361
|
|
|
7247
7362
|
`
|
|
7248
7363
|
);
|
|
@@ -7261,25 +7376,29 @@ FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
|
|
|
7261
7376
|
var init2 = defineCommand({
|
|
7262
7377
|
meta: {
|
|
7263
7378
|
name: "init",
|
|
7264
|
-
description: "
|
|
7379
|
+
description: "Set up FrontGuard: create config, PR template, and merge pipeline step into bitbucket-pipelines.yml"
|
|
7265
7380
|
},
|
|
7266
7381
|
run: async () => {
|
|
7267
|
-
const
|
|
7268
|
-
|
|
7382
|
+
const actions = await initFrontGuard(g.cwd());
|
|
7383
|
+
g.stdout.write("\nFrontGuard init complete:\n");
|
|
7384
|
+
for (const a3 of actions) {
|
|
7385
|
+
g.stdout.write(` \u2022 ${a3}
|
|
7386
|
+
`);
|
|
7387
|
+
}
|
|
7269
7388
|
g.stdout.write(
|
|
7270
|
-
"
|
|
7389
|
+
"\nNext steps:\n 1. Review frontguard.config.js \u2014 uncomment and tune the checks you need\n 2. Review bitbucket-pipelines.yml \u2014 check the merged FrontGuard step\n 3. Set BITBUCKET_ACCESS_TOKEN as a secured variable in your repo\n 4. Install the package: yarn add -D @cleartrip/frontguard\n\n"
|
|
7271
7390
|
);
|
|
7272
7391
|
}
|
|
7273
7392
|
});
|
|
7274
7393
|
var run = defineCommand({
|
|
7275
7394
|
meta: {
|
|
7276
7395
|
name: "run",
|
|
7277
|
-
description: "Run checks and print the review brief"
|
|
7396
|
+
description: "Run all checks and print the review brief"
|
|
7278
7397
|
},
|
|
7279
7398
|
args: {
|
|
7280
7399
|
markdown: {
|
|
7281
7400
|
type: "boolean",
|
|
7282
|
-
description: "Print markdown only",
|
|
7401
|
+
description: "Print markdown only (no console box)",
|
|
7283
7402
|
default: false
|
|
7284
7403
|
},
|
|
7285
7404
|
enforce: {
|
|
@@ -7289,23 +7408,15 @@ var run = defineCommand({
|
|
|
7289
7408
|
},
|
|
7290
7409
|
append: {
|
|
7291
7410
|
type: "string",
|
|
7292
|
-
description: "Append markdown from a file (
|
|
7411
|
+
description: "Append markdown from a file (e.g. IDE/ChatGPT paste)"
|
|
7293
7412
|
},
|
|
7294
7413
|
htmlOut: {
|
|
7295
7414
|
type: "string",
|
|
7296
|
-
description: "Write interactive HTML report
|
|
7297
|
-
},
|
|
7298
|
-
checksSnapshotOut: {
|
|
7299
|
-
type: "string",
|
|
7300
|
-
description: "Write HTML with only the Checks table (for screenshots / PR comments)"
|
|
7301
|
-
},
|
|
7302
|
-
checksPngOut: {
|
|
7303
|
-
type: "string",
|
|
7304
|
-
description: "Write PNG of the checks table (SVG raster; @resvg/resvg-js, no browser). Pair with --checksSnapshotOut or use alone"
|
|
7415
|
+
description: "Write interactive HTML report to this path"
|
|
7305
7416
|
},
|
|
7306
7417
|
prCommentOut: {
|
|
7307
7418
|
type: "string",
|
|
7308
|
-
description: "Write
|
|
7419
|
+
description: "Write Bitbucket PR comment Markdown to this path"
|
|
7309
7420
|
}
|
|
7310
7421
|
},
|
|
7311
7422
|
run: async ({ args }) => {
|
|
@@ -7315,8 +7426,6 @@ var run = defineCommand({
|
|
|
7315
7426
|
enforce: Boolean(args.enforce),
|
|
7316
7427
|
append: typeof args.append === "string" ? args.append : null,
|
|
7317
7428
|
htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
|
|
7318
|
-
checksSnapshotOut: typeof args.checksSnapshotOut === "string" ? args.checksSnapshotOut : null,
|
|
7319
|
-
checksPngOut: typeof args.checksPngOut === "string" ? args.checksPngOut : null,
|
|
7320
7429
|
prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
|
|
7321
7430
|
});
|
|
7322
7431
|
}
|