@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.
package/src/doctor.ts CHANGED
@@ -1,281 +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
- auditTestEnvironment,
21
- type AuditRisk,
22
- detectReactPatterns,
23
- formatTestAudit,
24
- hasReactPatterns,
25
- migrateReactCode,
26
- type ReactDiagnostic,
27
- } 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'
28
37
 
29
38
  export interface DoctorOptions {
30
39
  fix: boolean
40
+ /** Legacy boolean — interpreted as `format = 'json'` if true. */
31
41
  json: boolean
32
42
  ci: boolean
33
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) ────
34
54
  /**
35
- * When true, run the test-environment audit (mock-vnode pattern
36
- * detection) and append the result to the doctor output. Default
37
- * false the audit is scoped to test files only and isn't part of
38
- * the React-migration check pipeline, so we gate it to avoid noise
39
- * 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.
40
58
  */
41
59
  auditTests?: boolean | undefined
42
- /** Minimum risk level to include in the test-audit report. Default 'medium'. */
43
- auditMinRisk?: AuditRisk | undefined
44
- }
45
-
46
- interface FileResult {
47
- file: string
48
- diagnostics: ReactDiagnostic[]
49
- fixed: boolean
50
- }
51
-
52
- interface DoctorResult {
53
- passed: boolean
54
- files: FileResult[]
55
- summary: {
56
- filesScanned: number
57
- filesWithIssues: number
58
- totalErrors: number
59
- totalFixable: number
60
- totalFixed: number
61
- }
62
- }
63
-
64
- export async function doctor(options: DoctorOptions): Promise<number> {
65
- const startTime = performance.now()
66
- const files = collectSourceFiles(options.cwd)
67
- const result = runChecks(files, options)
68
- const elapsed = Math.round(performance.now() - startTime)
69
-
70
- if (options.json) {
71
- printJson(result)
72
- } else {
73
- printHuman(result, elapsed)
74
- }
75
-
76
- // Test-environment audit — optional follow-on pass. We run AFTER the
77
- // main React-migration output so a migration-focused run isn't
78
- // contaminated; pass `--audit-tests` to see it. The exit code is
79
- // unaffected since mock-vnode test risk is a "should review" signal,
80
- // not a "broken build" signal.
81
- if (options.auditTests) {
82
- const auditResult = auditTestEnvironment(options.cwd)
83
- if (options.json) {
84
- console.log('')
85
- console.log(JSON.stringify({ testAudit: auditResult }, null, 2))
86
- } else {
87
- console.log('')
88
- console.log(formatTestAudit(auditResult, { minRisk: options.auditMinRisk ?? 'medium' }))
89
- console.log('')
90
- }
91
- }
92
-
93
- return result.summary.totalErrors
60
+ /** Minimum risk for the test-environment audit. Default 'medium'. */
61
+ auditMinRisk?: 'high' | 'medium' | 'low' | undefined
62
+ /** @deprecated Prefer `--only islands-audit`. */
63
+ checkIslands?: boolean | undefined
64
+ /** @deprecated Prefer `--only ssg-audit`. */
65
+ checkSsg?: boolean | undefined
94
66
  }
95
67
 
96
- // ═══════════════════════════════════════════════════════════════════════════════
97
- // File collection
98
- // ═══════════════════════════════════════════════════════════════════════════════
99
-
100
- const sourceExtensions = new Set(['.tsx', '.jsx', '.ts', '.js'])
101
- const sourceIgnoreDirs = new Set([
102
- 'node_modules',
103
- 'dist',
104
- 'lib',
105
- '.pyreon',
106
- '.git',
107
- '.next',
108
- 'build',
109
- ])
110
-
111
- function shouldSkipDirEntry(entry: fs.Dirent): boolean {
112
- if (!entry.isDirectory()) return false
113
- return entry.name.startsWith('.') || sourceIgnoreDirs.has(entry.name)
114
- }
115
-
116
- function walkSourceFiles(dir: string, results: string[]): void {
117
- let entries: fs.Dirent[]
118
- try {
119
- entries = fs.readdirSync(dir, { withFileTypes: true })
120
- } catch {
121
- return
122
- }
123
-
124
- for (const entry of entries) {
125
- if (shouldSkipDirEntry(entry)) continue
126
-
127
- const fullPath = path.join(dir, entry.name)
128
- if (entry.isDirectory()) {
129
- walkSourceFiles(fullPath, results)
130
- } else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) {
131
- results.push(fullPath)
132
- }
133
- }
68
+ const resolveFormat = (options: DoctorOptions): DoctorFormat => {
69
+ if (options.format) return options.format
70
+ if (options.json) return 'json'
71
+ return 'text'
134
72
  }
