@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/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +468 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +441 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +71 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/context.ts +327 -0
- package/src/doctor.ts +251 -0
- package/src/index.ts +75 -0
- package/src/tests/context.test.ts +340 -0
- package/src/tests/doctor.test.ts +257 -0
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 }
|