@pyreon/cli 0.16.0 → 0.18.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.
@@ -1,4 +1,4 @@
1
- import { AuditRisk, ProjectContext, ProjectContext as ProjectContext$1 } from "@pyreon/compiler";
1
+ import { ProjectContext, ProjectContext as ProjectContext$1 } from "@pyreon/compiler";
2
2
 
3
3
  //#region src/context.d.ts
4
4
  interface ContextOptions {
@@ -7,48 +7,199 @@ interface ContextOptions {
7
7
  }
8
8
  declare function generateContext(options: ContextOptions): Promise<ProjectContext$1>;
9
9
  //#endregion
10
+ //#region src/doctor/types.d.ts
11
+ /**
12
+ * Unified `Finding` + `GateResult` types shared by every doctor gate.
13
+ *
14
+ * Each programmatic gate (`runDistributionGate`, `runDocClaimsGate`, ...)
15
+ * returns `GateResult`. The aggregator in `pyreon doctor` merges every
16
+ * gate's findings into a `DoctorReport` with per-category subscores + an
17
+ * overall 0-100 health score — that aggregation layer lands in the
18
+ * follow-up PR (this PR is foundation-only).
19
+ *
20
+ * Why a unified shape now (PR 1) instead of together with the aggregator
21
+ * (PR 2): the gates are independently usable today via standalone scripts
22
+ * (`bun run check-distribution`, etc.). Locking the shape early means the
23
+ * scripts and the future aggregator consume the same `Finding[]` — no
24
+ * shim layer.
25
+ *
26
+ * Mirrors the existing per-detector shapes (`IslandFinding`, `SsgFinding`,
27
+ * `TestAuditEntry`) but elevated to a cross-gate vocabulary. Categories
28
+ * map onto the five react.doctor-style buckets so the score formula has
29
+ * a clear assignment per gate without case-by-case classification.
30
+ */
31
+ type FindingCategory = 'correctness' | 'performance' | 'architecture' | 'testing' | 'documentation';
32
+ type Severity = 'error' | 'warning' | 'info';
33
+ /**
34
+ * A single actionable diagnostic. Every doctor gate emits Findings in
35
+ * this shape. Aggregation by category + severity drives the health score.
36
+ */
37
+ interface Finding {
38
+ /**
39
+ * Bucket the finding lands in for score aggregation. Each gate picks
40
+ * a default category for its emitted findings; an individual finding
41
+ * may override (e.g. a perf-flavored lint rule would still emit
42
+ * `category: 'performance'` even though the gate is `'lint'`).
43
+ */
44
+ category: FindingCategory;
45
+ /** Severity drives per-finding weight in the score formula. */
46
+ severity: Severity;
47
+ /**
48
+ * Stable code identifying the specific check. Format: `<gate>/<rule>`
49
+ * — e.g. `'audit-types/typed-but-unimplemented'`,
50
+ * `'distribution/missing-sideEffects'`, `'pyreon/for-missing-by'`.
51
+ * Used for filtering + cross-referencing in JSON output.
52
+ */
53
+ code: string;
54
+ /**
55
+ * Identifier of the gate that produced this finding. Useful for
56
+ * grouping in human output and `--skip <gate>` filtering. Examples:
57
+ * `'lint'`, `'audit-types'`, `'check-distribution'`, `'islands-audit'`.
58
+ */
59
+ gate: string;
60
+ /** One-paragraph human-readable explanation, including the fix path. */
61
+ message: string;
62
+ /** Where the finding surfaces. Optional for project-wide findings. */
63
+ location?: {
64
+ /** Absolute path */path: string; /** Path relative to the repo root for readable reporting */
65
+ relPath: string; /** 1-based line number */
66
+ line?: number | undefined; /** 1-based column number */
67
+ column?: number | undefined;
68
+ } | undefined;
69
+ /**
70
+ * Companion locations for cross-file findings (e.g. duplicate-island-
71
+ * name lists the second occurrence). Surfaces in human output below
72
+ * the primary location with an `↳` marker.
73
+ */
74
+ relatedLocations?: Array<{
75
+ path: string;
76
+ relPath: string;
77
+ line?: number | undefined;
78
+ column?: number | undefined;
79
+ label?: string | undefined;
80
+ }> | undefined;
81
+ /** Optional short fix hint shown under the message in human output. */
82
+ fix?: string | undefined;
83
+ /**
84
+ * `true` if `pyreon doctor --fix` can auto-resolve this. Currently
85
+ * limited to lint findings whose rule has an auto-fixer.
86
+ */
87
+ fixable?: boolean | undefined;
88
+ }
89
+ /**
90
+ * Result of running a single doctor gate. The aggregator collects N
91
+ * GateResults and computes the report.
92
+ */
93
+ interface GateResult {
94
+ /** Gate identifier (matches Finding.gate) */
95
+ gate: string;
96
+ /**
97
+ * Default category for findings this gate produces. The aggregator
98
+ * uses this as the fallback when a Finding doesn't override
99
+ * `category` itself — but Finding.category is the source of truth
100
+ * for score attribution.
101
+ */
102
+ category: FindingCategory;
103
+ /** All findings produced by this gate. May be empty. */
104
+ findings: Finding[];
105
+ /** Per-gate metadata for the human + JSON reports. */
106
+ meta: {
107
+ /** Number of files / packages / records the gate scanned. */scanned?: number | undefined; /** Wall-clock duration in milliseconds. */
108
+ elapsedMs: number;
109
+ /**
110
+ * `true` if the gate was skipped (e.g. `--skip <gate>`, missing
111
+ * prerequisite tool, mode-incompatible). The aggregator excludes
112
+ * skipped gates from the score and surfaces them in a "skipped"
113
+ * footer.
114
+ */
115
+ skipped?: boolean | undefined;
116
+ /**
117
+ * Why the gate was skipped (only meaningful when `skipped: true`).
118
+ */
119
+ skipReason?: string | undefined;
120
+ };
121
+ }
122
+ /**
123
+ * Per-category subscore + raw counts. The aggregator builds one
124
+ * `CategoryScore` per `FindingCategory`, then averages them into the
125
+ * overall score. Categories with no findings AND no contributing gates
126
+ * (skipped or filtered out) get `included: false` and are excluded
127
+ * from the mean — keeping a perfect 100 for an unmeasured category
128
+ * would be misleading.
129
+ */
130
+ interface CategoryScore {
131
+ category: FindingCategory;
132
+ /** 0-100 subscore for this bucket */
133
+ score: number;
134
+ errors: number;
135
+ warnings: number;
136
+ infos: number;
137
+ /** Letter grade derived from `score` (A/B/C/D/F) */
138
+ grade: Grade;
139
+ /** False if no gate covered this category — drop from mean */
140
+ included: boolean;
141
+ }
142
+ type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
143
+ /**
144
+ * Final aggregated report `pyreon doctor` produces. The renderer
145
+ * (text / json / gha) consumes this; gate orchestration is upstream.
146
+ */
147
+ interface DoctorReport {
148
+ /** 0-100 weighted mean of included `categories[].score` */
149
+ score: number;
150
+ /** Letter grade for `score` */
151
+ grade: Grade;
152
+ /** Per-category breakdown (always 5 entries — `included` flags coverage) */
153
+ categories: CategoryScore[];
154
+ /** Every gate that ran (or was skipped, with `meta.skipped: true`) */
155
+ gates: GateResult[];
156
+ /** Flat list of all findings across gates, ordered by severity then category */
157
+ findings: Finding[];
158
+ /** Aggregate counts across all findings */
159
+ totals: {
160
+ errors: number;
161
+ warnings: number;
162
+ infos: number;
163
+ };
164
+ /** Top-level wall-clock — sum of gates' elapsedMs (parallel sum, not max) */
165
+ elapsedMs: number;
166
+ /** ISO timestamp of when the report was produced (for diffing across runs) */
167
+ timestamp: string;
168
+ }
169
+ //#endregion
170
+ //#region src/doctor/orchestrator.d.ts
171
+ type GateName = 'react-patterns' | 'pyreon-patterns' | 'lint' | 'distribution' | 'doc-claims' | 'audit-tests' | 'islands-audit' | 'ssg-audit' | 'audit-types' | 'bundle-budgets';
172
+ //#endregion
10
173
  //#region src/doctor.d.ts
174
+ type DoctorFormat = 'text' | 'json' | 'gha';
11
175
  interface DoctorOptions {
12
176
  fix: boolean;
177
+ /** Legacy boolean — interpreted as `format = 'json'` if true. */
13
178
  json: boolean;
14
179
  ci: boolean;
15
180
  cwd: string;
181
+ /** Explicit format override (wins over `json` boolean). */
182
+ format?: DoctorFormat | undefined;
183
+ /** Enable slow gates (audit-types, bundle-budgets). */
184
+ full?: boolean | undefined;
185
+ /** Run ONLY these gates. */
186
+ only?: GateName[] | undefined;
187
+ /** Skip these gates. */
188
+ skip?: GateName[] | undefined;
16
189
  /**
17
- * When true, run the test-environment audit (mock-vnode pattern
18
- * detection) and append the result to the doctor output. Default
19
- * false the audit is scoped to test files only and isn't part of
20
- * the React-migration check pipeline, so we gate it to avoid noise
21
- * in the typical "is my migration done?" call.
190
+ * @deprecated Prefer `--only audit-tests`. Both forms behave
191
+ * identically: include the test-environment audit gate in the
192
+ * report. Kept so existing CI scripts continue to work.
22
193
  */
23
194
  auditTests?: boolean | undefined;
24
- /** Minimum risk level to include in the test-audit report. Default 'medium'. */
25
- auditMinRisk?: AuditRisk | undefined;
26
- /**
27
- * When true, run the project-wide islands audit and append the result
28
- * to the doctor output. Catches cross-file foot-guns (duplicate names,
29
- * dead islands, registry drift, nested islands, never-with-registry)
30
- * that PR G's per-file detector and PR B's auto-registry can't reach
31
- * (manual `hydrateIslands({...})` for non-Vite consumers, library
32
- * authors, multi-package projects). Default false.
33
- */
195
+ /** Minimum risk for the test-environment audit. Default 'medium'. */
196
+ auditMinRisk?: 'high' | 'medium' | 'low' | undefined;
197
+ /** @deprecated Prefer `--only islands-audit`. */
34
198
  checkIslands?: boolean | undefined;
35
- /**
36
- * When true, run the project-wide SSG / ISR audit (M3.4) and append
37
- * the result to the doctor output. Catches:
38
- * - `_404.tsx` not co-located with `_layout.tsx` (PR L5 carve-out)
39
- * - dynamic routes (`[id].tsx`) without `getStaticPaths` (PR A
40
- * silently skips them under `mode: 'ssg'`)
41
- * - `export const revalidate = X` where X isn't a numeric literal
42
- * (PR I's extractor silently drops non-literal forms)
43
- *
44
- * Like the islands audit, this is a "should review" signal — the exit
45
- * code is unaffected (the build doesn't break) but CI can pipe
46
- * `--check-ssg --json` and grep for findings.length > 0 to gate on
47
- * it. Default false.
48
- */
199
+ /** @deprecated Prefer `--only ssg-audit`. */
49
200
  checkSsg?: boolean | undefined;
50
201
  }
51
- declare function doctor(options: DoctorOptions): Promise<number>;
202
+ declare const doctor: (options: DoctorOptions) => Promise<number>;
52
203
  //#endregion
53
- export { type ContextOptions, type DoctorOptions, type ProjectContext, doctor, generateContext };
204
+ export { type ContextOptions, type DoctorOptions, type DoctorReport, type GateName, type ProjectContext, doctor, generateContext };
54
205
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "CLI tools for Pyreon — doctor, generate, context",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/cli#readme",
6
6
  "bugs": {
@@ -46,7 +46,8 @@
46
46
  "prepublishOnly": "bun run build"
47
47
  },
48
48
  "dependencies": {
49
- "@pyreon/compiler": "^0.16.0"
49
+ "@pyreon/compiler": "^0.18.0",
50
+ "@pyreon/lint": "^0.18.0"
50
51
  },
51
52
  "peerDependencies": {
52
53
  "typescript": ">=5.0.0"
@@ -0,0 +1,70 @@
1
+ /**
2
+ * audit-tests gate — wraps `@pyreon/compiler:auditTestEnvironment`.
3
+ *
4
+ * Catches mock-vnode test patterns (the PR #197 bug class — tests
5
+ * that hand-construct `{ type, props, children }` literals or use a
6
+ * `vnode()` helper instead of going through real `h()` from
7
+ * `@pyreon/core`). Three risk tiers (HIGH / MEDIUM / LOW) from the
8
+ * balance of mockVNodeLiteralCount + mockHelperCount +
9
+ * mockHelperCallCount + realHCallCount + importsH. The adapter
10
+ * maps tier → severity (high=error, medium=warning, low=info).
11
+ */
12
+
13
+ import { auditTestEnvironment } from '@pyreon/compiler'
14
+ import type { AuditRisk } from '@pyreon/compiler'
15
+
16
+ import type { Finding, GateResult, Severity } from '../types'
17
+
18
+ const SEVERITY_BY_RISK: Record<AuditRisk, Severity> = {
19
+ high: 'error',
20
+ medium: 'warning',
21
+ low: 'info',
22
+ }
23
+
24
+ export interface AuditTestsGateOptions {
25
+ cwd: string
26
+ /** Minimum risk to surface. Defaults to `'medium'`. */
27
+ minRisk?: AuditRisk
28
+ }
29
+
30
+ const RISK_RANK: Record<AuditRisk, number> = {
31
+ high: 3,
32
+ medium: 2,
33
+ low: 1,
34
+ }
35
+
36
+ export const runAuditTestsGate = async (
37
+ opts: AuditTestsGateOptions,
38
+ ): Promise<GateResult> => {
39
+ const start = Date.now()
40
+ const findings: Finding[] = []
41
+ const minRisk = opts.minRisk ?? 'medium'
42
+ const minRank = RISK_RANK[minRisk] ?? 0
43
+
44
+ const result = auditTestEnvironment(opts.cwd)
45
+
46
+ for (const entry of result.entries) {
47
+ const rank = RISK_RANK[entry.risk] ?? 0
48
+ if (rank < minRank) continue
49
+ const severity = SEVERITY_BY_RISK[entry.risk] ?? 'warning'
50
+
51
+ findings.push({
52
+ category: 'testing',
53
+ severity,
54
+ code: `audit-tests/mock-vnode-${entry.risk}`,
55
+ gate: 'audit-tests',
56
+ message: `Mock-vnode test pattern (risk: ${entry.risk}). Literals: ${entry.mockVNodeLiteralCount}, helper defs: ${entry.mockHelperCount}, helper calls: ${entry.mockHelperCallCount}, real h() calls: ${entry.realHCallCount}. ${entry.realHCallCount === 0 ? 'No real-h() coverage — every contract assertion is mock-only.' : 'Has real-h() coverage but mock-vnode patterns still dominate.'}`,
57
+ location: { path: entry.path, relPath: entry.relPath },
58
+ })
59
+ }
60
+
61
+ return {
62
+ gate: 'audit-tests',
63
+ category: 'testing',
64
+ findings,
65
+ meta: {
66
+ scanned: result.entries.length,
67
+ elapsedMs: Date.now() - start,
68
+ },
69
+ }
70
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * audit-types gate — programmatic API.
3
+ *
4
+ * Catches typed-but-unimplemented public-interface fields. Walks every
5
+ * exported interface in each high-risk package and counts non-type
6
+ * references; fields with zero references are flagged HIGH. Catches the
7
+ * 0.14.0-class bug (`mode: "ssg"` typed but never read by runtime).
8
+ *
9
+ * **Implementation note (subprocess adapter).** This gate invokes the
10
+ * standalone `scripts/audit-types.ts` script via `--json --all` and
11
+ * parses the output. The script is 476 lines of mature AST-walking
12
+ * logic with its own test suite; rather than surgically extract it
13
+ * mid-shape, the adapter shape keeps PR 1 tractable and lets PR 2's
14
+ * aggregation layer consume the same `Finding[]` shape as the other
15
+ * gates. Adapter cost is ~50ms subprocess overhead — noise within the
16
+ * gate's 1-5s scan runtime. Full extraction is a deferred follow-up
17
+ * (the doctor aggregator doesn't care HOW the gate runs).
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process'
21
+ import { join } from 'node:path'
22
+ import type { Finding, GateResult, Severity } from '../types'
23
+
24
+ interface ScriptFieldFinding {
25
+ package: string
26
+ interface: string
27
+ field: string
28
+ declaredIn: string
29
+ declaredLine: number
30
+ refCount: number
31
+ severity: 'HIGH' | 'MEDIUM' | 'LOW' | 'OK'
32
+ }
33
+
34
+ interface ScriptAuditResult {
35
+ package: string
36
+ packageDir: string
37
+ findings: ScriptFieldFinding[]
38
+ }
39
+
40
+ const mapSeverity = (s: ScriptFieldFinding['severity']): Severity | null => {
41
+ switch (s) {
42
+ case 'HIGH':
43
+ return 'error'
44
+ case 'MEDIUM':
45
+ return 'warning'
46
+ case 'LOW':
47
+ return 'info'
48
+ case 'OK':
49
+ return null // suppress — field has references, not a finding
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Pure parse-and-map function — public so tests can exercise the JSON
55
+ * → `Finding[]` translation without spawning a subprocess. Returns the
56
+ * findings plus the count of packages scanned. Exported as `_internal`
57
+ * (unstable API surface — may move when PR 2 lands the aggregator).
58
+ */
59
+ export const _parseAuditTypesOutput = (
60
+ raw: string,
61
+ cwd: string,
62
+ ): { findings: Finding[]; scanned: number } => {
63
+ const results = JSON.parse(raw) as ScriptAuditResult[]
64
+ const findings: Finding[] = []
65
+ for (const r of results) {
66
+ for (const f of r.findings) {
67
+ const severity = mapSeverity(f.severity)
68
+ if (severity === null) continue
69
+
70
+ findings.push({
71
+ category: 'architecture',
72
+ severity,
73
+ code: `audit-types/typed-but-unimplemented-${f.severity.toLowerCase()}`,
74
+ gate: 'audit-types',
75
+ message: `${f.package}: \`${f.interface}.${f.field}\` is typed in the public API but has ${f.refCount} non-type reference(s) in the package — likely typed-but-unimplemented.`,
76
+ location: {
77
+ path: join(cwd, f.declaredIn),
78
+ relPath: f.declaredIn,
79
+ line: f.declaredLine,
80
+ },
81
+ })
82
+ }
83
+ }
84
+ return { findings, scanned: results.length }
85
+ }
86
+
87
+ export interface AuditTypesGateOptions {
88
+ /** Repository root directory */
89
+ cwd: string
90
+ /** Path to bun executable. Defaults to `'bun'`. */
91
+ bun?: string
92
+ /**
93
+ * Specific packages to audit. Defaults to `--all` (high-risk list
94
+ * baked into the script: zero, router, core, server, runtime-server,
95
+ * vite-plugin).
96
+ */
97
+ packages?: string[]
98
+ }
99
+
100
+ export const runAuditTypesGate = async (
101
+ opts: AuditTypesGateOptions,
102
+ ): Promise<GateResult> => {
103
+ const start = Date.now()
104
+ const findings: Finding[] = []
105
+ const scriptPath = join(opts.cwd, 'scripts/audit-types.ts')
106
+ const args = ['run', scriptPath, '--json']
107
+ if (opts.packages && opts.packages.length > 0) {
108
+ args.push(...opts.packages)
109
+ } else {
110
+ args.push('--all')
111
+ }
112
+
113
+ let scannedPackages = 0
114
+ try {
115
+ const out = execFileSync(opts.bun ?? 'bun', args, {
116
+ cwd: opts.cwd,
117
+ encoding: 'utf8',
118
+ stdio: ['pipe', 'pipe', 'pipe'],
119
+ maxBuffer: 16 * 1024 * 1024, // 16MB — audit can produce large output
120
+ })
121
+ const parsed = _parseAuditTypesOutput(out, opts.cwd)
122
+ findings.push(...parsed.findings)
123
+ scannedPackages = parsed.scanned
124
+ } catch (err) {
125
+ // Script failure — surface as a single ERROR finding so the
126
+ // gate doesn't silently skip. Captures "script not found",
127
+ // parse errors, exec errors, etc.
128
+ findings.push({
129
+ category: 'architecture',
130
+ severity: 'error',
131
+ code: 'audit-types/gate-failed',
132
+ gate: 'audit-types',
133
+ message: `audit-types gate failed to run: ${(err as Error).message}`,
134
+ })
135
+ }
136
+
137
+ return {
138
+ gate: 'audit-types',
139
+ category: 'architecture',
140
+ findings,
141
+ meta: {
142
+ scanned: scannedPackages,
143
+ elapsedMs: Date.now() - start,
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * bundle-budgets gate — programmatic API.
3
+ *
4
+ * Locks the gzipped main-entry size of every published `@pyreon/*`
5
+ * package against `scripts/bundle-budgets.json` (current + 25% headroom).
6
+ * Three classes of finding land here:
7
+ *
8
+ * 1. **violations** — package bundles past its budget (real regression).
9
+ * Severity: `error`. Code: `bundle-budgets/over-budget`.
10
+ * 2. **missing** — package has no entry in `bundle-budgets.json` yet
11
+ * (new published package — author needs to commit a budget).
12
+ * Severity: `warning`. Code: `bundle-budgets/missing-budget`.
13
+ * 3. **failures** — the bundler couldn't measure a package (unresolved
14
+ * transitive dep, build artifact issue). Severity: `error`. Code:
15
+ * `bundle-budgets/bundle-failed`. Surfaced as a finding rather than
16
+ * silently dropped — same lesson as PR #434.
17
+ *
18
+ * **Implementation note (subprocess adapter).** This gate invokes the
19
+ * standalone `scripts/check-bundle-budgets.ts` script via `--json` and
20
+ * parses the output. The script is 466 lines of bundler orchestration
21
+ * + AST-walking dep collection logic; extracting it surgically into a
22
+ * pure function carries too much risk for PR 1. The adapter shape lets
23
+ * the doctor aggregator consume the same `Finding[]` shape as the other
24
+ * gates; full extraction is a deferred follow-up (`pyreon doctor`
25
+ * doesn't care HOW the gate runs — only that it returns `GateResult`).
26
+ *
27
+ * The full-bundle measurement is the slowest gate (~15-30s against
28
+ * 50+ published packages). Doctor's default fast mode opts this gate
29
+ * OUT; `--full` enables it.
30
+ */
31
+
32
+ import { execFileSync } from 'node:child_process'
33
+ import { join } from 'node:path'
34
+ import type { Finding, GateResult } from '../types'
35
+
36
+ interface ScriptViolation {
37
+ name: string
38
+ current: number
39
+ budget: number
40
+ overBy: number
41
+ overByPct: number
42
+ }
43
+
44
+ interface ScriptMissing {
45
+ name: string
46
+ current: number
47
+ }
48
+
49
+ interface ScriptFailure {
50
+ name: string
51
+ error: string
52
+ }
53
+
54
+ interface ScriptMeasured {
55
+ name: string
56
+ raw: number
57
+ gzip: number
58
+ }
59
+
60
+ interface ScriptOutput {
61
+ violations: ScriptViolation[]
62
+ missing: ScriptMissing[]
63
+ failures: ScriptFailure[]
64
+ measured: ScriptMeasured[]
65
+ }
66
+
67
+ const formatKB = (bytes: number): string => `${(bytes / 1024).toFixed(2)} KB`
68
+
69
+ /**
70
+ * Pure parse-and-map function — public so tests can exercise the JSON
71
+ * → `Finding[]` translation without spawning a subprocess. Returns the
72
+ * findings plus the count of packages scanned (measured + failures).
73
+ * Exported as `_internal` (unstable API surface — may move when PR 2
74
+ * lands the aggregator).
75
+ */
76
+ export const _parseBundleBudgetsOutput = (
77
+ raw: string,
78
+ cwd: string,
79
+ ): { findings: Finding[]; scanned: number } => {
80
+ const result = JSON.parse(raw) as ScriptOutput
81
+ const findings: Finding[] = []
82
+ const budgetsRelPath = 'scripts/bundle-budgets.json'
83
+ const budgetsPath = join(cwd, budgetsRelPath)
84
+
85
+ for (const v of result.violations) {
86
+ findings.push({
87
+ category: 'performance',
88
+ severity: 'error',
89
+ code: 'bundle-budgets/over-budget',
90
+ gate: 'bundle-budgets',
91
+ message: `${v.name}: ${formatKB(v.current)} > budget ${formatKB(v.budget)} (over by ${formatKB(v.overBy)}, +${v.overByPct.toFixed(1)}%). If growth is intentional, bump the value in scripts/bundle-budgets.json — the bump itself is the PR signal.`,
92
+ location: { path: budgetsPath, relPath: budgetsRelPath },
93
+ fix: `Run \`bun run check-bundle-budgets --update\` to regenerate budgets after intentional growth.`,
94
+ })
95
+ }
96
+
97
+ for (const m of result.missing) {
98
+ findings.push({
99
+ category: 'performance',
100
+ severity: 'warning',
101
+ code: 'bundle-budgets/missing-budget',
102
+ gate: 'bundle-budgets',
103
+ message: `${m.name}: ${formatKB(m.current)} (no budget entry). New published package?`,
104
+ location: { path: budgetsPath, relPath: budgetsRelPath },
105
+ fix: `Run \`bun run check-bundle-budgets --update\` and review the diff.`,
106
+ })
107
+ }
108
+
109
+ for (const f of result.failures) {
110
+ findings.push({
111
+ category: 'performance',
112
+ severity: 'error',
113
+ code: 'bundle-budgets/bundle-failed',
114
+ gate: 'bundle-budgets',
115
+ message: `${f.name}: bundle failed — ${f.error.split('\n')[0]}. Likely an unresolved third-party dep that the auto-external scan missed.`,
116
+ })
117
+ }
118
+
119
+ return {
120
+ findings,
121
+ scanned: result.measured.length + result.failures.length,
122
+ }
123
+ }
124
+
125
+ export interface BundleBudgetsGateOptions {
126
+ /** Repository root directory */
127
+ cwd: string
128
+ /** Path to bun executable. Defaults to `'bun'`. */
129
+ bun?: string
130
+ }
131
+
132
+ export const runBundleBudgetsGate = async (
133
+ opts: BundleBudgetsGateOptions,
134
+ ): Promise<GateResult> => {
135
+ const start = Date.now()
136
+ const findings: Finding[] = []
137
+ const scriptPath = join(opts.cwd, 'scripts/check-bundle-budgets.ts')
138
+
139
+ let scannedPackages = 0
140
+ try {
141
+ // The script always exits 1 when there are violations/missing/failures
142
+ // — but writes valid JSON to stdout regardless. Use a try/catch to
143
+ // capture stdout from the non-zero exit. `execFileSync` throws on
144
+ // non-zero, attaching `.stdout` to the error object.
145
+ let out: string
146
+ try {
147
+ out = execFileSync(opts.bun ?? 'bun', ['run', scriptPath, '--json'], {
148
+ cwd: opts.cwd,
149
+ encoding: 'utf8',
150
+ stdio: ['pipe', 'pipe', 'pipe'],
151
+ maxBuffer: 16 * 1024 * 1024,
152
+ })
153
+ } catch (err) {
154
+ const e = err as { stdout?: string | Buffer; message?: string }
155
+ if (e.stdout) {
156
+ out = typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf8')
157
+ } else {
158
+ throw err
159
+ }
160
+ }
161
+
162
+ const parsed = _parseBundleBudgetsOutput(out, opts.cwd)
163
+ findings.push(...parsed.findings)
164
+ scannedPackages = parsed.scanned
165
+ } catch (err) {
166
+ // Script failure — surface as a single ERROR finding so the
167
+ // gate doesn't silently skip. Captures parse errors, script-not-
168
+ // found, missing bundle-budgets.json, etc.
169
+ findings.push({
170
+ category: 'performance',
171
+ severity: 'error',
172
+ code: 'bundle-budgets/gate-failed',
173
+ gate: 'bundle-budgets',
174
+ message: `bundle-budgets gate failed to run: ${(err as Error).message}`,
175
+ })
176
+ }
177
+
178
+ return {
179
+ gate: 'bundle-budgets',
180
+ category: 'performance',
181
+ findings,
182
+ meta: {
183
+ scanned: scannedPackages,
184
+ elapsedMs: Date.now() - start,
185
+ },
186
+ }
187
+ }