135
73
 
136
- function collectSourceFiles(cwd: string): string[] {
137
- const results: string[] = []
138
- walkSourceFiles(cwd, results)
139
- 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
140
82
  }
141
83
 
142
- // ═══════════════════════════════════════════════════════════════════════════════
143
- // Check pipeline
144
- // ═══════════════════════════════════════════════════════════════════════════════
145
-
146
- function checkFileWithFix(
147
- file: string,
148
- relPath: string,
149
- ): { result: FileResult | null; fixCount: number } {
150
- let code: string
151
- try {
152
- code = fs.readFileSync(file, 'utf-8')
153
- } catch {
154
- 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,
155
92
  }
156
93
 
157
- if (!hasReactPatterns(code)) return { result: null, fixCount: 0 }
158
-
159
- const migrated = migrateReactCode(code, relPath)
160
- if (migrated.changes.length > 0) {
161
- fs.writeFileSync(file, migrated.code, 'utf-8')
162
- }
163
- const remaining = detectReactPatterns(migrated.code, relPath)
164
- if (remaining.length > 0 || migrated.changes.length > 0) {
165
- return {
166
- result: { file: relPath, diagnostics: remaining, fixed: migrated.changes.length > 0 },
167
- fixCount: migrated.changes.length,
168
- }
169
- }
170
- return { result: null, fixCount: 0 }
171
- }
172
-
173
- function checkFileDetectOnly(file: string, relPath: string): FileResult | null {
174
- let code: string
175
- try {
176
- code = fs.readFileSync(file, 'utf-8')
177
- } catch {
178
- return null
179
- }
94
+ const report = await runDoctor(orchestratorOpts)
95
+ const format = resolveFormat(options)
180
96
 
181
- if (!hasReactPatterns(code)) return null
182
-
183
- const diagnostics = detectReactPatterns(code, relPath)
184
- if (diagnostics.length > 0) {
185
- return { file: relPath, diagnostics, fixed: false }
186
- }
187
- return null
188
- }
189
-
190
- function runChecks(files: string[], options: DoctorOptions): DoctorResult {
191
- const fileResults: FileResult[] = []
192
- let totalFixed = 0
193
-
194
- for (const file of files) {
195
- const relPath = path.relative(options.cwd, file)
196
-
197
- if (options.fix) {
198
- const { result, fixCount } = checkFileWithFix(file, relPath)
199
- totalFixed += fixCount
200
- if (result) fileResults.push(result)
201
- } else {
202
- const result = checkFileDetectOnly(file, relPath)
203
- if (result) fileResults.push(result)
204
- }
205
- }
206
-
207
- const totalErrors = fileResults.reduce((sum, f) => sum + f.diagnostics.length, 0)
208
- const totalFixable = fileResults.reduce(
209
- (sum, f) => sum + f.diagnostics.filter((d) => d.fixable).length,
210
- 0,
211
- )
212
-
213
- return {
214
- passed: totalErrors === 0,
215
- files: fileResults,
216
- summary: {
217
- filesScanned: files.length,
218
- filesWithIssues: fileResults.length,
219
- totalErrors,
220
- totalFixable,
221
- totalFixed,
222
- },
223
- }
224
- }
225
-
226
- // ═══════════════════════════════════════════════════════════════════════════════
227
- // Output formatters
228
- // ═══════════════════════════════════════════════════════════════════════════════
229
-
230
- function printJson(result: DoctorResult): void {
231
- console.log(JSON.stringify(result, null, 2))
232
- }
233
-
234
- function printFileResult(fileResult: FileResult): void {
235
- if (fileResult.diagnostics.length === 0) return
236
-
237
- console.log(` ${fileResult.file}${fileResult.fixed ? ' (partially fixed)' : ''}`)
238
-
239
- for (const diag of fileResult.diagnostics) {
240
- const fixTag = diag.fixable ? ' [fixable]' : ''
241
- console.log(` ${diag.line}:${diag.column} — ${diag.message}${fixTag}`)
242
- console.log(` Current: ${diag.current}`)
243
- console.log(` Suggested: ${diag.suggested}`)
244
- 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 }))
245
103
  }
