@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.
@@ -2,11 +2,12 @@ import * as fs from 'node:fs'
2
2
  import * as os from 'node:os'
3
3
  import * as path from 'node:path'
4
4
 
5
+ import { describe, expect, it, vi } from 'vitest'
6
+
5
7
  import { type DoctorOptions, doctor } from '../doctor'
6
8
 
7
9
  function makeTmpDir(): string {
8
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-doctor-'))
9
- return dir
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-doctor-'))
10
11
  }
11
12
 
12
13
  function writeFile(dir: string, relPath: string, content: string): void {
@@ -15,342 +16,114 @@ function writeFile(dir: string, relPath: string, content: string): void {
15
16
  fs.writeFileSync(full, content, 'utf-8')
16
17
  }
17
18
 
18
- function readFile(dir: string, relPath: string): string {
19
- return fs.readFileSync(path.join(dir, relPath), 'utf-8')
20
- }
21
-
22
- function defaultOptions(cwd: string): DoctorOptions {
19
+ function defaults(cwd: string): DoctorOptions {
23
20
  return { fix: false, json: false, ci: false, cwd }
24
21
  }
25
22
 
26
- const REACT_TSX = `import { useState, useEffect } from "react"
27
-
28
- export function Counter() {
29
- const [count, setCount] = useState(0)
30
- useEffect(() => {
31
- console.log(count)
32
- }, [count])
33
- return <div className="counter">{count}</div>
34
- }
35
- `
36
-
37
- const CLEAN_TSX = `import { signal } from "@pyreon/reactivity"
38
-
39
- export function Counter() {
40
- const count = signal(0)
41
- return <div class="counter">{count()}</div>
42
- }
43
- `
44
-
45
- describe('doctor', () => {
46
- let tmpDir: string
47
- let logSpy: ReturnType<typeof vi.spyOn>
48
-
49
- beforeEach(() => {
50
- tmpDir = makeTmpDir()
51
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
52
- })
53
-
54
- afterEach(() => {
55
- logSpy.mockRestore()
56
- fs.rmSync(tmpDir, { recursive: true, force: true })
57
- })
58
-
59
- // ─── detect-only mode ──────────────────────────────────────────────────
60
-
61
- it('detects React patterns in files (no --fix)', async () => {
62
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
63
-
64
- const errorCount = await doctor(defaultOptions(tmpDir))
65
-
66
- expect(errorCount).toBeGreaterThan(0)
67
- })
68
-
69
- it('reports correct file paths and diagnostic counts', async () => {
70
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
71
-
72
- const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
73
- await doctor(opts)
74
-
75
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
76
- const result = JSON.parse(output)
77
-
78
- expect(result.passed).toBe(false)
79
- expect(result.files.length).toBe(1)
80
- expect(result.files[0].file).toBe(path.join('src', 'App.tsx'))
81
- expect(result.summary.filesWithIssues).toBe(1)
82
- expect(result.summary.totalErrors).toBeGreaterThan(0)
83
- // Should detect: react-import, use-state, use-effect-deps, class-name-prop
84
- const codes = result.files[0].diagnostics.map((d: { code: string }) => d.code)
85
- expect(codes).toContain('react-import')
86
- expect(codes).toContain('use-state')
87
- expect(codes).toContain('class-name-prop')
88
- })
89
-
90
- // ─── --fix mode ────────────────────────────────────────────────────────
91
-
92
- it('--fix mode rewrites files with migrations', async () => {
93
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
94
-
95
- const opts: DoctorOptions = { fix: true, json: false, ci: false, cwd: tmpDir }
96
- await doctor(opts)
97
-
98
- const updated = readFile(tmpDir, 'src/App.tsx')
99
- // React import should be removed or rewritten
100
- expect(updated).not.toContain('from "react"')
101
- // useState should be migrated to signal
102
- expect(updated).toContain('signal')
103
- // className should be migrated to class
104
- expect(updated).toContain('class=')
105
- })
106
-
107
- it('--fix mode reports totalFixed in JSON output', async () => {
108
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
109
-
110
- const opts: DoctorOptions = { fix: true, json: true, ci: false, cwd: tmpDir }
111
- await doctor(opts)
112
-
113
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
114
- const result = JSON.parse(output)
115
-
116
- expect(result.summary.totalFixed).toBeGreaterThan(0)
117
- })
118
-
119
- // ─── --json mode ───────────────────────────────────────────────────────
120
-
121
- it('--json mode returns structured JSON output', async () => {
122
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
123
-
124
- const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
125
- await doctor(opts)
126
-
127
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
128
- const result = JSON.parse(output)
129
-
130
- expect(result).toHaveProperty('passed')
131
- expect(result).toHaveProperty('files')
132
- expect(result).toHaveProperty('summary')
133
- expect(result.summary).toHaveProperty('filesScanned')
134
- expect(result.summary).toHaveProperty('filesWithIssues')
135
- expect(result.summary).toHaveProperty('totalErrors')
136
- expect(result.summary).toHaveProperty('totalFixable')
137
- expect(result.summary).toHaveProperty('totalFixed')
138
- expect(Array.isArray(result.files)).toBe(true)
139
- })
140
-
141
- // ─── --ci mode ─────────────────────────────────────────────────────────
142
-
143
- it('--ci mode returns non-zero error count when issues found', async () => {
144
- writeFile(tmpDir, 'src/App.tsx', REACT_TSX)
145
-
146
- const opts: DoctorOptions = { fix: false, json: false, ci: true, cwd: tmpDir }
147
- const errorCount = await doctor(opts)
148
-
149
- expect(errorCount).toBeGreaterThan(0)
150
- })
151
-
152
- it('--ci mode returns 0 when no issues found', async () => {
153
- writeFile(tmpDir, 'src/App.tsx', CLEAN_TSX)
154
-
155
- const opts: DoctorOptions = { fix: false, json: false, ci: true, cwd: tmpDir }
156
- const errorCount = await doctor(opts)
157
-
158
- expect(errorCount).toBe(0)
159
- })
160
-
161
- // ─── skipping ──────────────────────────────────────────────────────────
162
-
163
- it('skips node_modules and non-source files', async () => {
164
- writeFile(tmpDir, 'node_modules/some-pkg/index.tsx', REACT_TSX)
165
- writeFile(tmpDir, 'dist/bundle.tsx', REACT_TSX)
166
- writeFile(tmpDir, 'assets/readme.md', '# className something useState')
167
- writeFile(tmpDir, 'src/App.tsx', CLEAN_TSX)
168
-
169
- const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
170
- await doctor(opts)
171
-
172
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
173
- const result = JSON.parse(output)
174
-
175
- expect(result.passed).toBe(true)
176
- expect(result.summary.filesWithIssues).toBe(0)
177
- // Only the clean .tsx in src/ should be scanned
178
- expect(result.summary.filesScanned).toBe(1)
179
- })
180
-
181
- // ─── clean project ─────────────────────────────────────────────────────
182
-
183
- it('clean project returns no issues', async () => {
184
- writeFile(tmpDir, 'src/App.tsx', CLEAN_TSX)
185
-
186
- const errorCount = await doctor(defaultOptions(tmpDir))
187
-
188
- expect(errorCount).toBe(0)
189
- })
190
-
191
- it('clean project prints success message in human mode', async () => {
192
- writeFile(tmpDir, 'src/App.tsx', CLEAN_TSX)
193
-
194
- await doctor(defaultOptions(tmpDir))
195
-
196
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')
197
- expect(output).toContain('No issues found')
198
- })
199
-
200
- // ─── hasReactPatterns pre-filter ────────────────────────────────────────
201
-
202
- it('hasReactPatterns pre-filter skips non-React files efficiently', async () => {
203
- // A file with Pyreon-only code should not produce diagnostics
204
- const pyreonOnly = `import { signal, computed, effect } from "@pyreon/reactivity"
205
- import { onMount } from "@pyreon/core"
206
-
207
- export function App() {
208
- const count = signal(0)
209
- const doubled = computed(() => count() * 2)
210
- effect(() => console.log(doubled()))
211
- onMount(() => { console.log("mounted") })
212
- return <div class="app">{count()}</div>
213
- }
214
- `
215
- writeFile(tmpDir, 'src/App.tsx', pyreonOnly)
216
-
217
- const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
218
- await doctor(opts)
219
-
220
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
221
- const result = JSON.parse(output)
222
-
223
- expect(result.passed).toBe(true)
224
- expect(result.summary.totalErrors).toBe(0)
225
- })
226
-
227
- // ─── empty directory ────────────────────────────────────────────────────
228
-
229
- it('handles empty directory with no source files', async () => {
230
- const errorCount = await doctor(defaultOptions(tmpDir))
231
-
232
- expect(errorCount).toBe(0)
233
- })
234
-
235
- // ─── multiple files ─────────────────────────────────────────────────────
236
-
237
- it('scans multiple files and aggregates results', async () => {
238
- writeFile(tmpDir, 'src/A.tsx', REACT_TSX)
239
- writeFile(
240
- tmpDir,
241
- 'src/B.tsx',
242
- `import { useState } from "react"
243
- export function B() { const [x, setX] = useState(0); return <div>{x}</div> }
244
- `,
245
- )
246
- writeFile(tmpDir, 'src/C.tsx', CLEAN_TSX)
247
-
248
- const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
249
- await doctor(opts)
250
-
251
- const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('')
252
- const result = JSON.parse(output)
253
-
254
- expect(result.summary.filesScanned).toBe(3)
255
- expect(result.summary.filesWithIssues).toBe(2)
256
- })
257
- })
258
-
259
- describe('doctor — --audit-tests integration', () => {
260
- let logSpy: ReturnType<typeof vi.spyOn>
261
- let tmpDir: string
262
-
263
- beforeEach(() => {
264
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
265
- tmpDir = makeTmpDir()
266
- fs.mkdirSync(path.join(tmpDir, 'packages'), { recursive: true })
267
- })
268
- afterEach(() => {
269
- logSpy.mockRestore()
270
- fs.rmSync(tmpDir, { recursive: true, force: true })
271
- })
272
-
273
- it('does NOT print audit output when --audit-tests is absent (default)', async () => {
274
- writeFile(
275
- tmpDir,
276
- 'packages/x/src/tests/mock.test.ts',
277
- `const vnode = { type: 'div', props: {}, children: [] }`,
278
- )
279
- const opts: DoctorOptions = {
280
- fix: false,
281
- json: false,
282
- ci: false,
283
- cwd: tmpDir,
284
- auditTests: false,
285
- }
286
- await doctor(opts)
287
- const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
288
- expect(output).not.toContain('Test environment audit')
289
- })
290
-
291
- it('prints the test-audit report when --audit-tests is passed', async () => {
292
- writeFile(
293
- tmpDir,
294
- 'packages/x/src/tests/mock.test.ts',
295
- `const vnode = { type: 'div', props: {}, children: [] }`,
296
- )
297
- const opts: DoctorOptions = {
298
- fix: false,
299
- json: false,
300
- ci: false,
301
- cwd: tmpDir,
302
- auditTests: true,
303
- }
304
- await doctor(opts)
305
- const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
306
- expect(output).toContain('Test environment audit')
307
- expect(output).toContain('Mock-vnode exposure')
308
- // The HIGH file we wrote surfaces at the default minRisk=medium.
309
- expect(output).toContain('mock.test.ts')
310
- })
311
-
312
- it('emits machine-readable JSON when --json + --audit-tests both set', async () => {
313
- writeFile(
314
- tmpDir,
315
- 'packages/x/src/tests/mock.test.ts',
316
- `const vnode = { type: 'div', props: {}, children: [] }`,
317
- )
318
- const opts: DoctorOptions = {
319
- fix: false,
23
+ describe('doctor() end-to-end', () => {
24
+ // Use `--only react-patterns` for the empty-dir tests — most other
25
+ // gates walk up the dir tree looking for `packages/` (audit-tests)
26
+ // or read the real repo root for known files (doc-claims). Pinning
27
+ // to `react-patterns` isolates the test to the actual tmp dir.
28
+
29
+ it('runs against an empty dir and prints a clean banner', async () => {
30
+ const cwd = makeTmpDir()
31
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
32
+ const exitCode = await doctor({
33
+ ...defaults(cwd),
34
+ only: ['react-patterns'],
35
+ })
36
+ const out = log.mock.calls.map((c) => c[0]).join('\n')
37
+ log.mockRestore()
38
+ fs.rmSync(cwd, { recursive: true, force: true })
39
+
40
+ expect(out).toContain('pyreon doctor')
41
+ expect(out).toContain('Score:')
42
+ expect(exitCode).toBe(0)
43
+ })
44
+
45
+ it('--json emits a DoctorReport object', async () => {
46
+ const cwd = makeTmpDir()
47
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
48
+ await doctor({
49
+ ...defaults(cwd),
320
50
  json: true,
321
- ci: false,
322
- cwd: tmpDir,
323
- auditTests: true,
324
- }
325
- await doctor(opts)
326
- // Two JSON blobs logged separately — doctor result then the audit.
327
- const blobs = logSpy.mock.calls
328
- .map((c: unknown[]) => String(c[0] ?? ''))
329
- .filter((s: string) => s.trim().startsWith('{'))
330
- expect(blobs.length).toBeGreaterThanOrEqual(2)
331
- const auditBlob = blobs.find((s: string) => s.includes('testAudit'))
332
- expect(auditBlob).toBeDefined()
333
- const parsed = JSON.parse(auditBlob!) as { testAudit: { entries: unknown[] } }
334
- expect(Array.isArray(parsed.testAudit.entries)).toBe(true)
335
- })
336
-
337
- it('honours --audit-min-risk=high surfaces only HIGH files', async () => {
51
+ only: ['react-patterns'],
52
+ })
53
+ const out = log.mock.calls.map((c) => c[0]).join('')
54
+ log.mockRestore()
55
+ fs.rmSync(cwd, { recursive: true, force: true })
56
+
57
+ const parsed = JSON.parse(out)
58
+ expect(parsed.score).toBeTypeOf('number')
59
+ expect(parsed.grade).toMatch(/^[A-F]$/)
60
+ expect(Array.isArray(parsed.findings)).toBe(true)
61
+ expect(Array.isArray(parsed.gates)).toBe(true)
62
+ })
63
+
64
+ it('--ci returns 0 when no error findings', async () => {
65
+ const cwd = makeTmpDir()
66
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
67
+ const exitCode = await doctor({
68
+ ...defaults(cwd),
69
+ ci: true,
70
+ only: ['react-patterns'],
71
+ })
72
+ log.mockRestore()
73
+ fs.rmSync(cwd, { recursive: true, force: true })
74
+ expect(exitCode).toBe(0)
75
+ })
76
+
77
+ it('flags React patterns when detected', async () => {
78
+ const cwd = makeTmpDir()
338
79
  writeFile(
339
- tmpDir,
340
- 'packages/x/src/tests/mock.test.ts',
341
- `const vnode = { type: 'div', props: {}, children: [] }`,
80
+ cwd,
81
+ 'src/App.tsx',
82
+ `import { useState } from "react"\nexport function X() { const [c, setC] = useState(0); return <div className="x">{c}</div> }\n`,
342
83
  )
343
- const opts: DoctorOptions = {
344
- fix: false,
345
- json: false,
346
- ci: false,
347
- cwd: tmpDir,
84
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
85
+ await doctor({ ...defaults(cwd), json: true, only: ['react-patterns'] })
86
+ const out = log.mock.calls.map((c) => c[0]).join('')
87
+ log.mockRestore()
88
+ fs.rmSync(cwd, { recursive: true, force: true })
89
+
90
+ const parsed = JSON.parse(out)
91
+ expect(parsed.findings.length).toBeGreaterThan(0)
92
+ const codes = parsed.findings.map((f: { code: string }) => f.code)
93
+ expect(codes.some((c: string) => c.startsWith('react-patterns/'))).toBe(true)
94
+ })
95
+
96
+ it('legacy --audit-tests maps to --only audit-tests', async () => {
97
+ const cwd = makeTmpDir()
98
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
99
+ await doctor({
100
+ ...defaults(cwd),
101
+ json: true,
348
102
  auditTests: true,
349
- auditMinRisk: 'high',
350
- }
351
- await doctor(opts)
352
- const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
353
- expect(output).toContain('## HIGH')
354
- expect(output).not.toMatch(/^## MEDIUM/m)
103
+ })
104
+ const out = log.mock.calls.map((c) => c[0]).join('')
105
+ log.mockRestore()
106
+ fs.rmSync(cwd, { recursive: true, force: true })
107
+
108
+ const parsed = JSON.parse(out)
109
+ const ranGates = parsed.gates
110
+ .filter((g: { meta: { skipped?: boolean } }) => !g.meta.skipped)
111
+ .map((g: { gate: string }) => g.gate)
112
+ expect(ranGates).toEqual(['audit-tests'])
113
+ })
114
+
115
+ it('respects --format=gha for GitHub Actions output', async () => {
116
+ const cwd = makeTmpDir()
117
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
118
+ await doctor({
119
+ ...defaults(cwd),
120
+ format: 'gha',
121
+ only: ['react-patterns'],
122
+ })
123
+ const out = log.mock.calls.map((c) => c[0]).join('\n')
124
+ log.mockRestore()
125
+ fs.rmSync(cwd, { recursive: true, force: true })
126
+
127
+ expect(out).toContain('::notice::pyreon doctor score:')
355
128
  })
356
129
  })
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Smoke tests for the gate adapters added in PR 2. Each test runs
3
+ * the adapter against a synthetic tmp dir and asserts the GateResult
4
+ * shape contract (matches what `assertGateResultShape` checks for
5
+ * PR 1 gates). Some tests also assert a SPECIFIC finding code surfaces
6
+ * — those are the "bisect-load-bearing" specs that lock the
7
+ * detector → severity → category mapping.
8
+ */
9
+
10
+ import * as fs from 'node:fs'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
13
+
14
+ import { describe, expect, it } from 'vitest'
15
+
16
+ import {
17
+ runAuditTestsGate,
18
+ runIslandsAuditGate,
19
+ runLintGate,
20
+ runPyreonPatternsGate,
21
+ runReactPatternsGate,
22
+ runSsgAuditGate,
23
+ } from '../doctor/gates'
24
+ import type { GateResult } from '../doctor/types'
25
+
26
+ function makeTmpDir(): string {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-gate-adapters-'))
28
+ }
29
+
30
+ function writeFile(dir: string, relPath: string, content: string): void {
31
+ const full = path.join(dir, relPath)
32
+ fs.mkdirSync(path.dirname(full), { recursive: true })
33
+ fs.writeFileSync(full, content, 'utf-8')
34
+ }
35
+
36
+ const assertShape = (result: GateResult, gate: string): void => {
37
+ expect(result.gate).toBe(gate)
38
+ expect(typeof result.category).toBe('string')
39
+ expect(Array.isArray(result.findings)).toBe(true)
40
+ expect(typeof result.meta.elapsedMs).toBe('number')
41
+ for (const f of result.findings) {
42
+ expect(f.gate).toBe(gate)
43
+ expect(f.code).toMatch(new RegExp(`^${gate}/`))
44
+ }
45
+ }
46
+
47
+ describe('runReactPatternsGate', () => {
48
+ it('finds nothing in a clean dir', async () => {
49
+ const cwd = makeTmpDir()
50
+ writeFile(
51
+ cwd,
52
+ 'src/App.tsx',
53
+ `import { signal } from "@pyreon/reactivity"\nexport function X() { const c = signal(0); return <div class="x">{c()}</div> }\n`,
54
+ )
55
+ const result = await runReactPatternsGate({ cwd })
56
+ assertShape(result, 'react-patterns')
57
+ expect(result.findings).toEqual([])
58
+ fs.rmSync(cwd, { recursive: true, force: true })
59
+ })
60
+
61
+ it('flags useState + className when present', async () => {
62
+ const cwd = makeTmpDir()
63
+ writeFile(
64
+ cwd,
65
+ 'src/App.tsx',
66
+ `import { useState } from "react"\nexport function X() { const [c, setC] = useState(0); return <div className="x">{c}</div> }\n`,
67
+ )
68
+ const result = await runReactPatternsGate({ cwd })
69
+ assertShape(result, 'react-patterns')
70
+ expect(result.findings.length).toBeGreaterThan(0)
71
+ const codes = result.findings.map((f) => f.code)
72
+ expect(codes.some((c) => c.startsWith('react-patterns/'))).toBe(true)
73
+ fs.rmSync(cwd, { recursive: true, force: true })
74
+ })
75
+
76
+ it('--fix mode applies migrations and reports auto-fixed info findings', async () => {
77
+ const cwd = makeTmpDir()
78
+ writeFile(
79
+ cwd,
80
+ 'src/App.tsx',
81
+ `export function X() { return <div className="x" /> }\n`,
82
+ )
83
+ const result = await runReactPatternsGate({ cwd, fix: true })
84
+ assertShape(result, 'react-patterns')
85
+ // We exercised the --fix branch; the file may or may not have
86
+ // received changes depending on what `migrateReactCode` matches
87
+ // for. Either way the gate runs without throwing and emits a
88
+ // GateResult.
89
+ fs.rmSync(cwd, { recursive: true, force: true })
90
+ })
91
+ })
92
+
93
+ describe('runPyreonPatternsGate', () => {
94
+ it('runs against an empty dir and returns GateResult shape', async () => {
95
+ const cwd = makeTmpDir()
96
+ const result = await runPyreonPatternsGate({ cwd })
97
+ assertShape(result, 'pyreon-patterns')
98
+ expect(result.findings).toEqual([])
99
+ fs.rmSync(cwd, { recursive: true, force: true })
100
+ })
101
+
102
+ it('flags `<For>` without `by` prop', async () => {
103
+ const cwd = makeTmpDir()
104
+ writeFile(
105
+ cwd,
106
+ 'src/X.tsx',
107
+ `import { For } from "@pyreon/core"\nexport function X({ items }) { return <For each={items}>{(x) => <li>{x}</li>}</For> }\n`,
108
+ )
109
+ const result = await runPyreonPatternsGate({ cwd })
110
+ assertShape(result, 'pyreon-patterns')
111
+ // We can't strictly assert the code matches since detection is
112
+ // syntactic and the fixture is small; just assert SOME finding
113
+ // surfaces with the right gate.
114
+ if (result.findings.length > 0) {
115
+ expect(result.findings[0]!.gate).toBe('pyreon-patterns')
116
+ }
117
+ fs.rmSync(cwd, { recursive: true, force: true })
118
+ })
119
+ })
120
+
121
+ describe('runIslandsAuditGate', () => {
122
+ it('runs against an empty dir and returns GateResult shape', async () => {
123
+ const cwd = makeTmpDir()
124
+ const result = await runIslandsAuditGate({ cwd })
125
+ assertShape(result, 'islands-audit')
126
+ expect(result.category).toBe('architecture')
127
+ fs.rmSync(cwd, { recursive: true, force: true })
128
+ })
129
+ })
130
+
131
+ describe('runSsgAuditGate', () => {
132
+ it('runs against an empty dir and returns GateResult shape', async () => {
133
+ const cwd = makeTmpDir()
134
+ const result = await runSsgAuditGate({ cwd })
135
+ assertShape(result, 'ssg-audit')
136
+ expect(result.category).toBe('architecture')
137
+ fs.rmSync(cwd, { recursive: true, force: true })
138
+ })
139
+ })
140
+
141
+ describe('runAuditTestsGate', () => {
142
+ it('respects minRisk option', async () => {
143
+ const cwd = makeTmpDir()
144
+ const result = await runAuditTestsGate({ cwd, minRisk: 'high' })
145
+ assertShape(result, 'audit-tests')
146
+ // High-risk only — synthetic dir has nothing; expect empty.
147
+ expect(result.findings).toEqual([])
148
+ fs.rmSync(cwd, { recursive: true, force: true })
149
+ })
150
+
151
+ it('defaults minRisk to medium', async () => {
152
+ const cwd = makeTmpDir()
153
+ const result = await runAuditTestsGate({ cwd })
154
+ assertShape(result, 'audit-tests')
155
+ fs.rmSync(cwd, { recursive: true, force: true })
156
+ })
157
+ })
158
+
159
+ describe('runLintGate', () => {
160
+ it('runs against an empty dir and returns GateResult shape', async () => {
161
+ const cwd = makeTmpDir()
162
+ const result = await runLintGate({ cwd })
163
+ assertShape(result, 'lint')
164
+ expect(result.category).toBe('correctness')
165
+ fs.rmSync(cwd, { recursive: true, force: true })
166
+ })
167
+
168
+ it('exercises the diagnostic emission path against a real lint target', async () => {
169
+ const cwd = makeTmpDir()
170
+ // Write a file that triggers `pyreon/no-window-in-ssr` (one of
171
+ // the simplest universally-firing rules). This exercises
172
+ // mapLintCategory + the diagnostic-emission branch.
173
+ writeFile(
174
+ cwd,
175
+ 'src/App.tsx',
176
+ `export function X() { return <div>{window.innerWidth}</div> }\n`,
177
+ )
178
+ const result = await runLintGate({ cwd })
179
+ assertShape(result, 'lint')
180
+ // Whether or not findings fire depends on the lint setup detecting
181
+ // the tmp dir as a Pyreon project — either way we exercised the
182
+ // entry path + emission loop.
183
+ fs.rmSync(cwd, { recursive: true, force: true })
184
+ })
185
+
186
+ it('--fix mode runs without throwing', async () => {
187
+ const cwd = makeTmpDir()
188
+ writeFile(cwd, 'src/App.tsx', `export const x = 1\n`)
189
+ const result = await runLintGate({ cwd, fix: true })
190
+ assertShape(result, 'lint')
191
+ fs.rmSync(cwd, { recursive: true, force: true })
192
+ })
193
+ })