@pyreon/mcp 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,215 @@
1
+ /**
2
+ * Project scanner — extracts route, component, and island information from source files.
3
+ */
4
+
5
+ import * as fs from "node:fs"
6
+ import * as path from "node:path"
7
+
8
+ export interface RouteInfo {
9
+ path: string
10
+ name?: string | undefined
11
+ component?: string | undefined
12
+ hasLoader: boolean
13
+ hasGuard: boolean
14
+ params: string[]
15
+ }
16
+
17
+ export interface ComponentInfo {
18
+ name: string
19
+ file: string
20
+ hasSignals: boolean
21
+ signalNames: string[]
22
+ props: string[]
23
+ }
24
+
25
+ export interface IslandInfo {
26
+ name: string
27
+ file: string
28
+ hydrate: string
29
+ }
30
+
31
+ export interface ProjectContext {
32
+ framework: "pyreon"
33
+ version: string
34
+ generatedAt: string
35
+ routes: RouteInfo[]
36
+ components: ComponentInfo[]
37
+ islands: IslandInfo[]
38
+ }
39
+
40
+ export function generateContext(cwd: string): ProjectContext {
41
+ const files = collectSourceFiles(cwd)
42
+ const version = readVersion(cwd)
43
+
44
+ return {
45
+ framework: "pyreon",
46
+ version,
47
+ generatedAt: new Date().toISOString(),
48
+ routes: extractRoutes(files, cwd),
49
+ components: extractComponents(files, cwd),
50
+ islands: extractIslands(files, cwd),
51
+ }
52
+ }
53
+
54
+ function collectSourceFiles(cwd: string): string[] {
55
+ const results: string[] = []
56
+ const extensions = new Set([".tsx", ".jsx", ".ts", ".js"])
57
+ const ignoreDirs = new Set(["node_modules", "dist", "lib", ".pyreon", ".git", "build"])
58
+
59
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: simple recursive walker
60
+ function walk(dir: string): void {
61
+ let entries: fs.Dirent[]
62
+ try {
63
+ entries = fs.readdirSync(dir, { withFileTypes: true })
64
+ } catch {
65
+ return
66
+ }
67
+ for (const entry of entries) {
68
+ if (entry.name.startsWith(".") && entry.isDirectory()) continue
69
+ if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue
70
+ const fullPath = path.join(dir, entry.name)
71
+ if (entry.isDirectory()) {
72
+ walk(fullPath)
73
+ } else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
74
+ results.push(fullPath)
75
+ }
76
+ }
77
+ }
78
+
79
+ walk(cwd)
80
+ return results
81
+ }
82
+
83
+ function extractRoutes(files: string[], _cwd: string): RouteInfo[] {
84
+ const routes: RouteInfo[] = []
85
+
86
+ for (const file of files) {
87
+ let code: string
88
+ try {
89
+ code = fs.readFileSync(file, "utf-8")
90
+ } catch {
91
+ continue
92
+ }
93
+
94
+ const routeArrayRe =
95
+ /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g
96
+ let match: RegExpExecArray | null
97
+ for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
98
+ const block = match[1] ?? ""
99
+ const routeObjRe = /path\s*:\s*["']([^"']+)["']/g
100
+ let routeMatch: RegExpExecArray | null
101
+ for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
102
+ const routePath = routeMatch[1] ?? ""
103
+ const surroundingStart = Math.max(0, routeMatch.index - 50)
104
+ const surroundingEnd = Math.min(block.length, routeMatch.index + 200)
105
+ const surrounding = block.slice(surroundingStart, surroundingEnd)
106
+
107
+ routes.push({
108
+ path: routePath,
109
+ name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
110
+ hasLoader: /loader\s*:/.test(surrounding),
111
+ hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
112
+ params: extractParams(routePath),
113
+ })
114
+ }
115
+ }
116
+ }
117
+
118
+ return routes
119
+ }
120
+
121
+ function extractComponents(files: string[], cwd: string): ComponentInfo[] {
122
+ const components: ComponentInfo[] = []
123
+
124
+ for (const file of files) {
125
+ let code: string
126
+ try {
127
+ code = fs.readFileSync(file, "utf-8")
128
+ } catch {
129
+ continue
130
+ }
131
+
132
+ const componentRe =
133
+ /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g
134
+ let match: RegExpExecArray | null
135
+
136
+ for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
137
+ const name = match[1] ?? "Unknown"
138
+ const propsStr = match[2] ?? ""
139
+ const props = propsStr
140
+ .split(/[,;]/)
141
+ .map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "")
142
+ .filter((p) => p && p !== "props")
143
+
144
+ const bodyStart = match.index + match[0].length
145
+ const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2000))
146
+ const signalNames: string[] = []
147
+ const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g
148
+ let sigMatch: RegExpExecArray | null
149
+ for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) {
150
+ if (sigMatch[1]) signalNames.push(sigMatch[1])
151
+ }
152
+
153
+ components.push({
154
+ name,
155
+ file: path.relative(cwd, file),
156
+ hasSignals: signalNames.length > 0,
157
+ signalNames,
158
+ props,
159
+ })
160
+ }
161
+ }
162
+
163
+ return components
164
+ }
165
+
166
+ function extractIslands(files: string[], cwd: string): IslandInfo[] {
167
+ const islands: IslandInfo[] = []
168
+
169
+ for (const file of files) {
170
+ let code: string
171
+ try {
172
+ code = fs.readFileSync(file, "utf-8")
173
+ } catch {
174
+ continue
175
+ }
176
+
177
+ const islandRe =
178
+ /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g
179
+ let match: RegExpExecArray | null
180
+ for (match = islandRe.exec(code); match; match = islandRe.exec(code)) {
181
+ if (match[1]) {
182
+ islands.push({
183
+ name: match[1],
184
+ file: path.relative(cwd, file),
185
+ hydrate: match[2] ?? "load",
186
+ })
187
+ }
188
+ }
189
+ }
190
+
191
+ return islands
192
+ }
193
+
194
+ function extractParams(routePath: string): string[] {
195
+ const params: string[] = []
196
+ const paramRe = /:(\w+)\??/g
197
+ let match: RegExpExecArray | null
198
+ for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) {
199
+ if (match[1]) params.push(match[1])
200
+ }
201
+ return params
202
+ }
203
+
204
+ function readVersion(cwd: string): string {
205
+ try {
206
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"))
207
+ const deps: Record<string, unknown> = { ...pkg.dependencies, ...pkg.devDependencies }
208
+ for (const [name, ver] of Object.entries(deps)) {
209
+ if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "")
210
+ }
211
+ return (pkg.version as string) || "unknown"
212
+ } catch {
213
+ return "unknown"
214
+ }
215
+ }
@@ -0,0 +1,14 @@
1
+ import { API_REFERENCE } from "../api-reference"
2
+
3
+ describe("api-reference", () => {
4
+ it("has entries", () => {
5
+ expect(Object.keys(API_REFERENCE).length).toBeGreaterThan(0)
6
+ })
7
+
8
+ it("entries have required fields", () => {
9
+ for (const [key, entry] of Object.entries(API_REFERENCE)) {
10
+ expect(entry.signature, `${key} missing signature`).toBeTruthy()
11
+ expect(entry.example, `${key} missing example`).toBeTruthy()
12
+ }
13
+ })
14
+ })
@@ -0,0 +1,294 @@
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 "../project-scanner"
5
+
6
+ function makeTmpDir(): string {
7
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pyreon-scanner-"))
8
+ }
9
+
10
+ function writeFile(dir: string, relPath: string, content: string): void {
11
+ const full = path.join(dir, relPath)
12
+ fs.mkdirSync(path.dirname(full), { recursive: true })
13
+ fs.writeFileSync(full, content, "utf-8")
14
+ }
15
+
16
+ describe("generateContext", () => {
17
+ let tmpDir: string
18
+
19
+ beforeEach(() => {
20
+ tmpDir = makeTmpDir()
21
+ })
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true })
25
+ })
26
+
27
+ it("returns correct framework and version from @pyreon dependency", () => {
28
+ writeFile(
29
+ tmpDir,
30
+ "package.json",
31
+ JSON.stringify({ dependencies: { "@pyreon/core": "^1.2.3" } }),
32
+ )
33
+ const ctx = generateContext(tmpDir)
34
+ expect(ctx.framework).toBe("pyreon")
35
+ expect(ctx.version).toBe("1.2.3")
36
+ expect(ctx.generatedAt).toBeTruthy()
37
+ })
38
+
39
+ it("falls back to package version when no @pyreon dep", () => {
40
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "0.5.0" }))
41
+ const ctx = generateContext(tmpDir)
42
+ expect(ctx.version).toBe("0.5.0")
43
+ })
44
+
45
+ it("returns unknown version when no package.json", () => {
46
+ const ctx = generateContext(tmpDir)
47
+ expect(ctx.version).toBe("unknown")
48
+ })
49
+
50
+ it("extracts routes from createRouter calls", () => {
51
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
52
+ writeFile(
53
+ tmpDir,
54
+ "src/router.ts",
55
+ `
56
+ import { createRouter } from "@pyreon/router"
57
+ const router = createRouter([
58
+ { path: "/", component: Home },
59
+ { path: "/about", component: About },
60
+ ])
61
+ `,
62
+ )
63
+ const ctx = generateContext(tmpDir)
64
+ expect(ctx.routes).toHaveLength(2)
65
+ expect(ctx.routes[0]?.path).toBe("/")
66
+ expect(ctx.routes[1]?.path).toBe("/about")
67
+ })
68
+
69
+ it("extracts routes from routes variable", () => {
70
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
71
+ writeFile(
72
+ tmpDir,
73
+ "src/routes.ts",
74
+ `
75
+ const routes = [
76
+ { path: "/home", component: Home },
77
+ ]
78
+ `,
79
+ )
80
+ const ctx = generateContext(tmpDir)
81
+ expect(ctx.routes).toHaveLength(1)
82
+ expect(ctx.routes[0]?.path).toBe("/home")
83
+ })
84
+
85
+ it("extracts route params", () => {
86
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
87
+ writeFile(
88
+ tmpDir,
89
+ "src/router.ts",
90
+ `
91
+ const routes = [
92
+ { path: "/users/:id/posts/:postId" },
93
+ ]
94
+ `,
95
+ )
96
+ const ctx = generateContext(tmpDir)
97
+ expect(ctx.routes[0]?.params).toEqual(["id", "postId"])
98
+ })
99
+
100
+ it("extracts optional route params", () => {
101
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
102
+ writeFile(
103
+ tmpDir,
104
+ "src/router.ts",
105
+ `
106
+ const routes = [
107
+ { path: "/search/:query?" },
108
+ ]
109
+ `,
110
+ )
111
+ const ctx = generateContext(tmpDir)
112
+ expect(ctx.routes[0]?.params).toEqual(["query"])
113
+ })
114
+
115
+ it("detects loaders and guards", () => {
116
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
117
+ writeFile(
118
+ tmpDir,
119
+ "src/router.ts",
120
+ `
121
+ const routes = [
122
+ { path: "/dash", loader: fetchData, beforeEnter: authGuard },
123
+ ]
124
+ `,
125
+ )
126
+ const ctx = generateContext(tmpDir)
127
+ expect(ctx.routes[0]?.hasLoader).toBe(true)
128
+ expect(ctx.routes[0]?.hasGuard).toBe(true)
129
+ })
130
+
131
+ it("detects beforeLeave guard", () => {
132
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
133
+ writeFile(
134
+ tmpDir,
135
+ "src/router.ts",
136
+ `
137
+ const routes = [
138
+ { path: "/form", beforeLeave: confirmLeave },
139
+ ]
140
+ `,
141
+ )
142
+ const ctx = generateContext(tmpDir)
143
+ expect(ctx.routes[0]?.hasGuard).toBe(true)
144
+ })
145
+
146
+ it("extracts route names", () => {
147
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
148
+ writeFile(
149
+ tmpDir,
150
+ "src/router.ts",
151
+ `
152
+ const routes = [
153
+ { path: "/profile", name: "profile", component: Profile },
154
+ ]
155
+ `,
156
+ )
157
+ const ctx = generateContext(tmpDir)
158
+ expect(ctx.routes[0]?.name).toBe("profile")
159
+ })
160
+
161
+ it("extracts component names with props", () => {
162
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
163
+ writeFile(
164
+ tmpDir,
165
+ "src/Button.tsx",
166
+ `
167
+ export function Button(props: { label: string; disabled: boolean }) {
168
+ return <button>{props.label}</button>
169
+ }
170
+ `,
171
+ )
172
+ const ctx = generateContext(tmpDir)
173
+ expect(ctx.components).toHaveLength(1)
174
+ expect(ctx.components[0]?.name).toBe("Button")
175
+ expect(ctx.components[0]?.file).toBe("src/Button.tsx")
176
+ })
177
+
178
+ it("detects signals in components", () => {
179
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
180
+ writeFile(
181
+ tmpDir,
182
+ "src/Counter.tsx",
183
+ `
184
+ function Counter() {
185
+ const count = signal<number>(0)
186
+ const name = signal<string>("hello")
187
+ return <div>{count()}</div>
188
+ }
189
+ `,
190
+ )
191
+ const ctx = generateContext(tmpDir)
192
+ expect(ctx.components[0]?.hasSignals).toBe(true)
193
+ expect(ctx.components[0]?.signalNames).toEqual(["count", "name"])
194
+ })
195
+
196
+ it("reports no signals when none present", () => {
197
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
198
+ writeFile(
199
+ tmpDir,
200
+ "src/Static.tsx",
201
+ `
202
+ function Static() {
203
+ return <div>hello</div>
204
+ }
205
+ `,
206
+ )
207
+ const ctx = generateContext(tmpDir)
208
+ expect(ctx.components[0]?.hasSignals).toBe(false)
209
+ expect(ctx.components[0]?.signalNames).toEqual([])
210
+ })
211
+
212
+ it("extracts island declarations with hydrate strategy", () => {
213
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
214
+ writeFile(
215
+ tmpDir,
216
+ "src/islands.ts",
217
+ `
218
+ const Counter = island(() => import("./Counter"), { name: "Counter", hydrate: "visible" })
219
+ const Nav = island(() => import("./Nav"), { name: "Nav", hydrate: "idle" })
220
+ `,
221
+ )
222
+ const ctx = generateContext(tmpDir)
223
+ expect(ctx.islands).toHaveLength(2)
224
+ expect(ctx.islands[0]?.name).toBe("Counter")
225
+ expect(ctx.islands[1]?.name).toBe("Nav")
226
+ })
227
+
228
+ it("defaults hydrate to load when not specified", () => {
229
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
230
+ writeFile(
231
+ tmpDir,
232
+ "src/islands.ts",
233
+ `
234
+ const Widget = island(() => import("./Widget"), { name: "Widget" })
235
+ `,
236
+ )
237
+ const ctx = generateContext(tmpDir)
238
+ expect(ctx.islands[0]?.hydrate).toBe("load")
239
+ })
240
+
241
+ it("returns empty arrays for empty project", () => {
242
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
243
+ const ctx = generateContext(tmpDir)
244
+ expect(ctx.routes).toEqual([])
245
+ expect(ctx.components).toEqual([])
246
+ expect(ctx.islands).toEqual([])
247
+ })
248
+
249
+ it("skips node_modules and dist directories", () => {
250
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
251
+ writeFile(
252
+ tmpDir,
253
+ "node_modules/@pyreon/core/src/index.ts",
254
+ `function Internal() { return null }`,
255
+ )
256
+ writeFile(tmpDir, "dist/bundle.ts", `function Bundled() { return null }`)
257
+ writeFile(tmpDir, "src/App.tsx", `function App() { return <div /> }`)
258
+ const ctx = generateContext(tmpDir)
259
+ expect(ctx.components).toHaveLength(1)
260
+ expect(ctx.components[0]?.name).toBe("App")
261
+ })
262
+
263
+ it("handles unreadable directories gracefully", () => {
264
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
265
+ writeFile(tmpDir, "src/App.tsx", `function App() { return <div /> }`)
266
+ const badDir = path.join(tmpDir, "restricted")
267
+ fs.mkdirSync(badDir)
268
+ fs.chmodSync(badDir, 0o000)
269
+
270
+ try {
271
+ const ctx = generateContext(tmpDir)
272
+ expect(ctx.components).toHaveLength(1)
273
+ expect(ctx.components[0]?.name).toBe("App")
274
+ } finally {
275
+ fs.chmodSync(badDir, 0o755)
276
+ }
277
+ })
278
+
279
+ it("extracts const arrow function components", () => {
280
+ writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
281
+ writeFile(
282
+ tmpDir,
283
+ "src/Card.tsx",
284
+ `
285
+ export const Card = (props: CardProps) => {
286
+ return <div>{props.title}</div>
287
+ }
288
+ `,
289
+ )
290
+ const ctx = generateContext(tmpDir)
291
+ expect(ctx.components).toHaveLength(1)
292
+ expect(ctx.components[0]?.name).toBe("Card")
293
+ })
294
+ })