@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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * pyreon-patterns gate — wraps `@pyreon/compiler:detectPyreonPatterns`.
3
+ *
4
+ * Catches "using Pyreon wrong" mistakes — 12 detector codes today
5
+ * (for-missing-by, props-destructured, signal-write-as-call, etc.).
6
+ * The detector matches the anti-patterns catalogue in
7
+ * `.claude/rules/anti-patterns.md` (entries tagged `[detector: ...]`)
8
+ * 1:1 — so the user reading the doctor output gets the same advice
9
+ * as someone running `validate` via MCP.
10
+ */
11
+
12
+ import * as fs from 'node:fs'
13
+ import * as path from 'node:path'
14
+
15
+ import {
16
+ detectPyreonPatterns,
17
+ hasPyreonPatterns,
18
+ } from '@pyreon/compiler'
19
+
20
+ import type { Finding, GateResult } from '../types'
21
+ import { collectSourceFiles } from '../utils/walk'
22
+
23
+ export interface PyreonPatternsGateOptions {
24
+ cwd: string
25
+ }
26
+
27
+ export const runPyreonPatternsGate = async (
28
+ opts: PyreonPatternsGateOptions,
29
+ ): Promise<GateResult> => {
30
+ const start = Date.now()
31
+ const findings: Finding[] = []
32
+ const files = collectSourceFiles(opts.cwd)
33
+
34
+ for (const file of files) {
35
+ let code: string
36
+ try {
37
+ code = fs.readFileSync(file, 'utf-8')
38
+ } catch {
39
+ continue
40
+ }
41
+ if (!hasPyreonPatterns(code)) continue
42
+
43
+ const relPath = path.relative(opts.cwd, file)
44
+ const diagnostics = detectPyreonPatterns(code, relPath)
45
+
46
+ for (const diag of diagnostics) {
47
+ findings.push({
48
+ category: 'correctness',
49
+ severity: 'warning',
50
+ code: `pyreon-patterns/${diag.code}`,
51
+ gate: 'pyreon-patterns',
52
+ message: diag.message,
53
+ location: {
54
+ path: file,
55
+ relPath,
56
+ line: diag.line,
57
+ column: diag.column,
58
+ },
59
+ fix: diag.suggested,
60
+ })
61
+ }
62
+ }
63
+
64
+ return {
65
+ gate: 'pyreon-patterns',
66
+ category: 'correctness',
67
+ findings,
68
+ meta: { scanned: files.length, elapsedMs: Date.now() - start },
69
+ }
70
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * react-patterns gate — wraps `@pyreon/compiler:detectReactPatterns`.
3
+ *
4
+ * Catches "coming from React" mistakes: `useState` / `useEffect`,
5
+ * `className` / `htmlFor`, `onChange` on inputs, React-package
6
+ * imports, etc. The detector is already used standalone by the
7
+ * pre-PR-2 `pyreon doctor` legacy path; this adapter just emits its
8
+ * findings in the unified `Finding[]` shape so the v2 aggregator can
9
+ * fold them into the score.
10
+ *
11
+ * `--fix` mode delegates to `migrateReactCode` and reports BOTH the
12
+ * applied changes (as `info` findings) AND any residual diagnostics
13
+ * the migration didn't auto-resolve.
14
+ */
15
+
16
+ import * as fs from 'node:fs'
17
+ import * as path from 'node:path'
18
+
19
+ import {
20
+ detectReactPatterns,
21
+ hasReactPatterns,
22
+ migrateReactCode,
23
+ } from '@pyreon/compiler'
24
+
25
+ import type { Finding, GateResult } from '../types'
26
+ import { collectSourceFiles } from '../utils/walk'
27
+
28
+ export interface ReactPatternsGateOptions {
29
+ cwd: string
30
+ /** Apply `migrateReactCode` to each file with React patterns (writes to disk). */
31
+ fix?: boolean | undefined
32
+ }
33
+
34
+ export const runReactPatternsGate = async (
35
+ opts: ReactPatternsGateOptions,
36
+ ): Promise<GateResult> => {
37
+ const start = Date.now()
38
+ const findings: Finding[] = []
39
+ const files = collectSourceFiles(opts.cwd)
40
+
41
+ for (const file of files) {
42
+ let code: string
43
+ try {
44
+ code = fs.readFileSync(file, 'utf-8')
45
+ } catch {
46
+ continue
47
+ }
48
+ if (!hasReactPatterns(code)) continue
49
+
50
+ const relPath = path.relative(opts.cwd, file)
51
+
52
+ if (opts.fix) {
53
+ const migrated = migrateReactCode(code, relPath)
54
+ if (migrated.changes.length > 0) {
55
+ fs.writeFileSync(file, migrated.code, 'utf-8')
56
+ for (const ch of migrated.changes) {
57
+ findings.push({
58
+ category: 'correctness',
59
+ severity: 'info',
60
+ code: `react-patterns/auto-fixed-${ch.type}`,
61
+ gate: 'react-patterns',
62
+ message: `Auto-fixed: ${ch.description}`,
63
+ location: { path: file, relPath, line: ch.line },
64
+ })
65
+ }
66
+ }
67
+ const remaining = detectReactPatterns(migrated.code, relPath)
68
+ for (const diag of remaining) {
69
+ findings.push({
70
+ category: 'correctness',
71
+ severity: diag.fixable ? 'warning' : 'error',
72
+ code: `react-patterns/${diag.code}`,
73
+ gate: 'react-patterns',
74
+ message: diag.message,
75
+ location: {
76
+ path: file,
77
+ relPath,
78
+ line: diag.line,
79
+ column: diag.column,
80
+ },
81
+ fix: diag.suggested,
82
+ fixable: diag.fixable,
83
+ })
84
+ }
85
+ } else {
86
+ const diagnostics = detectReactPatterns(code, relPath)
87
+ for (const diag of diagnostics) {
88
+ findings.push({
89
+ category: 'correctness',
90
+ severity: diag.fixable ? 'warning' : 'error',
91
+ code: `react-patterns/${diag.code}`,
92
+ gate: 'react-patterns',
93
+ message: diag.message,
94
+ location: {
95
+ path: file,
96
+ relPath,
97
+ line: diag.line,
98
+ column: diag.column,
99
+ },
100
+ fix: diag.suggested,
101
+ fixable: diag.fixable,
102
+ })
103
+ }
104
+ }
105
+ }
106
+
107
+ return {
108
+ gate: 'react-patterns',
109
+ category: 'correctness',
110
+ findings,
111
+ meta: { scanned: files.length, elapsedMs: Date.now() - start },
112
+ }
113
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ssg-audit gate — wraps `@pyreon/compiler:auditSsg`.
3
+ *
4
+ * SSG / ISR convention checker (M3.4): `_404.tsx` placement, dynamic
5
+ * routes missing `getStaticPaths`, non-literal revalidate exports.
6
+ * Severities mirror the gate's intent: missing-getStaticPaths is a
7
+ * warn (legit under `mode: 'ssr' | 'isr'`), the other two are errors
8
+ * (silently broken under `mode: 'ssg'`).
9
+ */
10
+
11
+ import { auditSsg, type SsgFindingCode } from '@pyreon/compiler'
12
+
13
+ import type { Finding, GateResult, Severity } from '../types'
14
+
15
+ const SEVERITY_BY_CODE: Record<SsgFindingCode, Severity> = {
16
+ '404-outside-layout-dir': 'error',
17
+ 'dynamic-route-missing-get-static-paths': 'warning',
18
+ 'non-literal-revalidate-export': 'error',
19
+ }
20
+
21
+ export interface SsgAuditGateOptions {
22
+ cwd: string
23
+ }
24
+
25
+ export const runSsgAuditGate = async (
26
+ opts: SsgAuditGateOptions,
27
+ ): Promise<GateResult> => {
28
+ const start = Date.now()
29
+ const findings: Finding[] = []
30
+ const result = auditSsg(opts.cwd)
31
+
32
+ for (const f of result.findings) {
33
+ findings.push({
34
+ category: 'architecture',
35
+ severity: SEVERITY_BY_CODE[f.code] ?? 'error',
36
+ code: `ssg-audit/${f.code}`,
37
+ gate: 'ssg-audit',
38
+ message: f.message,
39
+ location: {
40
+ path: f.location.path,
41
+ relPath: f.location.relPath,
42
+ line: f.location.line,
43
+ column: f.location.column,
44
+ },
45
+ })
46
+ }
47
+
48
+ return {
49
+ gate: 'ssg-audit',
50
+ category: 'architecture',
51
+ findings,
52
+ meta: {
53
+ scanned: result.findings.length,
54
+ elapsedMs: Date.now() - start,
55
+ },
56
+ }
57
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Doctor orchestrator — picks the gate list per mode, runs them in
3
+ * parallel where safe, collects results, hands off to the aggregator.
4
+ *
5
+ * **Gate categorization by speed**:
6
+ * - FAST gates (default): react-patterns, pyreon-patterns, lint,
7
+ * distribution, doc-claims, islands-audit, ssg-audit, audit-tests.
8
+ * Total runtime ~2-5s on the real Pyreon repo.
9
+ * - SLOW gates (`--full` opt-in): audit-types (TS compiler-API walk
10
+ * across 6 packages, ~1-30s), bundle-budgets (Bun.build of every
11
+ * published package, ~15-30s).
12
+ *
13
+ * **Why parallel**: the gates are fully independent — no shared
14
+ * state, no file-write contention (only `--fix` writes, and lint /
15
+ * react-patterns target disjoint file patterns). Running them via
16
+ * `Promise.all` cuts wall-clock from ~5s sequential to ~1-2s for the
17
+ * fast set on a warm cache.
18
+ *
19
+ * **Skip filtering**: `--only` and `--skip` operate on gate names.
20
+ * Skipped gates appear in the report with `meta.skipped: true` so
21
+ * the renderer shows them in the footer ("Skipped: bundle-budgets").
22
+ */
23
+
24
+ import {
25
+ runAuditTestsGate,
26
+ runAuditTypesGate,
27
+ runBundleBudgetsGate,
28
+ runDistributionGate,
29
+ runDocClaimsGate,
30
+ runIslandsAuditGate,
31
+ runLintGate,
32
+ runPyreonPatternsGate,
33
+ runReactPatternsGate,
34
+ runSsgAuditGate,
35
+ } from './gates'
36
+ import { buildReport } from './report'
37
+ import type { DoctorReport, GateResult } from './types'
38
+
39
+ export type GateName =
40
+ | 'react-patterns'
41
+ | 'pyreon-patterns'
42
+ | 'lint'
43
+ | 'distribution'
44
+ | 'doc-claims'
45
+ | 'audit-tests'
46
+ | 'islands-audit'
47
+ | 'ssg-audit'
48
+ | 'audit-types'
49
+ | 'bundle-budgets'
50
+
51
+ /** Gates that run by default (fast). */
52
+ const FAST_GATES: GateName[] = [
53
+ 'react-patterns',
54
+ 'pyreon-patterns',
55
+ 'lint',
56
+ 'distribution',
57
+ 'doc-claims',
58
+ 'islands-audit',
59
+ 'ssg-audit',
60
+ 'audit-tests',
61
+ ]
62
+
63
+ /** Gates that require `--full` to enable. */
64
+ const SLOW_GATES: GateName[] = ['audit-types', 'bundle-budgets']
65
+
66
+ export interface OrchestratorOptions {
67
+ cwd: string
68
+ /** Enable slow gates (audit-types, bundle-budgets). */
69
+ full?: boolean | undefined
70
+ /** Run ONLY these gates (overrides `--full` / default selection). */
71
+ only?: GateName[] | undefined
72
+ /** Exclude these gates from whatever set would otherwise run. */
73
+ skip?: GateName[] | undefined
74
+ /** Apply auto-fixes where supported (lint + react-patterns). */
75
+ fix?: boolean | undefined
76
+ /** Min risk for the test-environment audit. Defaults to `'medium'`. */
77
+ auditMinRisk?: 'high' | 'medium' | 'low' | undefined
78
+ }
79
+
80
+ const skippedGate = (
81
+ gate: GateName,
82
+ category: GateResult['category'],
83
+ reason: string,
84
+ ): GateResult => ({
85
+ gate,
86
+ category,
87
+ findings: [],
88
+ meta: { elapsedMs: 0, skipped: true, skipReason: reason },
89
+ })
90
+
91
+ const ALL_GATE_CATEGORIES: Record<GateName, GateResult['category']> = {
92
+ 'react-patterns': 'correctness',
93
+ 'pyreon-patterns': 'correctness',
94
+ lint: 'correctness',
95
+ distribution: 'architecture',
96
+ 'doc-claims': 'documentation',
97
+ 'audit-tests': 'testing',
98
+ 'islands-audit': 'architecture',
99
+ 'ssg-audit': 'architecture',
100
+ 'audit-types': 'architecture',
101
+ 'bundle-budgets': 'performance',
102
+ }
103
+
104
+ /**
105
+ * Resolve which gates to run.
106
+ *
107
+ * Precedence: `--only` > `--skip` > (`--full` toggles slow gates) > default fast set.
108
+ */
109
+ export const resolveGates = (opts: OrchestratorOptions): GateName[] => {
110
+ if (opts.only && opts.only.length > 0) {
111
+ const skip = new Set(opts.skip ?? [])
112
+ return opts.only.filter((g) => !skip.has(g))
113
+ }
114
+ const base: GateName[] = opts.full ? [...FAST_GATES, ...SLOW_GATES] : [...FAST_GATES]
115
+ const skip = new Set(opts.skip ?? [])
116
+ return base.filter((g) => !skip.has(g))
117
+ }
118
+
119
+ /**
120
+ * Run the gates and build the report. Wall-clock is measured here
121
+ * (vs the aggregator's sum-of-elapsedMs which is total CPU time).
122
+ */
123
+ export const runDoctor = async (
124
+ opts: OrchestratorOptions,
125
+ ): Promise<DoctorReport> => {
126
+ const start = Date.now()
127
+ const selected = new Set(resolveGates(opts))
128
+ const all: GateName[] = [...FAST_GATES, ...SLOW_GATES]
129
+
130
+ const promises = all.map(async (gate): Promise<GateResult> => {
131
+ if (!selected.has(gate)) {
132
+ // Distinguish "user explicitly skipped" from "needs --full".
133
+ const reason = SLOW_GATES.includes(gate) && !opts.full
134
+ ? 'enable with --full'
135
+ : 'skipped'
136
+ return skippedGate(gate, ALL_GATE_CATEGORIES[gate], reason)
137
+ }
138
+ return runGate(gate, opts)
139
+ })
140
+
141
+ const gates = await Promise.all(promises)
142
+ const report = buildReport(gates)
143
+ // Overwrite the sum-of-elapsedMs proxy with the real wall-clock.
144
+ return { ...report, elapsedMs: Date.now() - start }
145
+ }
146
+
147
+ const runGate = async (
148
+ gate: GateName,
149
+ opts: OrchestratorOptions,
150
+ ): Promise<GateResult> => {
151
+ switch (gate) {
152
+ case 'react-patterns':
153
+ return runReactPatternsGate({ cwd: opts.cwd, fix: opts.fix })
154
+ case 'pyreon-patterns':
155
+ return runPyreonPatternsGate({ cwd: opts.cwd })
156
+ case 'lint':
157
+ return runLintGate({ cwd: opts.cwd, fix: opts.fix })
158
+ case 'distribution':
159
+ return runDistributionGate({ cwd: opts.cwd, skipPackProbe: true })
160
+ case 'doc-claims':
161
+ return runDocClaimsGate({ cwd: opts.cwd })
162
+ case 'audit-tests':
163
+ return runAuditTestsGate({
164
+ cwd: opts.cwd,
165
+ minRisk: opts.auditMinRisk ?? 'medium',
166
+ })
167
+ case 'islands-audit':
168
+ return runIslandsAuditGate({ cwd: opts.cwd })
169
+ case 'ssg-audit':
170
+ return runSsgAuditGate({ cwd: opts.cwd })
171
+ case 'audit-types':
172
+ return runAuditTypesGate({ cwd: opts.cwd })
173
+ case 'bundle-budgets':
174
+ return runBundleBudgetsGate({ cwd: opts.cwd })
175
+ }
176
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal ANSI helpers for `pyreon doctor` human output.
3
+ *
4
+ * We deliberately don't pull in `chalk` / `kleur` / etc. — the doctor
5
+ * already lives in `@pyreon/cli` (the entrypoint package) and adding
6
+ * a runtime ANSI library bumps the install footprint. The escapes
7
+ * are tiny and stable.
8
+ *
9
+ * The `enabled` flag respects:
10
+ * - `NO_COLOR=1` → disable (de-facto standard)
11
+ * - `FORCE_COLOR=1` / `0` → force on / off
12
+ * - `process.stdout.isTTY` → default if no override
13
+ * - CI tools like GitHub Actions set `CI=1` but their terminal DOES
14
+ * render ANSI — so CI alone doesn't disable color.
15
+ */
16
+
17
+ // ANSI control codes. ESC (char 27 / 0x1B) + open-bracket (CSI) for
18
+ // SGR codes; ESC + close-bracket-8 for OSC-8 hyperlinks. We build the
19
+ // strings via `String.fromCharCode(27)` rather than `` literals
20
+ // so the source code stays free of non-printable bytes (some editors
21
+ // + lint rules complain about raw ESC).
22
+ const ESC = String.fromCharCode(27)
23
+ const CSI = `${ESC}[`
24
+ const OSC = `${ESC}]`
25
+ // String terminator — ESC + backslash. BEL also works in most
26
+ // terminals but ESC-\ is the spec-compliant form.
27
+ const ST = `${ESC}\\`
28
+
29
+ const isColorEnabled = (): boolean => {
30
+ if (process.env.NO_COLOR) return false
31
+ if (process.env.FORCE_COLOR === '0') return false
32
+ if (process.env.FORCE_COLOR) return true
33
+ return Boolean(process.stdout.isTTY)
34
+ }
35
+
36
+ export const colorEnabled = isColorEnabled()
37
+
38
+ const wrap =
39
+ (open: string, close: string) =>
40
+ (s: string): string =>
41
+ colorEnabled ? `${CSI}${open}m${s}${CSI}${close}m` : s
42
+
43
+ export const bold = wrap('1', '22')
44
+ export const dim = wrap('2', '22')
45
+ export const red = wrap('31', '39')
46
+ export const green = wrap('32', '39')
47
+ export const yellow = wrap('33', '39')
48
+ export const blue = wrap('34', '39')
49
+ export const magenta = wrap('35', '39')
50
+ export const cyan = wrap('36', '39')
51
+ export const gray = wrap('90', '39')
52
+
53
+ /**
54
+ * OSC-8 hyperlink. iTerm2, WezTerm, kitty, modern VSCode terminals
55
+ * render this as a clickable link; other terminals show the visible
56
+ * text and ignore the escape. We use this for file paths so the user
57
+ * can cmd-click a finding's location and jump to it.
58
+ *
59
+ * `url` should be a `file://` URL with optional line/column:
60
+ * `file:///path/to/file.ts#L42`
61
+ */
62
+ export const hyperlink = (text: string, url: string): string => {
63
+ if (!colorEnabled) return text
64
+ return `${OSC}8;;${url}${ST}${text}${OSC}8;;${ST}`
65
+ }
66
+
67
+ /** Build a `file://` URL with optional line / column suffix. */
68
+ export const fileUrl = (
69
+ absPath: string,
70
+ line?: number,
71
+ _column?: number,
72
+ ): string => {
73
+ // file:// URLs don't have a standard line/column shape across
74
+ // terminals; vscode uses `#L<line>`, iTerm2 + others use `:line:col`
75
+ // in the visible text but not the URL. We embed `#L<line>` since
76
+ // it's the form VSCode parses natively (the most common consumer).
77
+ let url = `file://${absPath}`
78
+ if (line !== undefined) url += `#L${line}`
79
+ return url
80
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * GitHub Actions annotation renderer.
3
+ *
4
+ * Emits per-finding `::error file=X,line=Y,col=Z::message` lines that
5
+ * GitHub Actions parses into inline PR annotations (clickable in the
6
+ * "Files changed" tab). One line per finding plus a summary header.
7
+ *
8
+ * Severity map: doctor `error` → GHA `error`, doctor `warning` →
9
+ * GHA `warning`, doctor `info` → GHA `notice`.
10
+ *
11
+ * Reference: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
12
+ */
13
+
14
+ import type { DoctorReport, Severity } from '../types'
15
+
16
+ const GHA_LEVEL: Record<Severity, string> = {
17
+ error: 'error',
18
+ warning: 'warning',
19
+ info: 'notice',
20
+ }
21
+
22
+ const escape = (s: string): string =>
23
+ // GHA annotations require these chars URL-encoded
24
+ s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
25
+
26
+ export const renderGha = (report: DoctorReport): string => {
27
+ const lines: string[] = []
28
+
29
+ // Summary header — visible at the top of the workflow log + on the
30
+ // job summary if `$GITHUB_STEP_SUMMARY` is also written.
31
+ lines.push(
32
+ `::notice::pyreon doctor score: ${report.score}/100 (${report.grade}) — ${report.totals.errors} errors, ${report.totals.warnings} warnings, ${report.totals.infos} info`,
33
+ )
34
+
35
+ for (const f of report.findings) {
36
+ const level = GHA_LEVEL[f.severity]
37
+ const props: string[] = []
38
+ props.push(`title=${escape(f.code)}`)
39
+ if (f.location?.relPath) props.push(`file=${escape(f.location.relPath)}`)
40
+ if (f.location?.line) props.push(`line=${f.location.line}`)
41
+ if (f.location?.column) props.push(`col=${f.location.column}`)
42
+ const msg = f.fix ? `${f.message} — ${f.fix}` : f.message
43
+ lines.push(`::${level} ${props.join(',')}::${escape(msg)}`)
44
+ }
45
+
46
+ return lines.join('\n')
47
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Barrel export for the three doctor renderers.
3
+ */
4
+
5
+ export { renderText, type TextRenderOptions } from './text'
6
+ export { renderJson } from './json'
7
+ export { renderGha } from './gha'
8
+ export * from './ansi'
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Machine-readable JSON renderer. The full `DoctorReport` is
3
+ * structured for stable consumption — AI agents reading the output,
4
+ * CI dashboards, third-party tools all see the same shape.
5
+ *
6
+ * Backward-compatibility note: the legacy `doctor.ts` pre-PR-2
7
+ * emitted a different JSON shape (`{ passed, files, summary }`). The
8
+ * legacy `--json` is preserved via the compat path in the CLI
9
+ * orchestrator; this renderer is the new shape gated on `--format=json`
10
+ * (or default JSON when the v2 flag set is in use).
11
+ */
12
+
13
+ import type { DoctorReport } from '../types'
14
+
15
+ export const renderJson = (report: DoctorReport): string =>
16
+ JSON.stringify(report, null, 2)