@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/src/doctor.ts CHANGED
@@ -1,343 +1,114 @@
1
1
  /**
2
- * pyreon doctor — project-wide health check for AI-friendly development
2
+ * `pyreon doctor` — project-wide health audit.
3
3
  *
4
- * Runs a pipeline of checks:
5
- * 1. React pattern detection (imports, hooks, JSX attributes)
6
- * 2. Import source validation (@pyreon/* vs react/vue)
7
- * 3. Common Pyreon mistakes (signal without call, key vs by, etc.)
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
- * - Human-readable (default): colored terminal output
11
- * - JSON (--json): structured output for AI agent consumption
12
- * - CI (--ci): exits with code 1 on any error
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
- * Fix mode (--fix): auto-applies safe transforms via migrateReactCode
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
- auditIslands,
21
- auditSsg,
22
- auditTestEnvironment,
23
- type AuditRisk,
24
- detectReactPatterns,
25
- formatIslandAudit,
26
- formatSsgAudit,
27
- formatTestAudit,
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
- * When true, run the test-environment audit (mock-vnode pattern
40
- * detection) and append the result to the doctor output. Default
41
- * false the audit is scoped to test files only and isn't part of
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 level to include in the test-audit report. Default 'medium'. */
47
- auditMinRisk?: AuditRisk | undefined
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
- interface FileResult {
75
- file: string
76
- diagnostics: ReactDiagnostic[]
77
- fixed: boolean
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
- function walkSourceFiles(dir: string, results: string[]): void {
179
- let entries: fs.Dirent[]
180
- try {
181
- entries = fs.readdirSync(dir, { withFileTypes: true })
182
- } catch {
183
- return
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
- // Check pipeline
206
- // ═══════════════════════════════════════════════════════════════════════════════
207
-
208
- function checkFileWithFix(
209
- file: string,
210
- relPath: string,
211
- ): { result: FileResult | null; fixCount: number } {
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
- if (!hasReactPatterns(code)) return { result: null, fixCount: 0 }
94
+ const report = await runDoctor(orchestratorOpts)
95
+ const format = resolveFormat(options)
220
96
 
221
- const migrated = migrateReactCode(code, relPath)
222
- if (migrated.changes.length > 0) {
223
- fs.writeFileSync(file, migrated.code, 'utf-8')
224
- }
225
- const remaining = detectReactPatterns(migrated.code, relPath)
226
- if (remaining.length > 0 || migrated.changes.length > 0) {
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
- function printSummary(summary: DoctorResult['summary']): void {
311
- console.log(
312
- ` ${summary.totalErrors} issue${summary.totalErrors === 1 ? '' : 's'} in ${summary.filesWithIssues} file${summary.filesWithIssues === 1 ? '' : 's'}`,
313
- )
314
- if (summary.totalFixable > 0) {
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
- function printHuman(result: DoctorResult, elapsed: number): void {
321
- const { summary } = result
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 [--fix] [--json] — Scan project for React patterns, bad imports, etc.
8
- * pyreon context Generate .pyreon/context.json for AI tools
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 [--fix] [--json] [--ci] [--audit-tests] [--audit-min-risk <level>] [--check-islands] [--check-ssg]
23
- Scan for React patterns, bad imports, common mistakes.
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 riskIdx = args.indexOf('--audit-min-risk')
53
- const rawRisk = riskIdx >= 0 ? args[riskIdx + 1] : undefined
54
- if (rawRisk !== undefined && rawRisk !== 'high' && rawRisk !== 'medium' && rawRisk !== 'low') {
55
- console.error(`--audit-min-risk must be high | medium | low, got '${rawRisk}'`)
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: rawRisk as DoctorOptions['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 }