@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.
@@ -0,0 +1,340 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { generateContext } from "../context"
5
+
6
+ function makeTmpDir(): string {
7
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pyreon-ctx-"))
8
+ return dir
9
+ }
10
+
11
+ function writeFile(dir: string, relPath: string, content: string): void {
12
+ const full = path.join(dir, relPath)
13
+ fs.mkdirSync(path.dirname(full), { recursive: true })
14
+ fs.writeFileSync(full, content, "utf-8")
15
+ }
16
+
17
+ describe("generateContext", () => {
18
+ let tmpDir: string
19
+
20
+ beforeEach(() => {
21
+ tmpDir = makeTmpDir()
22
+ // Every project needs a package.json
23
+ writeFile(
24
+ tmpDir,
25
+ "package.json",
26
+ JSON.stringify({
27
+ name: "test-app",
28
+ version: "1.0.0",
29
+ dependencies: { "@pyreon/core": "^0.4.0" },
30
+ }),
31
+ )
32
+ })
33
+
34
+ afterEach(() => {
35
+ fs.rmSync(tmpDir, { recursive: true, force: true })
36
+ })
37
+
38
+ it("extracts routes from createRouter([...]) calls", async () => {
39
+ writeFile(
40
+ tmpDir,
41
+ "src/router.ts",
42
+ `
43
+ import { createRouter } from "@pyreon/router"
44
+
45
+ export default createRouter([
46
+ { path: "/", component: Home },
47
+ { path: "/about", component: About },
48
+ ])
49
+ `,
50
+ )
51
+
52
+ const ctx = await generateContext({ cwd: tmpDir })
53
+ expect(ctx.routes).toHaveLength(2)
54
+ expect(ctx.routes[0]?.path).toBe("/")
55
+ expect(ctx.routes[1]?.path).toBe("/about")
56
+ })
57
+
58
+ it("extracts route params (:id, :slug)", async () => {
59
+ writeFile(
60
+ tmpDir,
61
+ "src/router.ts",
62
+ `
63
+ const routes = [
64
+ { path: "/users/:id", component: User },
65
+ { path: "/posts/:slug/comments/:commentId", component: Comment },
66
+ ]
67
+ `,
68
+ )
69
+
70
+ const ctx = await generateContext({ cwd: tmpDir })
71
+ expect(ctx.routes).toHaveLength(2)
72
+ expect(ctx.routes[0]?.params).toEqual(["id"])
73
+ expect(ctx.routes[1]?.params).toEqual(["slug", "commentId"])
74
+ })
75
+
76
+ it("detects loader and guard presence on a route", async () => {
77
+ writeFile(
78
+ tmpDir,
79
+ "src/router.ts",
80
+ `
81
+ const routes = [
82
+ { path: "/dashboard", component: Dashboard, loader: fetchDashboard, beforeEnter: authGuard },
83
+ ]
84
+ `,
85
+ )
86
+
87
+ const ctx = await generateContext({ cwd: tmpDir })
88
+ expect(ctx.routes).toHaveLength(1)
89
+
90
+ const dashboard = ctx.routes[0]
91
+ expect(dashboard?.hasLoader).toBe(true)
92
+ expect(dashboard?.hasGuard).toBe(true)
93
+ })
94
+
95
+ it("reports no loader/guard when absent", async () => {
96
+ writeFile(
97
+ tmpDir,
98
+ "src/router.ts",
99
+ `
100
+ const routes = [
101
+ { path: "/public", component: Public },
102
+ ]
103
+ `,
104
+ )
105
+
106
+ const ctx = await generateContext({ cwd: tmpDir })
107
+ expect(ctx.routes).toHaveLength(1)
108
+
109
+ const publicRoute = ctx.routes[0]
110
+ expect(publicRoute?.hasLoader).toBe(false)
111
+ expect(publicRoute?.hasGuard).toBe(false)
112
+ })
113
+
114
+ it("extracts component names and props", async () => {
115
+ writeFile(
116
+ tmpDir,
117
+ "src/components/Button.tsx",
118
+ `
119
+ export function Button({ label, onClick, disabled }) {
120
+ return <button disabled={disabled} onClick={onClick}>{label}</button>
121
+ }
122
+ `,
123
+ )
124
+
125
+ const ctx = await generateContext({ cwd: tmpDir })
126
+ const button = ctx.components.find((c) => c.name === "Button")
127
+ expect(button).toBeDefined()
128
+ expect(button?.props).toContain("label")
129
+ expect(button?.props).toContain("onClick")
130
+ expect(button?.props).toContain("disabled")
131
+ })
132
+
133
+ it("detects signal usage in components", async () => {
134
+ writeFile(
135
+ tmpDir,
136
+ "src/components/Counter.tsx",
137
+ `
138
+ export function Counter() {
139
+ const count = signal<number>(0)
140
+ const doubled = signal<number>(0)
141
+ return <div>{count()}</div>
142
+ }
143
+ `,
144
+ )
145
+
146
+ const ctx = await generateContext({ cwd: tmpDir })
147
+ const counter = ctx.components.find((c) => c.name === "Counter")
148
+ expect(counter).toBeDefined()
149
+ expect(counter?.hasSignals).toBe(true)
150
+ expect(counter?.signalNames).toContain("count")
151
+ expect(counter?.signalNames).toContain("doubled")
152
+ })
153
+
154
+ it("extracts island declarations", async () => {
155
+ writeFile(
156
+ tmpDir,
157
+ "src/islands/Search.tsx",
158
+ `
159
+ import { island } from "@pyreon/server"
160
+
161
+ export const SearchIsland = island(() => import("./SearchWidget"), { name: "Search", hydrate: "visible" })
162
+ `,
163
+ )
164
+
165
+ const ctx = await generateContext({ cwd: tmpDir })
166
+ expect(ctx.islands).toHaveLength(1)
167
+ expect(ctx.islands[0]?.name).toBe("Search")
168
+ expect(ctx.islands[0]?.file).toBe(path.join("src", "islands", "Search.tsx"))
169
+ })
170
+
171
+ it("writes context.json to .pyreon/ directory", async () => {
172
+ writeFile(
173
+ tmpDir,
174
+ "src/App.tsx",
175
+ `
176
+ export function App() {
177
+ return <div>Hello</div>
178
+ }
179
+ `,
180
+ )
181
+
182
+ await generateContext({ cwd: tmpDir })
183
+
184
+ const outPath = path.join(tmpDir, ".pyreon", "context.json")
185
+ expect(fs.existsSync(outPath)).toBe(true)
186
+
187
+ const written = JSON.parse(fs.readFileSync(outPath, "utf-8"))
188
+ expect(written.framework).toBe("pyreon")
189
+ expect(written.version).toBe("0.4.0")
190
+ expect(written.generatedAt).toBeDefined()
191
+ expect(Array.isArray(written.routes)).toBe(true)
192
+ expect(Array.isArray(written.components)).toBe(true)
193
+ expect(Array.isArray(written.islands)).toBe(true)
194
+ })
195
+
196
+ it("handles empty project (no routes/components)", async () => {
197
+ // No .tsx files at all, just the package.json
198
+ const ctx = await generateContext({ cwd: tmpDir })
199
+
200
+ expect(ctx.routes).toEqual([])
201
+ expect(ctx.components).toEqual([])
202
+ expect(ctx.islands).toEqual([])
203
+ expect(ctx.framework).toBe("pyreon")
204
+ })
205
+
206
+ it("skips node_modules and dist directories", async () => {
207
+ writeFile(
208
+ tmpDir,
209
+ "node_modules/@pyreon/core/src/index.tsx",
210
+ `
211
+ export function Internal() {
212
+ return <div>internal</div>
213
+ }
214
+ `,
215
+ )
216
+ writeFile(
217
+ tmpDir,
218
+ "dist/App.tsx",
219
+ `
220
+ export function DistApp() {
221
+ return <div>dist</div>
222
+ }
223
+ `,
224
+ )
225
+ writeFile(
226
+ tmpDir,
227
+ "src/App.tsx",
228
+ `
229
+ export function RealApp() {
230
+ return <div>real</div>
231
+ }
232
+ `,
233
+ )
234
+
235
+ const ctx = await generateContext({ cwd: tmpDir })
236
+ const names = ctx.components.map((c) => c.name)
237
+ expect(names).toContain("RealApp")
238
+ expect(names).not.toContain("Internal")
239
+ expect(names).not.toContain("DistApp")
240
+ })
241
+
242
+ it("extracts routes from const routes = [...] syntax", async () => {
243
+ writeFile(
244
+ tmpDir,
245
+ "src/router.ts",
246
+ `
247
+ const routes: RouteRecord[] = [
248
+ { path: "/home", name: "home", component: Home },
249
+ ]
250
+ `,
251
+ )
252
+
253
+ const ctx = await generateContext({ cwd: tmpDir })
254
+ expect(ctx.routes).toHaveLength(1)
255
+ expect(ctx.routes[0]?.path).toBe("/home")
256
+ expect(ctx.routes[0]?.name).toBe("home")
257
+ })
258
+
259
+ it("defaults island hydrate to 'load' when not specified", async () => {
260
+ writeFile(
261
+ tmpDir,
262
+ "src/islands/Nav.tsx",
263
+ `
264
+ export const NavIsland = island(
265
+ () => import("./NavWidget"),
266
+ { name: "Nav" }
267
+ )
268
+ `,
269
+ )
270
+
271
+ const ctx = await generateContext({ cwd: tmpDir })
272
+ expect(ctx.islands).toHaveLength(1)
273
+ expect(ctx.islands[0]?.hydrate).toBe("load")
274
+ })
275
+
276
+ it("ensures .pyreon/ is added to .gitignore", async () => {
277
+ writeFile(tmpDir, ".gitignore", "node_modules/\n")
278
+
279
+ await generateContext({ cwd: tmpDir })
280
+
281
+ const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8")
282
+ expect(gitignore).toContain(".pyreon/")
283
+ })
284
+
285
+ it("does not duplicate .pyreon/ in .gitignore", async () => {
286
+ writeFile(tmpDir, ".gitignore", "node_modules/\n.pyreon/\n")
287
+
288
+ await generateContext({ cwd: tmpDir })
289
+
290
+ const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8")
291
+ const occurrences = gitignore.split(".pyreon/").length - 1
292
+ expect(occurrences).toBe(1)
293
+ })
294
+
295
+ it("reads version from @pyreon/* dependency", async () => {
296
+ writeFile(tmpDir, "src/App.tsx", `export function App() { return null }`)
297
+
298
+ const ctx = await generateContext({ cwd: tmpDir })
299
+ expect(ctx.version).toBe("0.4.0")
300
+ })
301
+
302
+ it("returns 'unknown' version when no package.json exists", async () => {
303
+ const emptyDir = makeTmpDir()
304
+ try {
305
+ const ctx = await generateContext({ cwd: emptyDir })
306
+ expect(ctx.version).toBe("unknown")
307
+ } finally {
308
+ fs.rmSync(emptyDir, { recursive: true, force: true })
309
+ }
310
+ })
311
+
312
+ it("writes to custom outPath when specified", async () => {
313
+ writeFile(tmpDir, "src/App.tsx", `export function App() { return null }`)
314
+ const customOut = path.join(tmpDir, "custom", "output.json")
315
+
316
+ await generateContext({ cwd: tmpDir, outPath: customOut })
317
+
318
+ expect(fs.existsSync(customOut)).toBe(true)
319
+ const written = JSON.parse(fs.readFileSync(customOut, "utf-8"))
320
+ expect(written.framework).toBe("pyreon")
321
+ })
322
+
323
+ it("detects component without signals", async () => {
324
+ writeFile(
325
+ tmpDir,
326
+ "src/Static.tsx",
327
+ `
328
+ export function Static({ title }) {
329
+ return <h1>{title}</h1>
330
+ }
331
+ `,
332
+ )
333
+
334
+ const ctx = await generateContext({ cwd: tmpDir })
335
+ const comp = ctx.components.find((c) => c.name === "Static")
336
+ expect(comp).toBeDefined()
337
+ expect(comp?.hasSignals).toBe(false)
338
+ expect(comp?.signalNames).toEqual([])
339
+ })
340
+ })
@@ -0,0 +1,257 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+
5
+ import { type DoctorOptions, doctor } from "../doctor"
6
+
7
+ function makeTmpDir(): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pyreon-doctor-"))
9
+ return dir
10
+ }
11
+
12
+ function writeFile(dir: string, relPath: string, content: string): void {
13
+ const full = path.join(dir, relPath)
14
+ fs.mkdirSync(path.dirname(full), { recursive: true })
15
+ fs.writeFileSync(full, content, "utf-8")
16
+ }
17
+
18
+ function readFile(dir: string, relPath: string): string {
19
+ return fs.readFileSync(path.join(dir, relPath), "utf-8")
20
+ }
21
+
22
+ function defaultOptions(cwd: string): DoctorOptions {
23
+ return { fix: false, json: false, ci: false, cwd }
24
+ }
25
+
26
+ const REACT_TSX = `import { useState, useEffect } from "react"
27
+
28
+ export function Counter() {
29
+ const [count, setCount] = useState(0)
30
+ useEffect(() => {
31
+ console.log(count)
32
+ }, [count])
33
+ return <div className="counter">{count}</div>
34
+ }
35
+ `
36
+
37
+ const CLEAN_TSX = `import { signal } from "@pyreon/reactivity"
38
+
39
+ export function Counter() {
40
+ const count = signal(0)
41
+ return <div class="counter">{count()}</div>
42
+ }
43
+ `
44
+
45
+ describe("doctor", () => {
46
+ let tmpDir: string
47
+ let logSpy: ReturnType<typeof vi.spyOn>
48
+
49
+ beforeEach(() => {
50
+ tmpDir = makeTmpDir()
51
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
52
+ })
53
+
54
+ afterEach(() => {
55
+ logSpy.mockRestore()
56
+ fs.rmSync(tmpDir, { recursive: true, force: true })
57
+ })
58
+
59
+ // ─── detect-only mode ──────────────────────────────────────────────────
60
+
61
+ it("detects React patterns in files (no --fix)", async () => {
62
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
63
+
64
+ const errorCount = await doctor(defaultOptions(tmpDir))
65
+
66
+ expect(errorCount).toBeGreaterThan(0)
67
+ })
68
+
69
+ it("reports correct file paths and diagnostic counts", async () => {
70
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
71
+
72
+ const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
73
+ await doctor(opts)
74
+
75
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
76
+ const result = JSON.parse(output)
77
+
78
+ expect(result.passed).toBe(false)
79
+ expect(result.files.length).toBe(1)
80
+ expect(result.files[0].file).toBe(path.join("src", "App.tsx"))
81
+ expect(result.summary.filesWithIssues).toBe(1)
82
+ expect(result.summary.totalErrors).toBeGreaterThan(0)
83
+ // Should detect: react-import, use-state, use-effect-deps, class-name-prop
84
+ const codes = result.files[0].diagnostics.map((d: { code: string }) => d.code)
85
+ expect(codes).toContain("react-import")
86
+ expect(codes).toContain("use-state")
87
+ expect(codes).toContain("class-name-prop")
88
+ })
89
+
90
+ // ─── --fix mode ────────────────────────────────────────────────────────
91
+
92
+ it("--fix mode rewrites files with migrations", async () => {
93
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
94
+
95
+ const opts: DoctorOptions = { fix: true, json: false, ci: false, cwd: tmpDir }
96
+ await doctor(opts)
97
+
98
+ const updated = readFile(tmpDir, "src/App.tsx")
99
+ // React import should be removed or rewritten
100
+ expect(updated).not.toContain('from "react"')
101
+ // useState should be migrated to signal
102
+ expect(updated).toContain("signal")
103
+ // className should be migrated to class
104
+ expect(updated).toContain("class=")
105
+ })
106
+
107
+ it("--fix mode reports totalFixed in JSON output", async () => {
108
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
109
+
110
+ const opts: DoctorOptions = { fix: true, json: true, ci: false, cwd: tmpDir }
111
+ await doctor(opts)
112
+
113
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
114
+ const result = JSON.parse(output)
115
+
116
+ expect(result.summary.totalFixed).toBeGreaterThan(0)
117
+ })
118
+
119
+ // ─── --json mode ───────────────────────────────────────────────────────
120
+
121
+ it("--json mode returns structured JSON output", async () => {
122
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
123
+
124
+ const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
125
+ await doctor(opts)
126
+
127
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
128
+ const result = JSON.parse(output)
129
+
130
+ expect(result).toHaveProperty("passed")
131
+ expect(result).toHaveProperty("files")
132
+ expect(result).toHaveProperty("summary")
133
+ expect(result.summary).toHaveProperty("filesScanned")
134
+ expect(result.summary).toHaveProperty("filesWithIssues")
135
+ expect(result.summary).toHaveProperty("totalErrors")
136
+ expect(result.summary).toHaveProperty("totalFixable")
137
+ expect(result.summary).toHaveProperty("totalFixed")
138
+ expect(Array.isArray(result.files)).toBe(true)
139
+ })
140
+
141
+ // ─── --ci mode ─────────────────────────────────────────────────────────
142
+
143
+ it("--ci mode returns non-zero error count when issues found", async () => {
144
+ writeFile(tmpDir, "src/App.tsx", REACT_TSX)
145
+
146
+ const opts: DoctorOptions = { fix: false, json: false, ci: true, cwd: tmpDir }
147
+ const errorCount = await doctor(opts)
148
+
149
+ expect(errorCount).toBeGreaterThan(0)
150
+ })
151
+
152
+ it("--ci mode returns 0 when no issues found", async () => {
153
+ writeFile(tmpDir, "src/App.tsx", CLEAN_TSX)
154
+
155
+ const opts: DoctorOptions = { fix: false, json: false, ci: true, cwd: tmpDir }
156
+ const errorCount = await doctor(opts)
157
+
158
+ expect(errorCount).toBe(0)
159
+ })
160
+
161
+ // ─── skipping ──────────────────────────────────────────────────────────
162
+
163
+ it("skips node_modules and non-source files", async () => {
164
+ writeFile(tmpDir, "node_modules/some-pkg/index.tsx", REACT_TSX)
165
+ writeFile(tmpDir, "dist/bundle.tsx", REACT_TSX)
166
+ writeFile(tmpDir, "assets/readme.md", "# className something useState")
167
+ writeFile(tmpDir, "src/App.tsx", CLEAN_TSX)
168
+
169
+ const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
170
+ await doctor(opts)
171
+
172
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
173
+ const result = JSON.parse(output)
174
+
175
+ expect(result.passed).toBe(true)
176
+ expect(result.summary.filesWithIssues).toBe(0)
177
+ // Only the clean .tsx in src/ should be scanned
178
+ expect(result.summary.filesScanned).toBe(1)
179
+ })
180
+
181
+ // ─── clean project ─────────────────────────────────────────────────────
182
+
183
+ it("clean project returns no issues", async () => {
184
+ writeFile(tmpDir, "src/App.tsx", CLEAN_TSX)
185
+
186
+ const errorCount = await doctor(defaultOptions(tmpDir))
187
+
188
+ expect(errorCount).toBe(0)
189
+ })
190
+
191
+ it("clean project prints success message in human mode", async () => {
192
+ writeFile(tmpDir, "src/App.tsx", CLEAN_TSX)
193
+
194
+ await doctor(defaultOptions(tmpDir))
195
+
196
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n")
197
+ expect(output).toContain("No issues found")
198
+ })
199
+
200
+ // ─── hasReactPatterns pre-filter ────────────────────────────────────────
201
+
202
+ it("hasReactPatterns pre-filter skips non-React files efficiently", async () => {
203
+ // A file with Pyreon-only code should not produce diagnostics
204
+ const pyreonOnly = `import { signal, computed, effect } from "@pyreon/reactivity"
205
+ import { onMount } from "@pyreon/core"
206
+
207
+ export function App() {
208
+ const count = signal(0)
209
+ const doubled = computed(() => count() * 2)
210
+ effect(() => console.log(doubled()))
211
+ onMount(() => { return undefined })
212
+ return <div class="app">{count()}</div>
213
+ }
214
+ `
215
+ writeFile(tmpDir, "src/App.tsx", pyreonOnly)
216
+
217
+ const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
218
+ await doctor(opts)
219
+
220
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
221
+ const result = JSON.parse(output)
222
+
223
+ expect(result.passed).toBe(true)
224
+ expect(result.summary.totalErrors).toBe(0)
225
+ })
226
+
227
+ // ─── empty directory ────────────────────────────────────────────────────
228
+
229
+ it("handles empty directory with no source files", async () => {
230
+ const errorCount = await doctor(defaultOptions(tmpDir))
231
+
232
+ expect(errorCount).toBe(0)
233
+ })
234
+
235
+ // ─── multiple files ─────────────────────────────────────────────────────
236
+
237
+ it("scans multiple files and aggregates results", async () => {
238
+ writeFile(tmpDir, "src/A.tsx", REACT_TSX)
239
+ writeFile(
240
+ tmpDir,
241
+ "src/B.tsx",
242
+ `import { useState } from "react"
243
+ export function B() { const [x, setX] = useState(0); return <div>{x}</div> }
244
+ `,
245
+ )
246
+ writeFile(tmpDir, "src/C.tsx", CLEAN_TSX)
247
+
248
+ const opts: DoctorOptions = { fix: false, json: true, ci: false, cwd: tmpDir }
249
+ await doctor(opts)
250
+
251
+ const output = logSpy.mock.calls.map((c: unknown[]) => c[0]).join("")
252
+ const result = JSON.parse(output)
253
+
254
+ expect(result.summary.filesScanned).toBe(3)
255
+ expect(result.summary.filesWithIssues).toBe(2)
256
+ })
257
+ })