@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.
- package/README.md +71 -32
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1509 -173
- package/lib/types/index.d.ts +183 -32
- package/package.json +3 -2
- package/src/doctor/gates/audit-tests.ts +70 -0
- package/src/doctor/gates/audit-types.ts +146 -0
- package/src/doctor/gates/bundle-budgets.ts +187 -0
- package/src/doctor/gates/distribution.ts +206 -0
- package/src/doctor/gates/doc-claims.ts +240 -0
- package/src/doctor/gates/index.ts +46 -0
- package/src/doctor/gates/islands-audit.ts +66 -0
- package/src/doctor/gates/lint.ts +129 -0
- package/src/doctor/gates/pyreon-patterns.ts +70 -0
- package/src/doctor/gates/react-patterns.ts +113 -0
- package/src/doctor/gates/ssg-audit.ts +57 -0
- package/src/doctor/orchestrator.ts +176 -0
- package/src/doctor/render/ansi.ts +80 -0
- package/src/doctor/render/gha.ts +47 -0
- package/src/doctor/render/index.ts +8 -0
- package/src/doctor/render/json.ts +16 -0
- package/src/doctor/render/text.ts +206 -0
- package/src/doctor/report.ts +61 -0
- package/src/doctor/score.ts +134 -0
- package/src/doctor/types.ts +196 -0
- package/src/doctor/utils/walk.ts +58 -0
- package/src/doctor.ts +82 -311
- package/src/index.ts +81 -20
- package/src/tests/doctor.test.ts +105 -457
- package/src/tests/gate-adapters.test.ts +193 -0
- package/src/tests/gates.test.ts +674 -0
- package/src/tests/orchestrator.test.ts +72 -0
- package/src/tests/render.test.ts +213 -0
- package/src/tests/report.test.ts +99 -0
- package/src/tests/score.test.ts +158 -0
package/src/tests/doctor.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,467 +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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
expect(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
'
|
|
341
|
-
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)
|
|
355
|
-
})
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
// ─── --check-islands integration (PR C) ─────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
describe('doctor — --check-islands integration', () => {
|
|
361
|
-
let logSpy: ReturnType<typeof vi.spyOn>
|
|
362
|
-
let tmpDir: string
|
|
363
|
-
|
|
364
|
-
beforeEach(() => {
|
|
365
|
-
logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
366
|
-
tmpDir = makeTmpDir()
|
|
367
|
-
fs.mkdirSync(path.join(tmpDir, 'packages'), { recursive: true })
|
|
368
|
-
})
|
|
369
|
-
afterEach(() => {
|
|
370
|
-
logSpy.mockRestore()
|
|
371
|
-
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
it('does NOT print islands audit output when --check-islands is absent (default)', async () => {
|
|
375
|
-
writeFile(
|
|
376
|
-
tmpDir,
|
|
377
|
-
'packages/x/src/Counter.tsx',
|
|
378
|
-
`import { island } from '@pyreon/server'
|
|
379
|
-
export const Counter = island(() => import('./Inner'), { name: 'Counter', hydrate: 'load' })`,
|
|
380
|
-
)
|
|
381
|
-
const opts: DoctorOptions = {
|
|
382
|
-
fix: false,
|
|
383
|
-
json: false,
|
|
384
|
-
ci: false,
|
|
385
|
-
cwd: tmpDir,
|
|
386
|
-
checkIslands: false,
|
|
387
|
-
}
|
|
388
|
-
await doctor(opts)
|
|
389
|
-
const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
|
|
390
|
-
expect(output).not.toContain('Islands audit')
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
it('prints the islands audit when --check-islands is passed', async () => {
|
|
394
|
-
writeFile(
|
|
395
|
-
tmpDir,
|
|
396
|
-
'packages/x/src/A.tsx',
|
|
397
|
-
`import { island } from '@pyreon/server'
|
|
398
|
-
export const A = island(() => import('./AInner'), { name: 'Dup', hydrate: 'load' })`,
|
|
399
|
-
)
|
|
400
|
-
writeFile(
|
|
401
|
-
tmpDir,
|
|
402
|
-
'packages/y/src/B.tsx',
|
|
403
|
-
`import { island } from '@pyreon/server'
|
|
404
|
-
export const B = island(() => import('./BInner'), { name: 'Dup', hydrate: 'load' })`,
|
|
405
|
-
)
|
|
406
|
-
writeFile(tmpDir, 'packages/x/src/AInner.tsx', `export default () => null`)
|
|
407
|
-
writeFile(tmpDir, 'packages/y/src/BInner.tsx', `export default () => null`)
|
|
408
|
-
const opts: DoctorOptions = {
|
|
409
|
-
fix: false,
|
|
410
|
-
json: false,
|
|
411
|
-
ci: false,
|
|
412
|
-
cwd: tmpDir,
|
|
413
|
-
checkIslands: true,
|
|
414
|
-
}
|
|
415
|
-
await doctor(opts)
|
|
416
|
-
const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
|
|
417
|
-
expect(output).toContain('Islands audit')
|
|
418
|
-
expect(output).toContain('## duplicate-name')
|
|
419
|
-
// Both file locations appear in the human-readable section
|
|
420
|
-
expect(output).toContain('A.tsx')
|
|
421
|
-
expect(output).toContain('B.tsx')
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
it('emits machine-readable JSON when --json + --check-islands both set', 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()
|
|
425
79
|
writeFile(
|
|
426
|
-
|
|
427
|
-
'
|
|
428
|
-
`import {
|
|
429
|
-
export const Orphan = island(() => import('./Inner'), { name: 'Orphan', hydrate: 'load' })`,
|
|
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`,
|
|
430
83
|
)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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),
|
|
434
101
|
json: true,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
.filter((
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
export const Counter = island(() => import('./Inner'), { name: 'Counter', hydrate: 'load' })`,
|
|
462
|
-
)
|
|
463
|
-
writeFile(tmpDir, 'packages/x/src/Inner.tsx', `export default () => null`)
|
|
464
|
-
writeFile(
|
|
465
|
-
tmpDir,
|
|
466
|
-
'packages/x/src/index.ts',
|
|
467
|
-
`export { Counter } from './Counter'`,
|
|
468
|
-
)
|
|
469
|
-
const opts: DoctorOptions = {
|
|
470
|
-
fix: false,
|
|
471
|
-
json: false,
|
|
472
|
-
ci: false,
|
|
473
|
-
cwd: tmpDir,
|
|
474
|
-
checkIslands: true,
|
|
475
|
-
}
|
|
476
|
-
await doctor(opts)
|
|
477
|
-
const output = logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n')
|
|
478
|
-
expect(output).toContain('Islands audit')
|
|
479
|
-
expect(output).toContain('No island findings')
|
|
102
|
+
auditTests: true,
|
|
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:')
|
|
480
128
|
})
|
|
481
129
|
})
|