246
- }
247
104
 
248
- function printSummary(summary: DoctorResult['summary']): void {
249
- console.log(
250
- ` ${summary.totalErrors} issue${summary.totalErrors === 1 ? '' : 's'} in ${summary.filesWithIssues} file${summary.filesWithIssues === 1 ? '' : 's'}`,
251
- )
252
- if (summary.totalFixable > 0) {
253
- console.log(` ${summary.totalFixable} auto-fixable — run 'pyreon doctor --fix' to apply`)
254
- }
255
- 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
256
110
  }
257
111
 
258
- function printHuman(result: DoctorResult, elapsed: number): void {
259
- const { summary } = result
260
-
261
- console.log('')
262
- console.log(` Pyreon Doctor — scanned ${summary.filesScanned} files in ${elapsed}ms`)
263
- console.log('')
264
-
265
- if (result.passed && summary.totalFixed === 0) {
266
- console.log(' ✓ No issues found. Your code is Pyreon-native!')
267
- console.log('')
268
- return
269
- }
270
-
271
- if (summary.totalFixed > 0) {
272
- console.log(` ✓ Auto-fixed ${summary.totalFixed} issue${summary.totalFixed === 1 ? '' : 's'}`)
273
- console.log('')
274
- }
275
-
276
- for (const fileResult of result.files) {
277
- printFileResult(fileResult)
278
- }
279
-
280
- printSummary(summary)
281
- }
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,18 +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>]
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).
36
+ doctor [options] Project-wide health audit with 0-100 score.
37
+ Runs 8 fast gates by default; --full enables 2 slow gates.
26
38
  context [--out <path>] Generate .pyreon/context.json for AI tools
27
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
+
28
60
  Options:
29
61
  --help Show this help message
30
62
  --version Show version
31
63
  `)
32
64
  }
33
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
+
34
99
  async function main(): Promise<void> {
35
100
  if (!command || command === '--help' || command === '-h') {
36
101
  printUsage()
@@ -43,19 +108,23 @@ async function main(): Promise<void> {
43
108
  }
44
109
 
45
110
  if (command === 'doctor') {
46
- const riskIdx = args.indexOf('--audit-min-risk')
47
- const rawRisk = riskIdx >= 0 ? args[riskIdx + 1] : undefined
48
- if (rawRisk !== undefined && rawRisk !== 'high' && rawRisk !== 'medium' && rawRisk !== 'low') {
49
- console.error(`--audit-min-risk must be high | medium | low, got '${rawRisk}'`)
50
- process.exit(1)
51
- }
111
+ const format = args.includes('--gha')
112
+ ? ('gha' as const)
113
+ : parseFormat(getFlagValue('--format'))
114
+
52
115
  const options: DoctorOptions = {
53
116
  fix: args.includes('--fix'),
54
117
  json: args.includes('--json'),
55
118
  ci: args.includes('--ci'),
56
119
  cwd: process.cwd(),
120
+ format,
121
+ full: args.includes('--full'),
122
+ only: parseGateList(getFlagValue('--only')),
123
+ skip: parseGateList(getFlagValue('--skip')),
57
124
  auditTests: args.includes('--audit-tests'),
58
- auditMinRisk: rawRisk as DoctorOptions['auditMinRisk'],
125
+ auditMinRisk: parseMinRisk(getFlagValue('--audit-min-risk')),
126
+ checkIslands: args.includes('--check-islands'),
127
+ checkSsg: args.includes('--check-ssg'),
59
128
  }
60
129
  const exitCode = await doctor(options)
61
130
  if (options.ci && exitCode > 0) {
@@ -82,5 +151,5 @@ main().catch((err) => {
82
151
  })
83
152
 
84
153
  export type { ContextOptions, ProjectContext } from './context'
85
- export type { DoctorOptions } from './doctor'
154
+ export type { DoctorOptions, DoctorReport, GateName } from './doctor'
86
155
  export { doctor, generateContext }