@pyreon/cli 0.15.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.
@@ -0,0 +1,674 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ import {
8
+ runAuditTypesGate,
9
+ runBundleBudgetsGate,
10
+ runDistributionGate,
11
+ runDocClaimsGate,
12
+ } from '../doctor/gates'
13
+ import { _parseAuditTypesOutput } from '../doctor/gates/audit-types'
14
+ import { _parseBundleBudgetsOutput } from '../doctor/gates/bundle-budgets'
15
+ import { _detectMapsInPackOutput } from '../doctor/gates/distribution'
16
+ import type { Finding, GateResult, Severity } from '../doctor/types'
17
+ import { finding } from '../doctor/types'
18
+
19
+ /**
20
+ * Shape contract for every doctor gate. These tests assert the
21
+ * `GateResult` invariants so the PR 2 aggregator can rely on them
22
+ * without per-gate special-casing.
23
+ */
24
+ function assertGateResultShape(result: GateResult, gate: string): void {
25
+ expect(result.gate).toBe(gate)
26
+ expect(typeof result.category).toBe('string')
27
+ expect(Array.isArray(result.findings)).toBe(true)
28
+ expect(typeof result.meta.elapsedMs).toBe('number')
29
+ expect(result.meta.elapsedMs).toBeGreaterThanOrEqual(0)
30
+ for (const f of result.findings) {
31
+ assertFindingShape(f, gate)
32
+ }
33
+ }
34
+
35
+ function assertFindingShape(f: Finding, gate: string): void {
36
+ expect(f.gate).toBe(gate)
37
+ expect(typeof f.code).toBe('string')
38
+ expect(f.code).toMatch(new RegExp(`^${gate}/`))
39
+ expect(typeof f.message).toBe('string')
40
+ expect(f.message.length).toBeGreaterThan(0)
41
+ const sev: Severity[] = ['error', 'warning', 'info']
42
+ expect(sev).toContain(f.severity)
43
+ }
44
+
45
+ // Vitest runs the file without `import.meta.dir`; resolve from the
46
+ // known package location relative to the workspace root via a stable
47
+ // anchor (`packages/tools/cli` -> 3 levels up to repo root).
48
+ const REPO_ROOT = path.resolve(
49
+ path.dirname(new URL(import.meta.url).pathname),
50
+ '..',
51
+ '..',
52
+ '..',
53
+ '..',
54
+ '..',
55
+ )
56
+
57
+ describe('runDistributionGate', () => {
58
+ it('returns clean GateResult shape against real repo', async () => {
59
+ // skipPackProbe: true — the live `npm pack --dry-run` is slow
60
+ // under CI parallel load (100s+ vs ~5s standalone). The probe's
61
+ // .map-detection logic is covered separately by the
62
+ // _detectMapsInPackOutput unit tests below; coverage for the
63
+ // execFileSync invocation itself isn't worth the timeout risk.
64
+ const result = await runDistributionGate({
65
+ cwd: REPO_ROOT,
66
+ skipPackProbe: true,
67
+ })
68
+ assertGateResultShape(result, 'distribution')
69
+ expect(result.category).toBe('architecture')
70
+ expect(result.meta.scanned).toBeGreaterThan(0)
71
+ })
72
+
73
+ it('runs the probe block but no-ops when probePackage is missing', async () => {
74
+ // skipPackProbe: false (default-on path) + probePackage that
75
+ // doesn't exist in the synthetic repo → `packages.find(...)`
76
+ // returns undefined → the inner try block is skipped without
77
+ // spawning npm. Covers the `if (!opts.skipPackProbe)` +
78
+ // `const probe = packages.find(...)` + `if (probe)` branches
79
+ // without depending on the npm subprocess (which times out on
80
+ // CI parallel load).
81
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-probe-'))
82
+ fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'ok'), {
83
+ recursive: true,
84
+ })
85
+ fs.writeFileSync(
86
+ path.join(tmp, 'packages', 'fundamentals', 'ok', 'package.json'),
87
+ JSON.stringify({
88
+ name: '@pyreon/ok',
89
+ sideEffects: false,
90
+ files: ['lib', '!lib/**/*.map'],
91
+ }),
92
+ )
93
+
94
+ const result = await runDistributionGate({
95
+ cwd: tmp,
96
+ probePackage: '@pyreon/does-not-exist',
97
+ // skipPackProbe omitted → defaults to false
98
+ })
99
+ assertGateResultShape(result, 'distribution')
100
+ expect(result.findings).toEqual([])
101
+
102
+ fs.rmSync(tmp, { recursive: true, force: true })
103
+ })
104
+
105
+ it('emits findings with the expected code prefixes when invariants fail', async () => {
106
+ // Create a synthetic broken package: published (no `private`),
107
+ // missing both sideEffects AND files-array map exclusion.
108
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-gate-'))
109
+ fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'broken'), {
110
+ recursive: true,
111
+ })
112
+ fs.writeFileSync(
113
+ path.join(tmp, 'packages', 'fundamentals', 'broken', 'package.json'),
114
+ JSON.stringify({ name: '@pyreon/broken', files: ['lib'] }),
115
+ )
116
+
117
+ const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
118
+ assertGateResultShape(result, 'distribution')
119
+
120
+ const codes = result.findings.map((f) => f.code).sort()
121
+ expect(codes).toContain('distribution/missing-sideEffects')
122
+ expect(codes).toContain('distribution/missing-map-exclusion')
123
+ for (const f of result.findings) {
124
+ expect(f.severity).toBe('error')
125
+ expect(f.message).toContain('@pyreon/broken')
126
+ }
127
+
128
+ fs.rmSync(tmp, { recursive: true, force: true })
129
+ })
130
+ })
131
+
132
+ describe('runDocClaimsGate', () => {
133
+ it('returns GateResult against real repo with expected shape', async () => {
134
+ const result = await runDocClaimsGate({ cwd: REPO_ROOT })
135
+ assertGateResultShape(result, 'doc-claims')
136
+ expect(result.category).toBe('documentation')
137
+ // The real repo has 7 claim sites configured today; verify the
138
+ // scanner found them all (any missing files would surface as
139
+ // file-missing findings, scanned count stays stable).
140
+ expect(result.meta.scanned).toBe(7)
141
+ })
142
+
143
+ // Helpers to build a tmp repo with the hooks claim sources the
144
+ // doc-claims gate walks. Used by the drift / hedged / pattern-miss
145
+ // path-covering tests below.
146
+ function buildHooksRepo(
147
+ tmp: string,
148
+ opts: {
149
+ hookCount: number
150
+ readmeClaim?: string | null
151
+ manifestTagline?: string | null
152
+ claudeMdRow?: string | null
153
+ claudeMdArch?: string | null
154
+ docsIndex?: string | null
155
+ },
156
+ ): void {
157
+ const hooksDir = path.join(tmp, 'packages', 'fundamentals', 'hooks')
158
+ fs.mkdirSync(path.join(hooksDir, 'src'), { recursive: true })
159
+
160
+ // Generate an index.ts with N `export { useX }` lines so the
161
+ // gate's countHookExports() returns hookCount. The regex requires
162
+ // `use[A-Z][a-zA-Z]+` so use letter-only suffixes (no digits).
163
+ const letters = 'abcdefghijklmnopqrstuvwxyz'
164
+ const exports = Array.from(
165
+ { length: opts.hookCount },
166
+ (_, i) => {
167
+ const name = (letters[i] ?? `Z${i}`).toUpperCase() + letters[i] + 'ook'
168
+ return `export { useH${name} }`
169
+ },
170
+ ).join('\n')
171
+ fs.writeFileSync(
172
+ path.join(hooksDir, 'src', 'index.ts'),
173
+ exports + '\n',
174
+ )
175
+
176
+ if (opts.readmeClaim !== null) {
177
+ fs.writeFileSync(
178
+ path.join(hooksDir, 'README.md'),
179
+ opts.readmeClaim ?? `${opts.hookCount} signal-based reactive utilities\n`,
180
+ )
181
+ }
182
+
183
+ if (opts.manifestTagline !== null) {
184
+ fs.writeFileSync(
185
+ path.join(hooksDir, 'src', 'manifest.ts'),
186
+ opts.manifestTagline ??
187
+ `tagline: '${opts.hookCount} signal-based hooks: foo'\nSignal-based hooks for Pyreon — ${opts.hookCount} reactive primitives\n`,
188
+ )
189
+ }
190
+
191
+ // CLAUDE.md carries TWO claim sites (table row + arch section)
192
+ fs.writeFileSync(
193
+ path.join(tmp, 'CLAUDE.md'),
194
+ `${opts.claudeMdRow ?? `| \`@pyreon/hooks\` | ${opts.hookCount} signal-based hooks for stuff |`}\n${
195
+ opts.claudeMdArch ?? `- ${opts.hookCount} signal-based hooks across 6 categories`
196
+ }\n3 doc pages covering all packages\n`,
197
+ )
198
+
199
+ // docs/docs/index.md carries one
200
+ fs.mkdirSync(path.join(tmp, 'docs', 'docs'), { recursive: true })
201
+ fs.writeFileSync(
202
+ path.join(tmp, 'docs', 'docs', 'index.md'),
203
+ opts.docsIndex ?? `| ${opts.hookCount} signal-based hooks for common UI patterns\n`,
204
+ )
205
+ }
206
+
207
+ it('emits drift finding when claim count diverges from actual', async () => {
208
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-drift-'))
209
+ // Actual export count is 3, but the README hard-codes 99.
210
+ buildHooksRepo(tmp, {
211
+ hookCount: 3,
212
+ readmeClaim: '99 signal-based reactive utilities\n',
213
+ })
214
+
215
+ const result = await runDocClaimsGate({ cwd: tmp })
216
+ assertGateResultShape(result, 'doc-claims')
217
+ const drift = result.findings.find((f) =>
218
+ f.code.endsWith('-drift'),
219
+ )!
220
+ expect(drift).toBeDefined()
221
+ expect(drift.severity).toBe('error')
222
+ expect(drift.message).toContain('claims 99')
223
+ expect(drift.message).toContain('actual 3')
224
+ expect(drift.fix).toContain('99 to 3')
225
+
226
+ fs.rmSync(tmp, { recursive: true, force: true })
227
+ })
228
+
229
+ it('emits hedged finding when CLAUDE.md uses "N+" form', async () => {
230
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-hedged-'))
231
+ // 3 actual exports; CLAUDE.md row uses the rejected hedged form.
232
+ buildHooksRepo(tmp, {
233
+ hookCount: 3,
234
+ claudeMdRow:
235
+ '| `@pyreon/hooks` | 3+ signal-based hooks for stuff |',
236
+ })
237
+
238
+ const result = await runDocClaimsGate({ cwd: tmp })
239
+ assertGateResultShape(result, 'doc-claims')
240
+ const hedged = result.findings.find((f) =>
241
+ f.code.endsWith('-hedged'),
242
+ )!
243
+ expect(hedged).toBeDefined()
244
+ expect(hedged.severity).toBe('error')
245
+ expect(hedged.message).toContain('hedged claim')
246
+ expect(hedged.fix).toContain('"3+"')
247
+
248
+ fs.rmSync(tmp, { recursive: true, force: true })
249
+ })
250
+
251
+ it('emits pattern-miss warning when the claim file lost its pattern', async () => {
252
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-miss-'))
253
+ // Build a repo where the README exists but doesn't contain the
254
+ // pattern at all — claim was rephrased / removed.
255
+ buildHooksRepo(tmp, {
256
+ hookCount: 3,
257
+ readmeClaim: 'Hooks library for Pyreon. No numeric claim here.\n',
258
+ })
259
+
260
+ const result = await runDocClaimsGate({ cwd: tmp })
261
+ assertGateResultShape(result, 'doc-claims')
262
+ const miss = result.findings.find((f) =>
263
+ f.code === 'doc-claims/hook-count-pattern-miss',
264
+ )!
265
+ expect(miss).toBeDefined()
266
+ expect(miss.severity).toBe('warning')
267
+ expect(miss.message).toContain('pattern not found')
268
+
269
+ fs.rmSync(tmp, { recursive: true, force: true })
270
+ })
271
+
272
+ it('emits file-missing finding when a claim file is absent (but at least one exists)', async () => {
273
+ // Build a minimal fake repo with the source-of-truth file AND at
274
+ // least one claim file present — that's the "this IS a Pyreon
275
+ // project, but some claim sites have been deleted/moved" shape.
276
+ // Gate walks claims[] and emits file-missing for each absent one.
277
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-gate-'))
278
+ fs.mkdirSync(
279
+ path.join(tmp, 'packages', 'fundamentals', 'hooks', 'src'),
280
+ { recursive: true },
281
+ )
282
+ fs.writeFileSync(
283
+ path.join(tmp, 'packages', 'fundamentals', 'hooks', 'src', 'index.ts'),
284
+ '',
285
+ )
286
+ // Plant one claim file (CLAUDE.md) so the gate doesn't skip.
287
+ // Content is empty so its claims trigger pattern-miss (warning),
288
+ // not file-missing. All OTHER claim files remain absent and
289
+ // produce file-missing (error) findings.
290
+ fs.writeFileSync(path.join(tmp, 'CLAUDE.md'), '')
291
+
292
+ const result = await runDocClaimsGate({ cwd: tmp })
293
+ assertGateResultShape(result, 'doc-claims')
294
+ expect(result.meta.skipped).not.toBe(true)
295
+ const fileMissing = result.findings.filter((f) =>
296
+ f.code.endsWith('-file-missing'),
297
+ )
298
+ expect(fileMissing.length).toBeGreaterThan(0)
299
+ for (const f of fileMissing) {
300
+ expect(f.severity).toBe('error')
301
+ expect(f.location?.relPath).toBeTruthy()
302
+ }
303
+
304
+ fs.rmSync(tmp, { recursive: true, force: true })
305
+ })
306
+
307
+ it('skips the gate when no claim files exist (non-Pyreon project)', async () => {
308
+ // A downstream consumer app has none of the Pyreon-monorepo claim
309
+ // sites (CLAUDE.md, hooks README, docs/docs/index.md, …). The gate
310
+ // recognises this and returns skipped:true rather than flooding
311
+ // findings with spurious file-missing errors for paths that don't
312
+ // apply to the user's project.
313
+ const tmp = fs.mkdtempSync(
314
+ path.join(os.tmpdir(), 'pyreon-claims-gate-skip-'),
315
+ )
316
+ // Tmp dir is empty — no Pyreon-shaped files anywhere.
317
+
318
+ const result = await runDocClaimsGate({ cwd: tmp })
319
+ assertGateResultShape(result, 'doc-claims')
320
+ expect(result.meta.skipped).toBe(true)
321
+ expect(result.meta.skipReason).toContain('no claim sites')
322
+ expect(result.findings).toHaveLength(0)
323
+
324
+ fs.rmSync(tmp, { recursive: true, force: true })
325
+ })
326
+ })
327
+
328
+ describe('runAuditTypesGate', () => {
329
+ // The live invocation against the real repo is intentionally NOT
330
+ // a unit test — the subprocess walks every public interface across
331
+ // 6 high-risk packages via the TS compiler API; even with warm
332
+ // caches it runs ~1s locally but ~30s+ under CI parallel load,
333
+ // tripping the test timeout. The standalone script
334
+ // `scripts/audit-types.ts` has its own CI gate (`Audit Types`)
335
+ // which exercises the live path; this test layer locks the
336
+ // GateResult shape contract via the failure-path spec below
337
+ // (the assertGateResultShape() helper fires either way).
338
+
339
+ it('surfaces gate-failed finding when script is unreachable', async () => {
340
+ // Point cwd at a directory with no scripts/audit-types.ts. The
341
+ // subprocess fails; the gate emits a single gate-failed finding
342
+ // rather than throwing.
343
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-audit-gate-'))
344
+ const result = await runAuditTypesGate({ cwd: tmp })
345
+ assertGateResultShape(result, 'audit-types')
346
+ const failed = result.findings.find(
347
+ (f) => f.code === 'audit-types/gate-failed',
348
+ )
349
+ expect(failed).toBeDefined()
350
+ expect(failed?.severity).toBe('error')
351
+ fs.rmSync(tmp, { recursive: true, force: true })
352
+ })
353
+ })
354
+
355
+ describe('finding() helper', () => {
356
+ it('returns the passed Finding unchanged (identity helper for readability)', () => {
357
+ const f = finding({
358
+ category: 'correctness',
359
+ severity: 'error',
360
+ code: 'test/example',
361
+ gate: 'test',
362
+ message: 'example',
363
+ })
364
+ expect(f.category).toBe('correctness')
365
+ expect(f.severity).toBe('error')
366
+ expect(f.code).toBe('test/example')
367
+ })
368
+ })
369
+
370
+ describe('runDistributionGate — findPackages error paths', () => {
371
+ it('tolerates non-directory entries under packages/* (unreadable catDir)', async () => {
372
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-dirread-'))
373
+ fs.mkdirSync(path.join(tmp, 'packages'), { recursive: true })
374
+ // Put a regular file where readdirSync(packages/file) would fail.
375
+ fs.writeFileSync(path.join(tmp, 'packages', 'notadir'), '')
376
+
377
+ // Should NOT throw — the catch wraps readdirSync(catDir) so the
378
+ // walker just skips the non-directory entry.
379
+ const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
380
+ assertGateResultShape(result, 'distribution')
381
+ expect(result.meta.scanned).toBe(0)
382
+
383
+ fs.rmSync(tmp, { recursive: true, force: true })
384
+ })
385
+
386
+ it('skips packages with invalid JSON package.json', async () => {
387
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-badpj-'))
388
+ fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'bad'), {
389
+ recursive: true,
390
+ })
391
+ fs.writeFileSync(
392
+ path.join(tmp, 'packages', 'fundamentals', 'bad', 'package.json'),
393
+ 'NOT JSON{{{',
394
+ )
395
+
396
+ const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
397
+ assertGateResultShape(result, 'distribution')
398
+ expect(result.meta.scanned).toBe(0)
399
+
400
+ fs.rmSync(tmp, { recursive: true, force: true })
401
+ })
402
+
403
+ it('skips private packages', async () => {
404
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-private-'))
405
+ fs.mkdirSync(path.join(tmp, 'packages', 'internals', 'priv'), {
406
+ recursive: true,
407
+ })
408
+ fs.writeFileSync(
409
+ path.join(tmp, 'packages', 'internals', 'priv', 'package.json'),
410
+ JSON.stringify({ name: '@pyreon/priv', private: true }),
411
+ )
412
+
413
+ const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
414
+ assertGateResultShape(result, 'distribution')
415
+ // Private package is excluded from the scanned count + emits no
416
+ // findings (the gate only walks published packages).
417
+ expect(result.meta.scanned).toBe(0)
418
+ expect(result.findings).toEqual([])
419
+
420
+ fs.rmSync(tmp, { recursive: true, force: true })
421
+ })
422
+ })
423
+
424
+ describe('_detectMapsInPackOutput', () => {
425
+ const probe = { dir: '/repo/packages/core/reactivity' }
426
+ const cwd = '/repo'
427
+
428
+ it('returns null when the tarball has no .map files', () => {
429
+ const raw = JSON.stringify([
430
+ {
431
+ files: [
432
+ { path: 'package.json' },
433
+ { path: 'lib/index.js' },
434
+ { path: 'lib/index.d.ts' },
435
+ ],
436
+ },
437
+ ])
438
+ const result = _detectMapsInPackOutput(
439
+ raw,
440
+ cwd,
441
+ probe,
442
+ '@pyreon/reactivity',
443
+ )
444
+ expect(result).toBeNull()
445
+ })
446
+
447
+ it('emits tarball-contains-map finding when .map files leak in', () => {
448
+ const raw = JSON.stringify([
449
+ {
450
+ files: [
451
+ { path: 'lib/index.js' },
452
+ { path: 'lib/index.js.map' },
453
+ { path: 'lib/types/index.d.ts.map' },
454
+ ],
455
+ },
456
+ ])
457
+ const result = _detectMapsInPackOutput(
458
+ raw,
459
+ cwd,
460
+ probe,
461
+ '@pyreon/reactivity',
462
+ )
463
+ expect(result).not.toBeNull()
464
+ expect(result!.code).toBe('distribution/tarball-contains-map')
465
+ expect(result!.severity).toBe('error')
466
+ expect(result!.gate).toBe('distribution')
467
+ expect(result!.message).toContain('@pyreon/reactivity')
468
+ expect(result!.message).toContain('2 .map file(s)')
469
+ expect(result!.message).toContain('lib/index.js.map')
470
+ expect(result!.location?.relPath).toBe(
471
+ 'packages/core/reactivity/package.json',
472
+ )
473
+ })
474
+
475
+ it('truncates the listed maps to the first 3 with an ellipsis', () => {
476
+ const raw = JSON.stringify([
477
+ {
478
+ files: [
479
+ { path: 'a.js.map' },
480
+ { path: 'b.js.map' },
481
+ { path: 'c.js.map' },
482
+ { path: 'd.js.map' },
483
+ ],
484
+ },
485
+ ])
486
+ const result = _detectMapsInPackOutput(
487
+ raw,
488
+ cwd,
489
+ probe,
490
+ '@pyreon/reactivity',
491
+ )!
492
+ expect(result.message).toContain('a.js.map, b.js.map, c.js.map')
493
+ expect(result.message).toContain('…')
494
+ expect(result.message).not.toContain('d.js.map')
495
+ })
496
+
497
+ it('handles npm pack output with no files entry gracefully', () => {
498
+ // Some npm versions / edge cases emit an empty array; the gate
499
+ // shouldn't crash.
500
+ const result = _detectMapsInPackOutput('[]', cwd, probe, '@pyreon/x')
501
+ expect(result).toBeNull()
502
+ })
503
+ })
504
+
505
+ describe('_parseAuditTypesOutput', () => {
506
+ it('maps HIGH/MEDIUM/LOW to error/warning/info and suppresses OK', () => {
507
+ const raw = JSON.stringify([
508
+ {
509
+ package: '@pyreon/zero',
510
+ packageDir: '/repo/packages/zero/zero',
511
+ findings: [
512
+ {
513
+ package: '@pyreon/zero',
514
+ interface: 'ZeroConfig',
515
+ field: 'mode',
516
+ declaredIn: 'packages/zero/zero/src/types.ts',
517
+ declaredLine: 42,
518
+ refCount: 0,
519
+ severity: 'HIGH',
520
+ },
521
+ {
522
+ package: '@pyreon/zero',
523
+ interface: 'ZeroConfig',
524
+ field: 'maybeUsed',
525
+ declaredIn: 'packages/zero/zero/src/types.ts',
526
+ declaredLine: 50,
527
+ refCount: 1,
528
+ severity: 'MEDIUM',
529
+ },
530
+ {
531
+ package: '@pyreon/zero',
532
+ interface: 'ZeroConfig',
533
+ field: 'rarelyUsed',
534
+ declaredIn: 'packages/zero/zero/src/types.ts',
535
+ declaredLine: 55,
536
+ refCount: 2,
537
+ severity: 'LOW',
538
+ },
539
+ {
540
+ package: '@pyreon/zero',
541
+ interface: 'ZeroConfig',
542
+ field: 'reallyUsed',
543
+ declaredIn: 'packages/zero/zero/src/types.ts',
544
+ declaredLine: 60,
545
+ refCount: 20,
546
+ severity: 'OK',
547
+ },
548
+ ],
549
+ },
550
+ ])
551
+ const { findings, scanned } = _parseAuditTypesOutput(raw, '/repo')
552
+ expect(scanned).toBe(1)
553
+ // OK is suppressed → 3 findings, not 4
554
+ expect(findings).toHaveLength(3)
555
+ expect(findings.map((f) => f.severity).sort()).toEqual([
556
+ 'error',
557
+ 'info',
558
+ 'warning',
559
+ ])
560
+ const high = findings.find((f) => f.severity === 'error')!
561
+ expect(high.code).toBe('audit-types/typed-but-unimplemented-high')
562
+ expect(high.gate).toBe('audit-types')
563
+ expect(high.category).toBe('architecture')
564
+ expect(high.message).toContain('@pyreon/zero')
565
+ expect(high.message).toContain('ZeroConfig.mode')
566
+ expect(high.location?.line).toBe(42)
567
+ expect(high.location?.relPath).toBe(
568
+ 'packages/zero/zero/src/types.ts',
569
+ )
570
+ expect(high.location?.path).toBe(
571
+ '/repo/packages/zero/zero/src/types.ts',
572
+ )
573
+ })
574
+
575
+ it('handles empty results array', () => {
576
+ const { findings, scanned } = _parseAuditTypesOutput('[]', '/repo')
577
+ expect(findings).toEqual([])
578
+ expect(scanned).toBe(0)
579
+ })
580
+
581
+ it('handles a package with no findings', () => {
582
+ const raw = JSON.stringify([
583
+ { package: '@pyreon/router', packageDir: '/repo/p', findings: [] },
584
+ ])
585
+ const { findings, scanned } = _parseAuditTypesOutput(raw, '/repo')
586
+ expect(findings).toEqual([])
587
+ expect(scanned).toBe(1)
588
+ })
589
+ })
590
+
591
+ describe('_parseBundleBudgetsOutput', () => {
592
+ it('emits over-budget, missing-budget, bundle-failed findings', () => {
593
+ const raw = JSON.stringify({
594
+ violations: [
595
+ {
596
+ name: '@pyreon/big',
597
+ current: 5120,
598
+ budget: 4096,
599
+ overBy: 1024,
600
+ overByPct: 25,
601
+ },
602
+ ],
603
+ missing: [{ name: '@pyreon/new', current: 2048 }],
604
+ failures: [{ name: '@pyreon/broken', error: 'cannot resolve foo\nstack' }],
605
+ measured: [
606
+ { name: '@pyreon/big', raw: 10240, gzip: 5120 },
607
+ { name: '@pyreon/new', raw: 4096, gzip: 2048 },
608
+ { name: '@pyreon/fine', raw: 1024, gzip: 512 },
609
+ ],
610
+ })
611
+ const { findings, scanned } = _parseBundleBudgetsOutput(raw, '/repo')
612
+
613
+ // 3 measured + 1 failure → 4 scanned
614
+ expect(scanned).toBe(4)
615
+ expect(findings).toHaveLength(3)
616
+
617
+ const over = findings.find((f) => f.code === 'bundle-budgets/over-budget')!
618
+ expect(over.severity).toBe('error')
619
+ expect(over.message).toContain('@pyreon/big')
620
+ expect(over.message).toContain('+25.0%')
621
+ expect(over.location?.relPath).toBe('scripts/bundle-budgets.json')
622
+ expect(over.fix).toContain('--update')
623
+
624
+ const missing = findings.find(
625
+ (f) => f.code === 'bundle-budgets/missing-budget',
626
+ )!
627
+ expect(missing.severity).toBe('warning')
628
+ expect(missing.message).toContain('@pyreon/new')
629
+
630
+ const failed = findings.find(
631
+ (f) => f.code === 'bundle-budgets/bundle-failed',
632
+ )!
633
+ expect(failed.severity).toBe('error')
634
+ expect(failed.message).toContain('@pyreon/broken')
635
+ // Only the FIRST line of the error message is surfaced — the
636
+ // stack trace below the first \n is dropped.
637
+ expect(failed.message).toContain('cannot resolve foo')
638
+ expect(failed.message).not.toContain('stack')
639
+ })
640
+
641
+ it('returns empty findings on clean output', () => {
642
+ const raw = JSON.stringify({
643
+ violations: [],
644
+ missing: [],
645
+ failures: [],
646
+ measured: [{ name: '@pyreon/fine', raw: 1024, gzip: 512 }],
647
+ })
648
+ const { findings, scanned } = _parseBundleBudgetsOutput(raw, '/repo')
649
+ expect(findings).toEqual([])
650
+ expect(scanned).toBe(1)
651
+ })
652
+ })
653
+
654
+ describe('runBundleBudgetsGate', () => {
655
+ // The full real-repo bundle measurement takes 15-30s and is the
656
+ // slowest gate by a wide margin — it lives behind doctor's --full
657
+ // flag for the same reason. Skip the live measurement in unit
658
+ // tests; lock the shape via the gate-failed path (cheap subprocess
659
+ // failure) and let the standalone script's existing tests cover
660
+ // the live bundler behaviour.
661
+
662
+ it('surfaces gate-failed finding when script is unreachable', async () => {
663
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-bb-gate-'))
664
+ const result = await runBundleBudgetsGate({ cwd: tmp })
665
+ assertGateResultShape(result, 'bundle-budgets')
666
+ expect(result.category).toBe('performance')
667
+ const failed = result.findings.find(
668
+ (f) => f.code === 'bundle-budgets/gate-failed',
669
+ )
670
+ expect(failed).toBeDefined()
671
+ expect(failed?.severity).toBe('error')
672
+ fs.rmSync(tmp, { recursive: true, force: true })
673
+ })
674
+ })