@pyreon/cli 0.5.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/context.ts ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * pyreon context — generates .pyreon/context.json for AI tool consumption
3
+ *
4
+ * Scans the project to extract:
5
+ * - Route definitions (paths, params, loaders, guards)
6
+ * - Component inventory (file, props, signals)
7
+ * - Island declarations (name, hydration strategy)
8
+ * - Framework version
9
+ */
10
+
11
+ import * as fs from "node:fs"
12
+ import * as path from "node:path"
13
+
14
+ export interface ContextOptions {
15
+ cwd: string
16
+ outPath?: string | undefined
17
+ }
18
+
19
+ export interface RouteInfo {
20
+ path: string
21
+ name?: string | undefined
22
+ component?: string | undefined
23
+ hasLoader: boolean
24
+ hasGuard: boolean
25
+ params: string[]
26
+ children?: RouteInfo[] | undefined
27
+ }
28
+
29
+ export interface ComponentInfo {
30
+ name: string
31
+ file: string
32
+ hasSignals: boolean
33
+ signalNames: string[]
34
+ props: string[]
35
+ }
36
+
37
+ export interface IslandInfo {
38
+ name: string
39
+ file: string
40
+ hydrate: string
41
+ }
42
+
43
+ export interface ProjectContext {
44
+ framework: "pyreon"
45
+ version: string
46
+ generatedAt: string
47
+ routes: RouteInfo[]
48
+ components: ComponentInfo[]
49
+ islands: IslandInfo[]
50
+ }
51
+
52
+ export async function generateContext(options: ContextOptions): Promise<ProjectContext> {
53
+ const version = readVersion(options.cwd)
54
+ const sourceFiles = collectTsxFiles(options.cwd)
55
+
56
+ const routes = extractRoutes(sourceFiles, options.cwd)
57
+ const components = extractComponents(sourceFiles, options.cwd)
58
+ const islands = extractIslands(sourceFiles, options.cwd)
59
+
60
+ const context: ProjectContext = {
61
+ framework: "pyreon",
62
+ version,
63
+ generatedAt: new Date().toISOString(),
64
+ routes,
65
+ components,
66
+ islands,
67
+ }
68
+
69
+ // Write to .pyreon/context.json
70
+ const outDir = options.outPath ? path.dirname(options.outPath) : path.join(options.cwd, ".pyreon")
71
+ const outFile = options.outPath ?? path.join(outDir, "context.json")
72
+
73
+ if (!fs.existsSync(outDir)) {
74
+ fs.mkdirSync(outDir, { recursive: true })
75
+ }
76
+ fs.writeFileSync(outFile, JSON.stringify(context, null, 2), "utf-8")
77
+
78
+ // Ensure .pyreon/ is in .gitignore
79
+ ensureGitignore(options.cwd)
80
+
81
+ const relOut = path.relative(options.cwd, outFile)
82
+ console.log(
83
+ ` ✓ Generated ${relOut} (${components.length} components, ${routes.length} routes, ${islands.length} islands)`,
84
+ )
85
+
86
+ return context
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════════
90
+ // Extractors
91
+ // ═══════════════════════════════════════════════════════════════════════════════
92
+
93
+ function parseRouteFromBlock(block: string, routeMatch: RegExpExecArray): RouteInfo {
94
+ const routePath = routeMatch[1] ?? ""
95
+ const params = extractParams(routePath)
96
+
97
+ const surroundingStart = Math.max(0, routeMatch.index - 50)
98
+ const surroundingEnd = Math.min(block.length, routeMatch.index + 200)
99
+ const surrounding = block.slice(surroundingStart, surroundingEnd)
100
+
101
+ const hasLoader = /loader\s*:/.test(surrounding)
102
+ const hasGuard = /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding)
103
+ const nameMatch = surrounding.match(/name\s*:\s*["']([^"']+)["']/)
104
+
105
+ return {
106
+ path: routePath,
107
+ name: nameMatch?.[1],
108
+ hasLoader,
109
+ hasGuard,
110
+ params,
111
+ }
112
+ }
113
+
114
+ function extractRoutesFromBlock(block: string): RouteInfo[] {
115
+ const routes: RouteInfo[] = []
116
+ const routeObjRe = /path\s*:\s*["']([^"']+)["']/g
117
+ let routeMatch: RegExpExecArray | null
118
+ while (true) {
119
+ routeMatch = routeObjRe.exec(block)
120
+ if (!routeMatch) break
121
+ routes.push(parseRouteFromBlock(block, routeMatch))
122
+ }
123
+ return routes
124
+ }
125
+
126
+ function extractRoutes(files: string[], _cwd: string): RouteInfo[] {
127
+ const routes: RouteInfo[] = []
128
+
129
+ for (const file of files) {
130
+ let code: string
131
+ try {
132
+ code = fs.readFileSync(file, "utf-8")
133
+ } catch {
134
+ continue
135
+ }
136
+
137
+ const routeArrayRe =
138
+ /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g
139
+ let match: RegExpExecArray | null
140
+ while (true) {
141
+ match = routeArrayRe.exec(code)
142
+ if (!match) break
143
+ const block = match[1] ?? ""
144
+ for (const route of extractRoutesFromBlock(block)) {
145
+ routes.push(route)
146
+ }
147
+ }
148
+ }
149
+
150
+ return routes
151
+ }
152
+
153
+ function parseProps(propsStr: string): string[] {
154
+ return propsStr
155
+ .split(",")
156
+ .map((p) => p.trim().split(":")[0]?.split("=")[0]?.trim() ?? "")
157
+ .filter((p) => p && p !== "props")
158
+ }
159
+
160
+ function collectSignalNames(body: string): string[] {
161
+ const signalNames: string[] = []
162
+ const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g
163
+ let sigMatch: RegExpExecArray | null
164
+ while (true) {
165
+ sigMatch = signalRe.exec(body)
166
+ if (!sigMatch) break
167
+ if (sigMatch[1]) signalNames.push(sigMatch[1])
168
+ }
169
+ return signalNames
170
+ }
171
+
172
+ function extractComponentsFromCode(code: string, relFile: string): ComponentInfo[] {
173
+ const components: ComponentInfo[] = []
174
+ const componentRe =
175
+ /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g
176
+ let match: RegExpExecArray | null
177
+
178
+ while (true) {
179
+ match = componentRe.exec(code)
180
+ if (!match) break
181
+ const name = match[1] ?? "Unknown"
182
+ const props = parseProps(match[2] ?? "")
183
+
184
+ const bodyStart = match.index + match[0].length
185
+ const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2000))
186
+ const signalNames = collectSignalNames(body)
187
+
188
+ components.push({
189
+ name,
190
+ file: relFile,
191
+ hasSignals: signalNames.length > 0,
192
+ signalNames,
193
+ props,
194
+ })
195
+ }
196
+
197
+ return components
198
+ }
199
+
200
+ function extractComponents(files: string[], _cwd: string): ComponentInfo[] {
201
+ const components: ComponentInfo[] = []
202
+
203
+ for (const file of files) {
204
+ let code: string
205
+ try {
206
+ code = fs.readFileSync(file, "utf-8")
207
+ } catch {
208
+ continue
209
+ }
210
+
211
+ const relFile = path.relative(_cwd, file)
212
+ for (const comp of extractComponentsFromCode(code, relFile)) {
213
+ components.push(comp)
214
+ }
215
+ }
216
+
217
+ return components
218
+ }
219
+
220
+ function extractIslands(files: string[], cwd: string): IslandInfo[] {
221
+ const islands: IslandInfo[] = []
222
+
223
+ for (const file of files) {
224
+ let code: string
225
+ try {
226
+ code = fs.readFileSync(file, "utf-8")
227
+ } catch {
228
+ continue
229
+ }
230
+
231
+ const islandRe =
232
+ /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g
233
+ let match: RegExpExecArray | null
234
+ while (true) {
235
+ match = islandRe.exec(code)
236
+ if (!match) break
237
+ if (match[1]) {
238
+ islands.push({
239
+ name: match[1],
240
+ file: path.relative(cwd, file),
241
+ hydrate: match[2] ?? "load",
242
+ })
243
+ }
244
+ }
245
+ }
246
+
247
+ return islands
248
+ }
249
+
250
+ // ═══════════════════════════════════════════════════════════════════════════════
251
+ // Helpers
252
+ // ═══════════════════════════════════════════════════════════════════════════════
253
+
254
+ function extractParams(routePath: string): string[] {
255
+ const params: string[] = []
256
+ const paramRe = /:(\w+)\??/g
257
+ let match: RegExpExecArray | null
258
+ while (true) {
259
+ match = paramRe.exec(routePath)
260
+ if (!match) break
261
+ if (match[1]) params.push(match[1])
262
+ }
263
+ return params
264
+ }
265
+
266
+ function readVersion(cwd: string): string {
267
+ try {
268
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"))
269
+ const deps: Record<string, unknown> = { ...pkg.dependencies, ...pkg.devDependencies }
270
+ for (const [name, version] of Object.entries(deps)) {
271
+ if (name.startsWith("@pyreon/") && typeof version === "string") {
272
+ return version.replace(/^[\^~]/, "")
273
+ }
274
+ }
275
+ return (pkg.version as string) || "unknown"
276
+ } catch {
277
+ return "unknown"
278
+ }
279
+ }
280
+
281
+ const tsxExtensions = new Set([".tsx", ".jsx", ".ts", ".js"])
282
+ const tsxIgnoreDirs = new Set(["node_modules", "dist", "lib", ".pyreon", ".git", "build"])
283
+
284
+ function shouldSkipEntry(entry: fs.Dirent): boolean {
285
+ if (!entry.isDirectory()) return false
286
+ return entry.name.startsWith(".") || tsxIgnoreDirs.has(entry.name)
287
+ }
288
+
289
+ function walkTsxFiles(dir: string, results: string[]): void {
290
+ let entries: fs.Dirent[]
291
+ try {
292
+ entries = fs.readdirSync(dir, { withFileTypes: true })
293
+ } catch {
294
+ return
295
+ }
296
+
297
+ for (const entry of entries) {
298
+ if (shouldSkipEntry(entry)) continue
299
+
300
+ const fullPath = path.join(dir, entry.name)
301
+ if (entry.isDirectory()) {
302
+ walkTsxFiles(fullPath, results)
303
+ } else if (entry.isFile() && tsxExtensions.has(path.extname(entry.name))) {
304
+ results.push(fullPath)
305
+ }
306
+ }
307
+ }
308
+
309
+ function collectTsxFiles(cwd: string): string[] {
310
+ const results: string[] = []
311
+ walkTsxFiles(cwd, results)
312
+ return results
313
+ }
314
+
315
+ function ensureGitignore(cwd: string): void {
316
+ const gitignorePath = path.join(cwd, ".gitignore")
317
+ try {
318
+ const content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : ""
319
+
320
+ if (!content.includes(".pyreon/") && !content.includes(".pyreon\n")) {
321
+ const addition = content.endsWith("\n") ? ".pyreon/\n" : "\n.pyreon/\n"
322
+ fs.appendFileSync(gitignorePath, addition)
323
+ }
324
+ } catch {
325
+ // Ignore errors with .gitignore
326
+ }
327
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * pyreon doctor — project-wide health check for AI-friendly development
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.)
8
+ *
9
+ * 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
13
+ *
14
+ * Fix mode (--fix): auto-applies safe transforms via migrateReactCode
15
+ */
16
+
17
+ import * as fs from "node:fs"
18
+ import * as path from "node:path"
19
+ import {
20
+ detectReactPatterns,
21
+ hasReactPatterns,
22
+ migrateReactCode,
23
+ type ReactDiagnostic,
24
+ } from "@pyreon/compiler"
25
+
26
+ export interface DoctorOptions {
27
+ fix: boolean
28
+ json: boolean
29
+ ci: boolean
30
+ cwd: string
31
+ }
32
+
33
+ interface FileResult {
34
+ file: string
35
+ diagnostics: ReactDiagnostic[]
36
+ fixed: boolean
37
+ }
38
+
39
+ interface DoctorResult {
40
+ passed: boolean
41
+ files: FileResult[]
42
+ summary: {
43
+ filesScanned: number
44
+ filesWithIssues: number
45
+ totalErrors: number
46
+ totalFixable: number
47
+ totalFixed: number
48
+ }
49
+ }
50
+
51
+ export async function doctor(options: DoctorOptions): Promise<number> {
52
+ const startTime = performance.now()
53
+ const files = collectSourceFiles(options.cwd)
54
+ const result = runChecks(files, options)
55
+ const elapsed = Math.round(performance.now() - startTime)
56
+
57
+ if (options.json) {
58
+ printJson(result)
59
+ } else {
60
+ printHuman(result, elapsed)
61
+ }
62
+
63
+ return result.summary.totalErrors
64
+ }
65
+
66
+ // ═══════════════════════════════════════════════════════════════════════════════
67
+ // File collection
68
+ // ═══════════════════════════════════════════════════════════════════════════════
69
+
70
+ const sourceExtensions = new Set([".tsx", ".jsx", ".ts", ".js"])
71
+ const sourceIgnoreDirs = new Set([
72
+ "node_modules",
73
+ "dist",
74
+ "lib",
75
+ ".pyreon",
76
+ ".git",
77
+ ".next",
78
+ "build",
79
+ ])
80
+
81
+ function shouldSkipDirEntry(entry: fs.Dirent): boolean {
82
+ if (!entry.isDirectory()) return false
83
+ return entry.name.startsWith(".") || sourceIgnoreDirs.has(entry.name)
84
+ }
85
+
86
+ function walkSourceFiles(dir: string, results: string[]): void {
87
+ let entries: fs.Dirent[]
88
+ try {
89
+ entries = fs.readdirSync(dir, { withFileTypes: true })
90
+ } catch {
91
+ return
92
+ }
93
+
94
+ for (const entry of entries) {
95
+ if (shouldSkipDirEntry(entry)) continue
96
+
97
+ const fullPath = path.join(dir, entry.name)
98
+ if (entry.isDirectory()) {
99
+ walkSourceFiles(fullPath, results)
100
+ } else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) {
101
+ results.push(fullPath)
102
+ }
103
+ }
104
+ }
105
+
106
+ function collectSourceFiles(cwd: string): string[] {
107
+ const results: string[] = []
108
+ walkSourceFiles(cwd, results)
109
+ return results
110
+ }
111
+
112
+ // ═══════════════════════════════════════════════════════════════════════════════
113
+ // Check pipeline
114
+ // ═══════════════════════════════════════════════════════════════════════════════
115
+
116
+ function checkFileWithFix(
117
+ file: string,
118
+ relPath: string,
119
+ ): { result: FileResult | null; fixCount: number } {
120
+ let code: string
121
+ try {
122
+ code = fs.readFileSync(file, "utf-8")
123
+ } catch {
124
+ return { result: null, fixCount: 0 }
125
+ }
126
+
127
+ if (!hasReactPatterns(code)) return { result: null, fixCount: 0 }
128
+
129
+ const migrated = migrateReactCode(code, relPath)
130
+ if (migrated.changes.length > 0) {
131
+ fs.writeFileSync(file, migrated.code, "utf-8")
132
+ }
133
+ const remaining = detectReactPatterns(migrated.code, relPath)
134
+ if (remaining.length > 0 || migrated.changes.length > 0) {
135
+ return {
136
+ result: { file: relPath, diagnostics: remaining, fixed: migrated.changes.length > 0 },
137
+ fixCount: migrated.changes.length,
138
+ }
139
+ }
140
+ return { result: null, fixCount: 0 }
141
+ }
142
+
143
+ function checkFileDetectOnly(file: string, relPath: string): FileResult | null {
144
+ let code: string
145
+ try {
146
+ code = fs.readFileSync(file, "utf-8")
147
+ } catch {
148
+ return null
149
+ }
150
+
151
+ if (!hasReactPatterns(code)) return null
152
+
153
+ const diagnostics = detectReactPatterns(code, relPath)
154
+ if (diagnostics.length > 0) {
155
+ return { file: relPath, diagnostics, fixed: false }
156
+ }
157
+ return null
158
+ }
159
+
160
+ function runChecks(files: string[], options: DoctorOptions): DoctorResult {
161
+ const fileResults: FileResult[] = []
162
+ let totalFixed = 0
163
+
164
+ for (const file of files) {
165
+ const relPath = path.relative(options.cwd, file)
166
+
167
+ if (options.fix) {
168
+ const { result, fixCount } = checkFileWithFix(file, relPath)
169
+ totalFixed += fixCount
170
+ if (result) fileResults.push(result)
171
+ } else {
172
+ const result = checkFileDetectOnly(file, relPath)
173
+ if (result) fileResults.push(result)
174
+ }
175
+ }
176
+
177
+ const totalErrors = fileResults.reduce((sum, f) => sum + f.diagnostics.length, 0)
178
+ const totalFixable = fileResults.reduce(
179
+ (sum, f) => sum + f.diagnostics.filter((d) => d.fixable).length,
180
+ 0,
181
+ )
182
+
183
+ return {
184
+ passed: totalErrors === 0,
185
+ files: fileResults,
186
+ summary: {
187
+ filesScanned: files.length,
188
+ filesWithIssues: fileResults.length,
189
+ totalErrors,
190
+ totalFixable,
191
+ totalFixed,
192
+ },
193
+ }
194
+ }
195
+
196
+ // ═══════════════════════════════════════════════════════════════════════════════
197
+ // Output formatters
198
+ // ═══════════════════════════════════════════════════════════════════════════════
199
+
200
+ function printJson(result: DoctorResult): void {
201
+ console.log(JSON.stringify(result, null, 2))
202
+ }
203
+
204
+ function printFileResult(fileResult: FileResult): void {
205
+ if (fileResult.diagnostics.length === 0) return
206
+
207
+ console.log(` ${fileResult.file}${fileResult.fixed ? " (partially fixed)" : ""}`)
208
+
209
+ for (const diag of fileResult.diagnostics) {
210
+ const fixTag = diag.fixable ? " [fixable]" : ""
211
+ console.log(` ${diag.line}:${diag.column} — ${diag.message}${fixTag}`)
212
+ console.log(` Current: ${diag.current}`)
213
+ console.log(` Suggested: ${diag.suggested}`)
214
+ console.log("")
215
+ }
216
+ }
217
+
218
+ function printSummary(summary: DoctorResult["summary"]): void {
219
+ console.log(
220
+ ` ${summary.totalErrors} issue${summary.totalErrors === 1 ? "" : "s"} in ${summary.filesWithIssues} file${summary.filesWithIssues === 1 ? "" : "s"}`,
221
+ )
222
+ if (summary.totalFixable > 0) {
223
+ console.log(` ${summary.totalFixable} auto-fixable — run 'pyreon doctor --fix' to apply`)
224
+ }
225
+ console.log("")
226
+ }
227
+
228
+ function printHuman(result: DoctorResult, elapsed: number): void {
229
+ const { summary } = result
230
+
231
+ console.log("")
232
+ console.log(` Pyreon Doctor — scanned ${summary.filesScanned} files in ${elapsed}ms`)
233
+ console.log("")
234
+
235
+ if (result.passed && summary.totalFixed === 0) {
236
+ console.log(" ✓ No issues found. Your code is Pyreon-native!")
237
+ console.log("")
238
+ return
239
+ }
240
+
241
+ if (summary.totalFixed > 0) {
242
+ console.log(` ✓ Auto-fixed ${summary.totalFixed} issue${summary.totalFixed === 1 ? "" : "s"}`)
243
+ console.log("")
244
+ }
245
+
246
+ for (const fileResult of result.files) {
247
+ printFileResult(fileResult)
248
+ }
249
+
250
+ printSummary(summary)
251
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @pyreon/cli — Developer tools for Pyreon
5
+ *
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
9
+ */
10
+
11
+ import { generateContext } from "./context"
12
+ import { type DoctorOptions, doctor } from "./doctor"
13
+
14
+ const args = process.argv.slice(2)
15
+ const command = args[0]
16
+
17
+ function printUsage(): void {
18
+ console.log(`
19
+ pyreon <command> [options]
20
+
21
+ Commands:
22
+ doctor [--fix] [--json] [--ci] Scan for React patterns, bad imports, and common mistakes
23
+ context [--out <path>] Generate .pyreon/context.json for AI tools
24
+
25
+ Options:
26
+ --help Show this help message
27
+ --version Show version
28
+ `)
29
+ }
30
+
31
+ async function main(): Promise<void> {
32
+ if (!command || command === "--help" || command === "-h") {
33
+ printUsage()
34
+ return
35
+ }
36
+
37
+ if (command === "--version" || command === "-v") {
38
+ console.log("0.4.0")
39
+ return
40
+ }
41
+
42
+ if (command === "doctor") {
43
+ const options: DoctorOptions = {
44
+ fix: args.includes("--fix"),
45
+ json: args.includes("--json"),
46
+ ci: args.includes("--ci"),
47
+ cwd: process.cwd(),
48
+ }
49
+ const exitCode = await doctor(options)
50
+ if (options.ci && exitCode > 0) {
51
+ process.exit(1)
52
+ }
53
+ return
54
+ }
55
+
56
+ if (command === "context") {
57
+ const outIdx = args.indexOf("--out")
58
+ const outPath = outIdx >= 0 ? args[outIdx + 1] : undefined
59
+ await generateContext({ cwd: process.cwd(), outPath })
60
+ return
61
+ }
62
+
63
+ console.error(`Unknown command: ${command}`)
64
+ printUsage()
65
+ process.exit(1)
66
+ }
67
+
68
+ main().catch((err) => {
69
+ console.error(err)
70
+ process.exit(1)
71
+ })
72
+
73
+ export type { ContextOptions, ProjectContext } from "./context"
74
+ export type { DoctorOptions } from "./doctor"
75
+ export { doctor, generateContext }