@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/doctor.ts
CHANGED
|
@@ -1,343 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pyreon doctor — project-wide health
|
|
2
|
+
* `pyreon doctor` — project-wide health audit.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* PR 2 rewrites this entrypoint around the unified gate API
|
|
5
|
+
* (`packages/tools/cli/src/doctor/`). The orchestrator runs every
|
|
6
|
+
* gate in parallel, the aggregator builds a `DoctorReport` with a
|
|
7
|
+
* 0-100 score, the renderer formats it for text / JSON / GHA.
|
|
8
|
+
*
|
|
9
|
+
* The legacy single-purpose flags (`--audit-tests`, `--check-islands`,
|
|
10
|
+
* `--check-ssg`) are interpreted as `--only <gate>` shortcuts so any
|
|
11
|
+
* existing CI script that relied on the old shape keeps working.
|
|
12
|
+
* Without those flags, doctor runs the full fast-gate set + computes
|
|
13
|
+
* a score — the new default behaviour.
|
|
8
14
|
*
|
|
9
15
|
* Output modes:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
16
|
+
* - text (default): big-score banner + per-category bars + top-N findings
|
|
17
|
+
* - --json: full `DoctorReport` as JSON
|
|
18
|
+
* - --gha: GitHub Actions annotation lines (one per finding)
|
|
13
19
|
*
|
|
14
|
-
*
|
|
20
|
+
* Modes:
|
|
21
|
+
* - --full: enable slow gates (audit-types, bundle-budgets)
|
|
22
|
+
* - --only <gates>: run ONLY the listed comma-separated gates
|
|
23
|
+
* - --skip <gates>: exclude these gates
|
|
24
|
+
* - --fix: auto-fix where possible (lint + react-patterns)
|
|
25
|
+
* - --ci: exit non-zero on any error finding
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
|
-
import * as fs from 'node:fs'
|
|
18
|
-
import * as path from 'node:path'
|
|
19
28
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
hasReactPatterns,
|
|
29
|
-
migrateReactCode,
|
|
30
|
-
type ReactDiagnostic,
|
|
31
|
-
} from '@pyreon/compiler'
|
|
29
|
+
runDoctor,
|
|
30
|
+
type GateName,
|
|
31
|
+
type OrchestratorOptions,
|
|
32
|
+
} from './doctor/orchestrator'
|
|
33
|
+
import { renderGha, renderJson, renderText } from './doctor/render'
|
|
34
|
+
import type { DoctorReport } from './doctor/types'
|
|
35
|
+
|
|
36
|
+
export type DoctorFormat = 'text' | 'json' | 'gha'
|
|
32
37
|
|
|
33
38
|
export interface DoctorOptions {
|
|
34
39
|
fix: boolean
|
|
40
|
+
/** Legacy boolean — interpreted as `format = 'json'` if true. */
|
|
35
41
|
json: boolean
|
|
36
42
|
ci: boolean
|
|
37
43
|
cwd: string
|
|
44
|
+
/** Explicit format override (wins over `json` boolean). */
|
|
45
|
+
format?: DoctorFormat | undefined
|
|
46
|
+
/** Enable slow gates (audit-types, bundle-budgets). */
|
|
47
|
+
full?: boolean | undefined
|
|
48
|
+
/** Run ONLY these gates. */
|
|
49
|
+
only?: GateName[] | undefined
|
|
50
|
+
/** Skip these gates. */
|
|
51
|
+
skip?: GateName[] | undefined
|
|
52
|
+
|
|
53
|
+
// ── Legacy flags (mapped to --only shortcuts for back-compat) ────
|
|
38
54
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the React-migration check pipeline, so we gate it to avoid noise
|
|
43
|
-
* in the typical "is my migration done?" call.
|
|
55
|
+
* @deprecated Prefer `--only audit-tests`. Both forms behave
|
|
56
|
+
* identically: include the test-environment audit gate in the
|
|
57
|
+
* report. Kept so existing CI scripts continue to work.
|
|
44
58
|
*/
|
|
45
59
|
auditTests?: boolean | undefined
|
|
46
|
-
/** Minimum risk
|
|
47
|
-
auditMinRisk?:
|
|
48
|
-
/**
|
|
49
|
-
* When true, run the project-wide islands audit and append the result
|
|
50
|
-
* to the doctor output. Catches cross-file foot-guns (duplicate names,
|
|
51
|
-
* dead islands, registry drift, nested islands, never-with-registry)
|
|
52
|
-
* that PR G's per-file detector and PR B's auto-registry can't reach
|
|
53
|
-
* (manual `hydrateIslands({...})` for non-Vite consumers, library
|
|
54
|
-
* authors, multi-package projects). Default false.
|
|
55
|
-
*/
|
|
60
|
+
/** Minimum risk for the test-environment audit. Default 'medium'. */
|
|
61
|
+
auditMinRisk?: 'high' | 'medium' | 'low' | undefined
|
|
62
|
+
/** @deprecated Prefer `--only islands-audit`. */
|
|
56
63
|
checkIslands?: boolean | undefined
|
|
57
|
-
/**
|
|
58
|
-
* When true, run the project-wide SSG / ISR audit (M3.4) and append
|
|
59
|
-
* the result to the doctor output. Catches:
|
|
60
|
-
* - `_404.tsx` not co-located with `_layout.tsx` (PR L5 carve-out)
|
|
61
|
-
* - dynamic routes (`[id].tsx`) without `getStaticPaths` (PR A
|
|
62
|
-
* silently skips them under `mode: 'ssg'`)
|
|
63
|
-
* - `export const revalidate = X` where X isn't a numeric literal
|
|
64
|
-
* (PR I's extractor silently drops non-literal forms)
|
|
65
|
-
*
|
|
66
|
-
* Like the islands audit, this is a "should review" signal — the exit
|
|
67
|
-
* code is unaffected (the build doesn't break) but CI can pipe
|
|
68
|
-
* `--check-ssg --json` and grep for findings.length > 0 to gate on
|
|
69
|
-
* it. Default false.
|
|
70
|
-
*/
|
|
64
|
+
/** @deprecated Prefer `--only ssg-audit`. */
|
|
71
65
|
checkSsg?: boolean | undefined
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface DoctorResult {
|
|
81
|
-
passed: boolean
|
|
82
|
-
files: FileResult[]
|
|
83
|
-
summary: {
|
|
84
|
-
filesScanned: number
|
|
85
|
-
filesWithIssues: number
|
|
86
|
-
totalErrors: number
|
|
87
|
-
totalFixable: number
|
|
88
|
-
totalFixed: number
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function doctor(options: DoctorOptions): Promise<number> {
|
|
93
|
-
const startTime = performance.now()
|
|
94
|
-
const files = collectSourceFiles(options.cwd)
|
|
95
|
-
const result = runChecks(files, options)
|
|
96
|
-
const elapsed = Math.round(performance.now() - startTime)
|
|
97
|
-
|
|
98
|
-
if (options.json) {
|
|
99
|
-
printJson(result)
|
|
100
|
-
} else {
|
|
101
|
-
printHuman(result, elapsed)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Test-environment audit — optional follow-on pass. We run AFTER the
|
|
105
|
-
// main React-migration output so a migration-focused run isn't
|
|
106
|
-
// contaminated; pass `--audit-tests` to see it. The exit code is
|
|
107
|
-
// unaffected since mock-vnode test risk is a "should review" signal,
|
|
108
|
-
// not a "broken build" signal.
|
|
109
|
-
if (options.auditTests) {
|
|
110
|
-
const auditResult = auditTestEnvironment(options.cwd)
|
|
111
|
-
if (options.json) {
|
|
112
|
-
console.log('')
|
|
113
|
-
console.log(JSON.stringify({ testAudit: auditResult }, null, 2))
|
|
114
|
-
} else {
|
|
115
|
-
console.log('')
|
|
116
|
-
console.log(formatTestAudit(auditResult, { minRisk: options.auditMinRisk ?? 'medium' }))
|
|
117
|
-
console.log('')
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Islands audit — optional follow-on pass (PR C of the islands DX
|
|
122
|
-
// roadmap). Runs the project-wide cross-file scan. Like the
|
|
123
|
-
// test-audit, this is a "should review" signal — exit code unaffected
|
|
124
|
-
// (the build doesn't break) but in CI you can pipe `--check-islands
|
|
125
|
-
// --json` and grep for findings.length > 0 to gate on it.
|
|
126
|
-
if (options.checkIslands) {
|
|
127
|
-
const islandsResult = auditIslands(options.cwd)
|
|
128
|
-
if (options.json) {
|
|
129
|
-
console.log('')
|
|
130
|
-
console.log(JSON.stringify({ islandAudit: islandsResult }, null, 2))
|
|
131
|
-
} else {
|
|
132
|
-
console.log('')
|
|
133
|
-
console.log(formatIslandAudit(islandsResult))
|
|
134
|
-
console.log('')
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// M3.4 — SSG audit. Catches `_404.tsx` placement (PR L5 carve-out),
|
|
139
|
-
// dynamic-route enumerators (PR A silent skip), and non-literal
|
|
140
|
-
// revalidate exports (PR I's extractor limitation). Exit code
|
|
141
|
-
// unaffected — same "should review" treatment as islands / test
|
|
142
|
-
// audits; CI gates via `--json | jq '.ssgAudit.findings | length'`.
|
|
143
|
-
if (options.checkSsg) {
|
|
144
|
-
const ssgResult = auditSsg(options.cwd)
|
|
145
|
-
if (options.json) {
|
|
146
|
-
console.log('')
|
|
147
|
-
console.log(JSON.stringify({ ssgAudit: ssgResult }, null, 2))
|
|
148
|
-
} else {
|
|
149
|
-
console.log('')
|
|
150
|
-
console.log(formatSsgAudit(ssgResult))
|
|
151
|
-
console.log('')
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return result.summary.totalErrors
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
159
|
-
// File collection
|
|
160
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
161
|
-
|
|
162
|
-
const sourceExtensions = new Set(['.tsx', '.jsx', '.ts', '.js'])
|
|
163
|
-
const sourceIgnoreDirs = new Set([
|
|
164
|
-
'node_modules',
|
|
165
|
-
'dist',
|
|
166
|
-
'lib',
|
|
167
|
-
'.pyreon',
|
|
168
|
-
'.git',
|
|
169
|
-
'.next',
|
|
170
|
-
'build',
|
|
171
|
-
])
|
|
172
|
-
|
|
173
|
-
function shouldSkipDirEntry(entry: fs.Dirent): boolean {
|
|
174
|
-
if (!entry.isDirectory()) return false
|
|
175
|
-
return entry.name.startsWith('.') || sourceIgnoreDirs.has(entry.name)
|
|
68
|
+
const resolveFormat = (options: DoctorOptions): DoctorFormat => {
|
|
69
|
+
if (options.format) return options.format
|
|
70
|
+
if (options.json) return 'json'
|
|
71
|
+
return 'text'
|
|
176
72
|
}
|
|
177
73
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
for (const entry of entries) {
|
|
187
|
-
if (shouldSkipDirEntry(entry)) continue
|
|
188
|
-
|
|
189
|
-
const fullPath = path.join(dir, entry.name)
|
|
190
|
-
if (entry.isDirectory()) {
|
|
191
|
-
walkSourceFiles(fullPath, results)
|
|
192
|
-
} else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) {
|
|
193
|
-
results.push(fullPath)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function collectSourceFiles(cwd: string): string[] {
|
|
199
|
-
const results: string[] = []
|
|
200
|
-
walkSourceFiles(cwd, results)
|
|
201
|
-
return results
|
|
74
|
+
const resolveOnly = (options: DoctorOptions): GateName[] | undefined => {
|
|
75
|
+
if (options.only && options.only.length > 0) return options.only
|
|
76
|
+
// Legacy single-purpose flags → `--only` shortcuts.
|
|
77
|
+
const legacyOnly: GateName[] = []
|
|
78
|
+
if (options.auditTests) legacyOnly.push('audit-tests')
|
|
79
|
+
if (options.checkIslands) legacyOnly.push('islands-audit')
|
|
80
|
+
if (options.checkSsg) legacyOnly.push('ssg-audit')
|
|
81
|
+
return legacyOnly.length > 0 ? legacyOnly : undefined
|
|
202
82
|
}
|
|
203
83
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
let code: string
|
|
213
|
-
try {
|
|
214
|
-
code = fs.readFileSync(file, 'utf-8')
|
|
215
|
-
} catch {
|
|
216
|
-
return { result: null, fixCount: 0 }
|
|
84
|
+
export const doctor = async (options: DoctorOptions): Promise<number> => {
|
|
85
|
+
const orchestratorOpts: OrchestratorOptions = {
|
|
86
|
+
cwd: options.cwd,
|
|
87
|
+
full: options.full,
|
|
88
|
+
only: resolveOnly(options),
|
|
89
|
+
skip: options.skip,
|
|
90
|
+
fix: options.fix,
|
|
91
|
+
auditMinRisk: options.auditMinRisk,
|
|
217
92
|
}
|
|
218
93
|
|
|
219
|
-
|
|
94
|
+
const report = await runDoctor(orchestratorOpts)
|
|
95
|
+
const format = resolveFormat(options)
|
|
220
96
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
result: { file: relPath, diagnostics: remaining, fixed: migrated.changes.length > 0 },
|
|
229
|
-
fixCount: migrated.changes.length,
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return { result: null, fixCount: 0 }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function checkFileDetectOnly(file: string, relPath: string): FileResult | null {
|
|
236
|
-
let code: string
|
|
237
|
-
try {
|
|
238
|
-
code = fs.readFileSync(file, 'utf-8')
|
|
239
|
-
} catch {
|
|
240
|
-
return null
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (!hasReactPatterns(code)) return null
|
|
244
|
-
|
|
245
|
-
const diagnostics = detectReactPatterns(code, relPath)
|
|
246
|
-
if (diagnostics.length > 0) {
|
|
247
|
-
return { file: relPath, diagnostics, fixed: false }
|
|
248
|
-
}
|
|
249
|
-
return null
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function runChecks(files: string[], options: DoctorOptions): DoctorResult {
|
|
253
|
-
const fileResults: FileResult[] = []
|
|
254
|
-
let totalFixed = 0
|
|
255
|
-
|
|
256
|
-
for (const file of files) {
|
|
257
|
-
const relPath = path.relative(options.cwd, file)
|
|
258
|
-
|
|
259
|
-
if (options.fix) {
|
|
260
|
-
const { result, fixCount } = checkFileWithFix(file, relPath)
|
|
261
|
-
totalFixed += fixCount
|
|
262
|
-
if (result) fileResults.push(result)
|
|
263
|
-
} else {
|
|
264
|
-
const result = checkFileDetectOnly(file, relPath)
|
|
265
|
-
if (result) fileResults.push(result)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const totalErrors = fileResults.reduce((sum, f) => sum + f.diagnostics.length, 0)
|
|
270
|
-
const totalFixable = fileResults.reduce(
|
|
271
|
-
(sum, f) => sum + f.diagnostics.filter((d) => d.fixable).length,
|
|
272
|
-
0,
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
passed: totalErrors === 0,
|
|
277
|
-
files: fileResults,
|
|
278
|
-
summary: {
|
|
279
|
-
filesScanned: files.length,
|
|
280
|
-
filesWithIssues: fileResults.length,
|
|
281
|
-
totalErrors,
|
|
282
|
-
totalFixable,
|
|
283
|
-
totalFixed,
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
289
|
-
// Output formatters
|
|
290
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
291
|
-
|
|
292
|
-
function printJson(result: DoctorResult): void {
|
|
293
|
-
console.log(JSON.stringify(result, null, 2))
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function printFileResult(fileResult: FileResult): void {
|
|
297
|
-
if (fileResult.diagnostics.length === 0) return
|
|
298
|
-
|
|
299
|
-
console.log(` ${fileResult.file}${fileResult.fixed ? ' (partially fixed)' : ''}`)
|
|
300
|
-
|
|
301
|
-
for (const diag of fileResult.diagnostics) {
|
|
302
|
-
const fixTag = diag.fixable ? ' [fixable]' : ''
|
|
303
|
-
console.log(` ${diag.line}:${diag.column} — ${diag.message}${fixTag}`)
|
|
304
|
-
console.log(` Current: ${diag.current}`)
|
|
305
|
-
console.log(` Suggested: ${diag.suggested}`)
|
|
306
|
-
console.log('')
|
|
97
|
+
if (format === 'json') {
|
|
98
|
+
console.log(renderJson(report))
|
|
99
|
+
} else if (format === 'gha') {
|
|
100
|
+
console.log(renderGha(report))
|
|
101
|
+
} else {
|
|
102
|
+
console.log(renderText(report, { cwd: options.cwd }))
|
|
307
103
|
}
|
|
308
|
-
}
|
|
309
104
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
console.log(` ${summary.totalFixable} auto-fixable — run 'pyreon doctor --fix' to apply`)
|
|
316
|
-
}
|
|
317
|
-
console.log('')
|
|
105
|
+
// Exit code: in --ci mode, any error finding fails. Otherwise, only
|
|
106
|
+
// a non-zero is returned when there are findings AT ALL — so
|
|
107
|
+
// `pyreon doctor && echo green` works as a quick gate.
|
|
108
|
+
if (options.ci) return report.totals.errors
|
|
109
|
+
return report.totals.errors + report.totals.warnings + report.totals.infos
|
|
318
110
|
}
|
|
319
111
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
console.log('')
|
|
324
|
-
console.log(` Pyreon Doctor — scanned ${summary.filesScanned} files in ${elapsed}ms`)
|
|
325
|
-
console.log('')
|
|
326
|
-
|
|
327
|
-
if (result.passed && summary.totalFixed === 0) {
|
|
328
|
-
console.log(' ✓ No issues found. Your code is Pyreon-native!')
|
|
329
|
-
console.log('')
|
|
330
|
-
return
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (summary.totalFixed > 0) {
|
|
334
|
-
console.log(` ✓ Auto-fixed ${summary.totalFixed} issue${summary.totalFixed === 1 ? '' : 's'}`)
|
|
335
|
-
console.log('')
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
for (const fileResult of result.files) {
|
|
339
|
-
printFileResult(fileResult)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
printSummary(summary)
|
|
343
|
-
}
|
|
112
|
+
// Re-export the report types for external consumers (CI integrations,
|
|
113
|
+
// AI agents, dashboards).
|
|
114
|
+
export type { DoctorReport, GateName }
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,26 @@
|
|
|
4
4
|
* @pyreon/cli — Developer tools for Pyreon
|
|
5
5
|
*
|
|
6
6
|
* Commands:
|
|
7
|
-
* pyreon doctor
|
|
8
|
-
* pyreon context
|
|
7
|
+
* pyreon doctor — project-wide health audit (score + per-category bars + findings)
|
|
8
|
+
* pyreon context — generate .pyreon/context.json for AI tools
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { generateContext } from './context'
|
|
12
12
|
import { type DoctorOptions, doctor } from './doctor'
|
|
13
|
+
import type { GateName } from './doctor/orchestrator'
|
|
14
|
+
|
|
15
|
+
const VALID_GATES: GateName[] = [
|
|
16
|
+
'react-patterns',
|
|
17
|
+
'pyreon-patterns',
|
|
18
|
+
'lint',
|
|
19
|
+
'distribution',
|
|
20
|
+
'doc-claims',
|
|
21
|
+
'audit-tests',
|
|
22
|
+
'islands-audit',
|
|
23
|
+
'ssg-audit',
|
|
24
|
+
'audit-types',
|
|
25
|
+
'bundle-budgets',
|
|
26
|
+
]
|
|
13
27
|
|
|
14
28
|
const args = process.argv.slice(2)
|
|
15
29
|
const command = args[0]
|
|
@@ -19,24 +33,69 @@ function printUsage(): void {
|
|
|
19
33
|
pyreon <command> [options]
|
|
20
34
|
|
|
21
35
|
Commands:
|
|
22
|
-
doctor [
|
|
23
|
-
|
|
24
|
-
--audit-tests appends mock-vnode test-audit (PR #197 class).
|
|
25
|
-
--audit-min-risk is high|medium|low (default medium).
|
|
26
|
-
--check-islands appends project-wide islands audit
|
|
27
|
-
(duplicate names, dead islands, registry drift, nested,
|
|
28
|
-
never-with-registry).
|
|
29
|
-
--check-ssg appends project-wide SSG / ISR audit
|
|
30
|
-
(_404.tsx placement, dynamic routes missing
|
|
31
|
-
getStaticPaths, non-literal revalidate exports).
|
|
36
|
+
doctor [options] Project-wide health audit with 0-100 score.
|
|
37
|
+
Runs 8 fast gates by default; --full enables 2 slow gates.
|
|
32
38
|
context [--out <path>] Generate .pyreon/context.json for AI tools
|
|
33
39
|
|
|
40
|
+
doctor options:
|
|
41
|
+
--fix Auto-fix what we can (lint + react-patterns).
|
|
42
|
+
--full Include slow gates (audit-types, bundle-budgets).
|
|
43
|
+
--only <gates> Run ONLY these gates (comma-separated).
|
|
44
|
+
--skip <gates> Skip these gates (comma-separated).
|
|
45
|
+
--format text|json|gha Output format (default: text).
|
|
46
|
+
--json Shortcut for --format=json.
|
|
47
|
+
--gha Shortcut for --format=gha (GitHub Actions annotations).
|
|
48
|
+
--ci Exit non-zero on error findings only.
|
|
49
|
+
--audit-min-risk high|medium|low Minimum risk for test-env audit (default: medium).
|
|
50
|
+
|
|
51
|
+
doctor gates:
|
|
52
|
+
Fast: ${VALID_GATES.slice(0, 8).join(', ')}
|
|
53
|
+
Slow: ${VALID_GATES.slice(8).join(', ')} (require --full)
|
|
54
|
+
|
|
55
|
+
Legacy doctor flags (still work — map to --only shortcuts):
|
|
56
|
+
--audit-tests Equivalent to --only audit-tests
|
|
57
|
+
--check-islands Equivalent to --only islands-audit
|
|
58
|
+
--check-ssg Equivalent to --only ssg-audit
|
|
59
|
+
|
|
34
60
|
Options:
|
|
35
61
|
--help Show this help message
|
|
36
62
|
--version Show version
|
|
37
63
|
`)
|
|
38
64
|
}
|
|
39
65
|
|
|
66
|
+
const parseGateList = (raw: string | undefined): GateName[] | undefined => {
|
|
67
|
+
if (!raw) return undefined
|
|
68
|
+
const names = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
69
|
+
const invalid = names.filter((n) => !VALID_GATES.includes(n as GateName))
|
|
70
|
+
if (invalid.length > 0) {
|
|
71
|
+
console.error(
|
|
72
|
+
`Unknown gate(s): ${invalid.join(', ')}. Valid: ${VALID_GATES.join(', ')}`,
|
|
73
|
+
)
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
return names as GateName[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getFlagValue = (flag: string): string | undefined => {
|
|
80
|
+
const idx = args.indexOf(flag)
|
|
81
|
+
if (idx < 0) return undefined
|
|
82
|
+
return args[idx + 1]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parseFormat = (raw: string | undefined): DoctorOptions['format'] => {
|
|
86
|
+
if (!raw) return undefined
|
|
87
|
+
if (raw === 'text' || raw === 'json' || raw === 'gha') return raw
|
|
88
|
+
console.error(`--format must be text|json|gha, got '${raw}'`)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parseMinRisk = (raw: string | undefined): DoctorOptions['auditMinRisk'] => {
|
|
93
|
+
if (!raw) return undefined
|
|
94
|
+
if (raw === 'high' || raw === 'medium' || raw === 'low') return raw
|
|
95
|
+
console.error(`--audit-min-risk must be high|medium|low, got '${raw}'`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
40
99
|
async function main(): Promise<void> {
|
|
41
100
|
if (!command || command === '--help' || command === '-h') {
|
|
42
101
|
printUsage()
|
|
@@ -49,19 +108,21 @@ async function main(): Promise<void> {
|
|
|
49
108
|
}
|
|
50
109
|
|
|
51
110
|
if (command === 'doctor') {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.exit(1)
|
|
57
|
-
}
|
|
111
|
+
const format = args.includes('--gha')
|
|
112
|
+
? ('gha' as const)
|
|
113
|
+
: parseFormat(getFlagValue('--format'))
|
|
114
|
+
|
|
58
115
|
const options: DoctorOptions = {
|
|
59
116
|
fix: args.includes('--fix'),
|
|
60
117
|
json: args.includes('--json'),
|
|
61
118
|
ci: args.includes('--ci'),
|
|
62
119
|
cwd: process.cwd(),
|
|
120
|
+
format,
|
|
121
|
+
full: args.includes('--full'),
|
|
122
|
+
only: parseGateList(getFlagValue('--only')),
|
|
123
|
+
skip: parseGateList(getFlagValue('--skip')),
|
|
63
124
|
auditTests: args.includes('--audit-tests'),
|
|
64
|
-
auditMinRisk:
|
|
125
|
+
auditMinRisk: parseMinRisk(getFlagValue('--audit-min-risk')),
|
|
65
126
|
checkIslands: args.includes('--check-islands'),
|
|
66
127
|
checkSsg: args.includes('--check-ssg'),
|
|
67
128
|
}
|
|
@@ -90,5 +151,5 @@ main().catch((err) => {
|
|
|
90
151
|
})
|
|
91
152
|
|
|
92
153
|
export type { ContextOptions, ProjectContext } from './context'
|
|
93
|
-
export type { DoctorOptions } from './doctor'
|
|
154
|
+
export type { DoctorOptions, DoctorReport, GateName } from './doctor'
|
|
94
155
|
export { doctor, generateContext }
|