@pyreon/compiler 0.15.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.
package/lib/index.js CHANGED
@@ -7,6 +7,312 @@ import * as fs from "node:fs";
7
7
  import { readFileSync, readdirSync, statSync } from "node:fs";
8
8
  import ts from "typescript";
9
9
 
10
+ //#region src/defer-inline.ts
11
+ /**
12
+ * Inline-children transform for `<Defer>`.
13
+ *
14
+ * Rewrites:
15
+ *
16
+ * import { Modal } from './Modal'
17
+ * <Defer when={open()}><Modal /></Defer>
18
+ *
19
+ * into:
20
+ *
21
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
22
+ * {C => <C />}
23
+ * </Defer>
24
+ *
25
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
26
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
27
+ * bundle the module statically and the dynamic import becomes a no-op.
28
+ *
29
+ * Scope of v1 (this file):
30
+ * - Single Defer element per file (no nested handling — bail otherwise).
31
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
32
+ * (component reference), no props. Props or multiple children → leave
33
+ * the Defer untransformed (user must use the explicit `chunk` form).
34
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
35
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
36
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
37
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
38
+ *
39
+ * The transform is intentionally conservative — anything unusual leaves
40
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
41
+ * constraints with closure-capture handling, namespace imports, etc.
42
+ *
43
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
44
+ * output is still JSX — `transformJSX` then converts it to `h()` /
45
+ * `_tpl()` calls as usual.
46
+ */
47
+ /**
48
+ * Detect the language for `parseSync`. `oxc-parser` infers from filename
49
+ * by extension — we mirror the same logic for the few extensions we
50
+ * support so the parser is invoked correctly.
51
+ */
52
+ function getLang$1(filename) {
53
+ if (filename.endsWith(".tsx")) return "tsx";
54
+ if (filename.endsWith(".jsx")) return "jsx";
55
+ if (filename.endsWith(".ts")) return "ts";
56
+ return "js";
57
+ }
58
+ /**
59
+ * Returns the JSX tag name as a string when the opening element's name
60
+ * is a simple identifier (the only shape we recognise as a "named JSX
61
+ * element"). Member-expression names (`<obj.X />`) and namespaced names
62
+ * (`<svg:rect />`) return null — the caller treats those as non-matches.
63
+ */
64
+ function getJsxName(node) {
65
+ const open = node.openingElement;
66
+ if (!open) return null;
67
+ const name = open.name;
68
+ if (!name || name.type !== "JSXIdentifier") return null;
69
+ return name.name;
70
+ }
71
+ /**
72
+ * `<Tag />` qualifies as a "bare component reference child" when:
73
+ * - It's a JSXElement (not text, fragment, or expression container).
74
+ * - The opening name is a capitalised JSXIdentifier (component).
75
+ * - It has no attributes (no props passed).
76
+ * - It's self-closing OR has zero non-whitespace children.
77
+ */
78
+ function isBareComponentChild(node) {
79
+ if (node.type !== "JSXElement") return null;
80
+ const tag = getJsxName(node);
81
+ if (!tag || !/^[A-Z]/.test(tag)) return null;
82
+ if ((node.openingElement.attributes ?? []).length > 0) return null;
83
+ const children = node.children ?? [];
84
+ for (const child of children) {
85
+ if (child.type === "JSXText" && /^\s*$/.test(child.value)) continue;
86
+ return null;
87
+ }
88
+ return { name: tag };
89
+ }
90
+ /**
91
+ * Filter whitespace-only JSXText nodes; return remaining children. JSX
92
+ * source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
93
+ * level: text, element, text. The text nodes are formatting noise.
94
+ */
95
+ function nonWhitespaceChildren(node) {
96
+ return (node.children ?? []).filter((c) => !(c.type === "JSXText" && /^\s*$/.test(c.value)));
97
+ }
98
+ function findDeferMatches(program) {
99
+ const matches = [];
100
+ const walk = (node) => {
101
+ if (!node || typeof node !== "object") return;
102
+ if (node.type === "JSXElement" && getJsxName(node) === "Defer") {
103
+ const open = node.openingElement;
104
+ if (!(open.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "chunk")) {
105
+ const live = nonWhitespaceChildren(node);
106
+ if (live.length === 1) {
107
+ const childInfo = isBareComponentChild(live[0]);
108
+ if (childInfo) {
109
+ const close = node.closingElement;
110
+ matches.push({
111
+ node,
112
+ child: live[0],
113
+ childName: childInfo.name,
114
+ insertChunkAt: open.end - 1,
115
+ childrenRange: {
116
+ start: open.end,
117
+ end: close?.start ?? node.end
118
+ }
119
+ });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ for (const key in node) {
125
+ if (key === "parent") continue;
126
+ const v = node[key];
127
+ if (Array.isArray(v)) for (const item of v) walk(item);
128
+ else if (v && typeof v === "object" && typeof v.type === "string") walk(v);
129
+ }
130
+ };
131
+ walk(program);
132
+ return matches;
133
+ }
134
+ function findImportFor(program, name) {
135
+ const body = program.body ?? [];
136
+ for (const stmt of body) {
137
+ if (stmt.type !== "ImportDeclaration") continue;
138
+ const specifiers = stmt.specifiers ?? [];
139
+ for (const spec of specifiers) if (spec.type === "ImportDefaultSpecifier") {
140
+ if (spec.local.name === name) return {
141
+ node: stmt,
142
+ source: stmt.source.value,
143
+ kind: "default"
144
+ };
145
+ } else if (spec.type === "ImportSpecifier") {
146
+ const local = spec.local.name;
147
+ const imported = spec.imported?.name;
148
+ if (local === name && imported !== void 0 && imported === local) return {
149
+ node: stmt,
150
+ source: stmt.source.value,
151
+ kind: "named"
152
+ };
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ /**
158
+ * Count references to `name` outside the given JSXElement subtree. The
159
+ * static import can only be safely removed if the binding is used
160
+ * EXCLUSIVELY inside that subtree.
161
+ */
162
+ function countReferencesOutside(program, name, skipSubtree) {
163
+ let count = 0;
164
+ const skipStart = skipSubtree.start;
165
+ const skipEnd = skipSubtree.end;
166
+ const countInNode = (node) => {
167
+ if (!node || typeof node !== "object") return;
168
+ const ns = node.start;
169
+ const ne = node.end;
170
+ if (typeof ns === "number" && typeof ne === "number" && ns >= skipStart && ne <= skipEnd) return;
171
+ if (node.type === "Identifier" && node.name === name) count++;
172
+ if (node.type === "JSXIdentifier" && node.name === name) count++;
173
+ for (const key in node) {
174
+ if (key === "parent") continue;
175
+ const v = node[key];
176
+ if (Array.isArray(v)) for (const item of v) countInNode(item);
177
+ else if (v && typeof v === "object" && typeof v.type === "string") countInNode(v);
178
+ }
179
+ };
180
+ const body = program.body ?? [];
181
+ for (const stmt of body) {
182
+ if (stmt.type === "ImportDeclaration") continue;
183
+ countInNode(stmt);
184
+ }
185
+ return count;
186
+ }
187
+ /** Build the chunk={...} attribute string for a default or named import. */
188
+ function buildChunkAttr(source, kind, name) {
189
+ if (kind === "default") return ` chunk={() => import('${source}')}`;
190
+ return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`;
191
+ }
192
+ /**
193
+ * Apply edits to the source string. Edits MUST be non-overlapping; we
194
+ * sort by start descending and splice each into the source so earlier
195
+ * positions stay valid as we work backwards.
196
+ */
197
+ function applyEdits(source, edits) {
198
+ const sorted = [...edits].sort((a, b) => b.start - a.start);
199
+ let out = source;
200
+ for (const e of sorted) out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
201
+ return out;
202
+ }
203
+ /**
204
+ * Main entry. Returns the (possibly transformed) source plus the list
205
+ * of warnings for cases the transform deliberately skipped.
206
+ *
207
+ * Bails (returns input unchanged with `changed: false`) when:
208
+ * - No `<Defer>` JSX element appears in the file (fast path).
209
+ * - The file fails to parse (syntax error — let downstream handle).
210
+ * - No `<Defer>` matches the inline-eligible shape.
211
+ *
212
+ * Per-Defer skips with a warning:
213
+ * - Multiple children → user must use render-prop form
214
+ * - Child has props → user must use render-prop form
215
+ * - Child name isn't imported → can't resolve the chunk source
216
+ * - Child binding is used outside the Defer subtree → can't remove
217
+ * the static import (dynamic import would be a no-op via Rolldown's
218
+ * same-module dedup)
219
+ */
220
+ function transformDeferInline(code, filename = "input.tsx") {
221
+ const warnings = [];
222
+ if (!code.includes("Defer")) return {
223
+ code,
224
+ changed: false,
225
+ warnings
226
+ };
227
+ let program;
228
+ try {
229
+ program = parseSync(filename, code, {
230
+ sourceType: "module",
231
+ lang: getLang$1(filename)
232
+ }).program;
233
+ } catch {
234
+ return {
235
+ code,
236
+ changed: false,
237
+ warnings
238
+ };
239
+ }
240
+ const matches = findDeferMatches(program);
241
+ if (matches.length === 0) return {
242
+ code,
243
+ changed: false,
244
+ warnings
245
+ };
246
+ const edits = [];
247
+ let changed = false;
248
+ for (const m of matches) {
249
+ const importInfo = findImportFor(program, m.childName);
250
+ if (!importInfo) {
251
+ const loc = getLoc(code, m.child.start ?? 0);
252
+ warnings.push({
253
+ message: `<Defer>'s inline child <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
254
+ line: loc.line,
255
+ column: loc.column,
256
+ code: "defer-inline/import-not-found"
257
+ });
258
+ continue;
259
+ }
260
+ if (countReferencesOutside(program, m.childName, m.node) > 0) {
261
+ const loc = getLoc(code, m.node.start ?? 0);
262
+ warnings.push({
263
+ message: `<Defer>'s inline child <${m.childName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
264
+ line: loc.line,
265
+ column: loc.column,
266
+ code: "defer-inline/import-used-elsewhere"
267
+ });
268
+ continue;
269
+ }
270
+ edits.push({
271
+ start: m.insertChunkAt,
272
+ end: m.insertChunkAt,
273
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, m.childName)
274
+ });
275
+ edits.push({
276
+ start: m.childrenRange.start,
277
+ end: m.childrenRange.end,
278
+ replacement: `{(__C) => <__C />}`
279
+ });
280
+ const impStart = importInfo.node.start;
281
+ let impEnd = importInfo.node.end;
282
+ if (code[impEnd] === "\n") impEnd += 1;
283
+ edits.push({
284
+ start: impStart,
285
+ end: impEnd,
286
+ replacement: ""
287
+ });
288
+ changed = true;
289
+ }
290
+ if (!changed) return {
291
+ code,
292
+ changed: false,
293
+ warnings
294
+ };
295
+ return {
296
+ code: applyEdits(code, edits),
297
+ changed: true,
298
+ warnings
299
+ };
300
+ }
301
+ /** Resolve a byte offset into 1-based line + 0-based column. */
302
+ function getLoc(code, offset) {
303
+ let line = 1;
304
+ let lastNl = -1;
305
+ for (let i = 0; i < offset && i < code.length; i++) if (code.charCodeAt(i) === 10) {
306
+ line++;
307
+ lastNl = i;
308
+ }
309
+ return {
310
+ line,
311
+ column: offset - lastNl - 1
312
+ };
313
+ }
314
+
315
+ //#endregion
10
316
  //#region src/event-names.ts
11
317
  /**
12
318
  * React-style → DOM event-name remap.
@@ -56,6 +362,102 @@ import ts from "typescript";
56
362
  */
57
363
  const REACT_EVENT_REMAP = Object.freeze({ doubleclick: "dblclick" });
58
364
 
365
+ //#endregion
366
+ //#region src/load-native.ts
367
+ /**
368
+ * Native binding loader — resolves the @pyreon/compiler napi-rs binary
369
+ * via two paths in priority order:
370
+ *
371
+ * 1. **In-tree binary** at `<package>/native/pyreon-compiler.node`.
372
+ * Populated by `scripts/build-native.ts` during local development
373
+ * (Phase 2). Faster path because it skips npm-package resolution.
374
+ *
375
+ * 2. **Per-platform npm package** (Phase 5b — not active until per-
376
+ * platform packages are published). Resolves `@pyreon/compiler-
377
+ * <platform>-<arch>[-<libc>]` via the standard Node module
378
+ * resolution algorithm. End users on machines without a local
379
+ * `cargo` install will hit this path: `bun install` resolves
380
+ * `optionalDependencies` to the matching per-platform package and
381
+ * this loader picks it up.
382
+ *
383
+ * 3. **JS fallback** (caller's responsibility) — if both paths fail,
384
+ * `loadNativeBinding()` returns `null` and the caller uses the
385
+ * pure-JS implementation. Slower but correctness-equivalent.
386
+ *
387
+ * Platform detection follows the napi-rs convention. Linux variants
388
+ * include a `libc` suffix (`gnu` for glibc, `musl` for musl) per
389
+ * https://napi.rs/docs/cli/build#deployment.
390
+ *
391
+ * The two-path resolution lets dev-mode (where `cargo build` produced
392
+ * an in-tree binary) and production-mode (where the user has only the
393
+ * published per-platform package) coexist with no flag flipping.
394
+ */
395
+ const nodeProcess = process;
396
+ /**
397
+ * Resolve the per-platform package name following the napi-rs naming
398
+ * convention: `@pyreon/compiler-<platform>-<arch>[-<libc>]`.
399
+ *
400
+ * Examples:
401
+ * darwin + arm64 → @pyreon/compiler-darwin-arm64
402
+ * darwin + x64 → @pyreon/compiler-darwin-x64
403
+ * linux + x64 + gnu → @pyreon/compiler-linux-x64-gnu
404
+ * linux + arm64 + gnu → @pyreon/compiler-linux-arm64-gnu
405
+ * win32 + x64 + msvc → @pyreon/compiler-win32-x64-msvc
406
+ *
407
+ * Returns `null` for unsupported (platform, arch) combinations — caller
408
+ * skips per-platform resolution entirely and falls through to JS.
409
+ */
410
+ function getPlatformPackageName(platform = nodeProcess.platform, arch = nodeProcess.arch, libc = detectLibc(platform)) {
411
+ const suffix = libc ? `-${libc}` : "";
412
+ if (!{
413
+ darwin: ["arm64", "x64"],
414
+ linux: ["x64", "arm64"],
415
+ win32: ["x64"]
416
+ }[platform]?.includes(arch)) return null;
417
+ return `@pyreon/compiler-${platform}-${arch}${suffix}`;
418
+ }
419
+ /**
420
+ * Detect the libc family for the current Linux runtime. Returns:
421
+ * - `'gnu'` on glibc-based distros (Debian, Ubuntu, RHEL, …)
422
+ * - `'musl'` on musl-based distros (Alpine, …)
423
+ * - `null` on macOS / Windows (no libc differentiation)
424
+ * - `'msvc'` on Windows (we only ship MSVC binaries)
425
+ *
426
+ * `process.report.getReport().header.glibcVersionRuntime` is the
427
+ * Node-canonical detection: present on glibc, absent on musl. Falls
428
+ * back to `gnu` on read failure since glibc is the more common case.
429
+ */
430
+ function detectLibc(platform) {
431
+ if (platform === "win32") return "msvc";
432
+ if (platform !== "linux") return null;
433
+ try {
434
+ const report = nodeProcess.report?.getReport();
435
+ if (typeof report === "object" && report !== null) return report.header?.glibcVersionRuntime ? "gnu" : "musl";
436
+ } catch {}
437
+ return "gnu";
438
+ }
439
+ /**
440
+ * Load the native binding by trying paths in order:
441
+ * 1. In-tree binary (`<package>/native/pyreon-compiler.node`)
442
+ * 2. Per-platform npm package (`@pyreon/compiler-<triple>`)
443
+ *
444
+ * Returns `null` if both paths fail — caller falls back to the
445
+ * pure-JS implementation. NEVER throws — every error path swallows
446
+ * silently because a missing native binary is a perf optimization
447
+ * miss, not a correctness failure.
448
+ */
449
+ function loadNativeBinding(metaUrl) {
450
+ const nativeRequire = createRequire(metaUrl);
451
+ try {
452
+ return nativeRequire(join(dirname(fileURLToPath(metaUrl)), "..", "native", "pyreon-compiler.node"));
453
+ } catch {}
454
+ const pkgName = getPlatformPackageName();
455
+ if (pkgName !== null) try {
456
+ return nativeRequire(pkgName);
457
+ } catch {}
458
+ return null;
459
+ }
460
+
59
461
  //#endregion
60
462
  //#region src/jsx.ts
61
463
  /**
@@ -87,11 +489,8 @@ const REACT_EVENT_REMAP = Object.freeze({ doubleclick: "dblclick" });
87
489
  *
88
490
  * Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
89
491
  */
90
- let nativeTransformJsx = null;
91
- try {
92
- const __dirname = dirname(fileURLToPath(import.meta.url));
93
- nativeTransformJsx = createRequire(import.meta.url)(join(__dirname, "..", "native", "pyreon-compiler.node")).transformJsx;
94
- } catch {}
492
+ const nativeBinding = loadNativeBinding(import.meta.url);
493
+ const nativeTransformJsx = nativeBinding ? nativeBinding.transformJsx : null;
95
494
  const SKIP_PROPS = new Set(["key", "ref"]);
96
495
  const EVENT_RE = /^on[A-Z]/;
97
496
  const DELEGATED_EVENTS = new Set([
@@ -240,6 +639,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
240
639
  let hoistIdx = 0;
241
640
  let needsTplImport = false;
242
641
  let needsRpImport = false;
642
+ let needsWrapSpreadImport = false;
243
643
  let needsBindTextImportGlobal = false;
244
644
  let needsBindDirectImportGlobal = false;
245
645
  let needsBindImportGlobal = false;
@@ -299,6 +699,41 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
299
699
  if (jsxTagName(node) !== "For") return;
300
700
  if (!jsxAttrs(node).some((p) => p.type === "JSXAttribute" && p.name?.type === "JSXIdentifier" && p.name.name === "by")) warn(node.openingElement?.name ?? node, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
301
701
  }
702
+ /**
703
+ * Wrap component-JSX spread arguments with `_wrapSpread(...)` so
704
+ * getter-shaped reactive props survive esbuild's JS-level spread emit.
705
+ *
706
+ * esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
707
+ * The JS spread fires every getter on `source` and stores the resolved
708
+ * values — collapsing compiler-emitted reactive props (`_rp` thunks
709
+ * later converted to getters by `makeReactiveProps`) to static values
710
+ * before the receiving component sees them.
711
+ *
712
+ * `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
713
+ * so the JS-level spread carries function values instead. The runtime
714
+ * `makeReactiveProps` step converts them back to getters on the
715
+ * component's props object — preserving the live signal subscription.
716
+ *
717
+ * Lowercase tags (DOM elements) go through the template path's
718
+ * `_applyProps` which already handles spread reactively — no need to
719
+ * wrap there.
720
+ */
721
+ function handleJsxSpreadAttribute(attr, parentElement) {
722
+ const tagName = jsxTagName(parentElement);
723
+ if (!(tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase())) return;
724
+ const arg = attr.argument;
725
+ if (!arg) return;
726
+ if (arg.type === "CallExpression" && arg.callee?.type === "Identifier" && arg.callee.name === "_wrapSpread") return;
727
+ const start = arg.start;
728
+ const end = arg.end;
729
+ const sliced = sliceExpr(arg);
730
+ replacements.push({
731
+ start,
732
+ end,
733
+ text: `_wrapSpread(${sliced})`
734
+ });
735
+ needsWrapSpreadImport = true;
736
+ }
302
737
  function handleJsxAttribute(node, parentElement) {
303
738
  const name = node.name?.type === "JSXIdentifier" ? node.name.name : "";
304
739
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
@@ -584,6 +1019,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
584
1019
  if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
585
1020
  checkForWarnings(node);
586
1021
  for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
1022
+ else if (attr.type === "JSXSpreadAttribute") handleJsxSpreadAttribute(attr, node);
587
1023
  for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
588
1024
  else walkNode(child);
589
1025
  return;
@@ -605,16 +1041,16 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
605
1041
  warnings
606
1042
  };
607
1043
  replacements.sort((a, b) => a.start - b.start);
608
- const parts = [];
609
- let lastPos = 0;
1044
+ const outParts = [];
1045
+ let outPos = 0;
610
1046
  for (const r of replacements) {
611
- parts.push(code.slice(lastPos, r.start));
612
- parts.push(r.text);
613
- lastPos = r.end;
1047
+ outParts.push(code.slice(outPos, r.start));
1048
+ outParts.push(r.text);
1049
+ outPos = r.end;
614
1050
  }
615
- parts.push(code.slice(lastPos));
616
- let result = parts.join("");
617
- if (hoists.length > 0) result = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + result;
1051
+ outParts.push(code.slice(outPos));
1052
+ let output = outParts.join("");
1053
+ if (hoists.length > 0) output = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + output;
618
1054
  if (needsTplImport) {
619
1055
  const runtimeDomImports = ["_tpl"];
620
1056
  if (needsBindDirectImportGlobal) runtimeDomImports.push("_bindDirect");
@@ -622,11 +1058,16 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
622
1058
  if (needsApplyPropsImportGlobal) runtimeDomImports.push("_applyProps");
623
1059
  if (needsMountSlotImportGlobal) runtimeDomImports.push("_mountSlot");
624
1060
  const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
625
- result = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + result;
1061
+ output = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + output;
1062
+ }
1063
+ if (needsRpImport || needsWrapSpreadImport) {
1064
+ const coreImports = [];
1065
+ if (needsRpImport) coreImports.push("_rp");
1066
+ if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
1067
+ output = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + output;
626
1068
  }
627
- if (needsRpImport) result = `import { _rp } from "@pyreon/core";\n` + result;
628
1069
  return {
629
- code: result,
1070
+ code: output,
630
1071
  usesTemplates: needsTplImport,
631
1072
  warnings
632
1073
  };
@@ -2102,6 +2543,14 @@ function diagnoseError(error) {
2102
2543
  * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
2103
2544
  * cast on JSX returns is unnecessary (`JSX.Element`
2104
2545
  * is already assignable to `VNodeChild`).
2546
+ * - `island-never-with-registry-entry` — an `island()` declared with
2547
+ * `hydrate: 'never'` is also registered in the same
2548
+ * file's `hydrateIslands({ ... })` call. The whole
2549
+ * point of `'never'` is shipping zero client JS;
2550
+ * registering pulls the component module into the
2551
+ * client bundle graph (the runtime short-circuits
2552
+ * and never calls the loader, but the bundler still
2553
+ * includes the import). Drop the registry entry.
2105
2554
  *
2106
2555
  * Two-mode surface mirrors `react-intercept.ts`:
2107
2556
  * - `detectPyreonPatterns(code)` — diagnostics only
@@ -2372,6 +2821,69 @@ function detectAsUnknownAsVNodeChild(ctx, node) {
2372
2821
  if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
2373
2822
  pushDiag(ctx, node, "as-unknown-as-vnodechild", "`as unknown as VNodeChild` is unnecessary — `JSX.Element` (the type produced by JSX) is already assignable to `VNodeChild`. Remove the double cast; it is pure noise that hides genuine type issues if they ever appear at this site.", getNodeText(ctx, node), getNodeText(ctx, inner.expression), false);
2374
2823
  }
2824
+ /**
2825
+ * Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
2826
+ * call expressions and collect the `name` field of each never-strategy island.
2827
+ *
2828
+ * Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
2829
+ *
2830
+ * island(() => import('./X'), { name: 'X', hydrate: 'never' })
2831
+ *
2832
+ * Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
2833
+ * fall through and don't populate the set — false-negatives, not false
2834
+ * positives):
2835
+ *
2836
+ * - Loader is a variable, not an inline arrow
2837
+ * - Name is a variable / template / spread, not a string literal
2838
+ * - Options come from a spread (`island(loader, opts)`)
2839
+ *
2840
+ * The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
2841
+ * unrecognized keys won't match. Both halves are syntactic — a semantic
2842
+ * cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
2843
+ */
2844
+ function collectNeverIslandNames(sf) {
2845
+ const names = /* @__PURE__ */ new Set();
2846
+ function walk(node) {
2847
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
2848
+ const opts = node.arguments[1];
2849
+ if (opts && ts.isObjectLiteralExpression(opts)) {
2850
+ let nameVal;
2851
+ let hydrateVal;
2852
+ for (const prop of opts.properties) {
2853
+ if (!ts.isPropertyAssignment(prop)) continue;
2854
+ const key = prop.name;
2855
+ const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
2856
+ if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
2857
+ else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
2858
+ }
2859
+ if (nameVal && hydrateVal === "never") names.add(nameVal);
2860
+ }
2861
+ }
2862
+ ts.forEachChild(node, walk);
2863
+ }
2864
+ walk(sf);
2865
+ return names;
2866
+ }
2867
+ /**
2868
+ * Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
2869
+ * key matches an `island()` name declared with `hydrate: 'never'` in the
2870
+ * same file. Each matching entry produces one diagnostic at the property's
2871
+ * location so the IDE highlights exactly which key needs to go.
2872
+ */
2873
+ function detectIslandNeverWithRegistry(ctx, node) {
2874
+ if (ctx.neverIslandNames.size === 0) return;
2875
+ const callee = node.expression;
2876
+ if (!ts.isIdentifier(callee) || callee.text !== "hydrateIslands") return;
2877
+ const arg = node.arguments[0];
2878
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return;
2879
+ for (const prop of arg.properties) {
2880
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
2881
+ const key = prop.name;
2882
+ const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
2883
+ if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
2884
+ pushDiag(ctx, prop, "island-never-with-registry-entry", `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`, getNodeText(ctx, prop), `// remove the "${keyText}" entry — never-strategy islands need no registry entry`, false);
2885
+ }
2886
+ }
2375
2887
  function visitNode(ctx, node) {
2376
2888
  if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
2377
2889
  if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
@@ -2387,6 +2899,7 @@ function visitNode(ctx, node) {
2387
2899
  detectEmptyTheme(ctx, node);
2388
2900
  detectRawEventListener(ctx, node);
2389
2901
  detectSignalWriteAsCall(ctx, node);
2902
+ detectIslandNeverWithRegistry(ctx, node);
2390
2903
  }
2391
2904
  if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
2392
2905
  if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
@@ -2403,7 +2916,8 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
2403
2916
  sf,
2404
2917
  code,
2405
2918
  diagnostics: [],
2406
- signalBindings: collectSignalBindings(sf)
2919
+ signalBindings: collectSignalBindings(sf),
2920
+ neverIslandNames: collectNeverIslandNames(sf)
2407
2921
  };
2408
2922
  visit(ctx, sf);
2409
2923
  ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
@@ -2411,7 +2925,7 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
2411
2925
  }
2412
2926
  /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
2413
2927
  function hasPyreonPatterns(code) {
2414
- return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code);
2928
+ return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) || /\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code);
2415
2929
  }
2416
2930
 
2417
2931
  //#endregion
@@ -2445,7 +2959,7 @@ function hasPyreonPatterns(code) {
2445
2959
  * tell an agent what to write; this one tells an agent which existing
2446
2960
  * tests need strengthening.
2447
2961
  */
2448
- function findMonorepoRoot(startDir) {
2962
+ function findMonorepoRoot$2(startDir) {
2449
2963
  let dir = resolve(startDir);
2450
2964
  for (let i = 0; i < 30; i++) {
2451
2965
  try {
@@ -2611,7 +3125,7 @@ function classifyRisk(entry) {
2611
3125
  return "medium";
2612
3126
  }
2613
3127
  function auditTestEnvironment(startDir) {
2614
- const root = findMonorepoRoot(startDir);
3128
+ const root = findMonorepoRoot$2(startDir);
2615
3129
  if (!root) return {
2616
3130
  root: null,
2617
3131
  entries: [],
@@ -2726,5 +3240,784 @@ function describeRisk(risk) {
2726
3240
  }
2727
3241
 
2728
3242
  //#endregion
2729
- export { auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
3243
+ //#region src/island-audit.ts
3244
+ /**
3245
+ * Project-wide islands audit for the `audit_islands` MCP tool +
3246
+ * `pyreon doctor --check-islands` CLI flag (PR C of the islands DX
3247
+ * roadmap).
3248
+ *
3249
+ * Companion gates that pre-date this module:
3250
+ *
3251
+ * - PR G's `island-never-with-registry-entry` detector (in
3252
+ * `pyreon-intercept.ts`) catches the same shape per FILE — it only
3253
+ * fires when the `island()` declaration AND `hydrateIslands({...})`
3254
+ * call are in the same source.
3255
+ * - PR B's auto-registry (`@pyreon/vite-plugin` `islands: true`)
3256
+ * eliminates the manual sync entirely — the registry is generated
3257
+ * from `island()` declarations, so it can't drift.
3258
+ *
3259
+ * What this audit adds: cross-file analysis. Five findings:
3260
+ *
3261
+ * 1. `never-with-registry-entry` — project-wide cross-file version of
3262
+ * the per-file detector. Fires when ANY file's `island()` with
3263
+ * `hydrate: 'never'` matches a key in ANY file's `hydrateIslands`
3264
+ * call.
3265
+ * 2. `duplicate-name` — two `island()` declarations with the same
3266
+ * `name`. Runtime would only hydrate the first; the second fails
3267
+ * silently.
3268
+ * 3. `registry-mismatch` — a `hydrateIslands({ X })` entry with no
3269
+ * matching `island()` declaration anywhere in the project. Catches
3270
+ * the manual-form drift foot-gun (typo / removed island /
3271
+ * forgotten import).
3272
+ * 4. `nested-island` — an `island()` whose loader-imported file ALSO
3273
+ * contains an `island()` call. Statically reachable nesting; the
3274
+ * outer's `hydrateRoot` would replace the inner before its loader
3275
+ * runs.
3276
+ * 5. `dead-island` — an `island()` declared in a file that no other
3277
+ * file imports (statically OR dynamically). Heuristic catches the
3278
+ * common shape of "declared but never wired up." False negatives
3279
+ * possible (file imported but the island binding within it isn't
3280
+ * used) — that's the cost of staying syntactic + cheap.
3281
+ *
3282
+ * Architectural note. This is intentionally syntactic, not semantic.
3283
+ * The audit reads source files as text + AST and never resolves through
3284
+ * type-checking. False negatives are acceptable; false positives must
3285
+ * be rare. Every finding includes file paths + line/column + actionable
3286
+ * fix suggestion so the user can verify in seconds.
3287
+ */
3288
+ function findMonorepoRoot$1(startDir) {
3289
+ let dir = resolve(startDir);
3290
+ for (let i = 0; i < 30; i++) {
3291
+ try {
3292
+ if (statSync(join(dir, "packages")).isDirectory()) return dir;
3293
+ } catch {}
3294
+ const parent = dirname(dir);
3295
+ if (parent === dir) return null;
3296
+ dir = parent;
3297
+ }
3298
+ return null;
3299
+ }
3300
+ function walkSourceFiles(dir, out, depth = 0) {
3301
+ if (depth > 12) return;
3302
+ let entries;
3303
+ try {
3304
+ entries = readdirSync(dir);
3305
+ } catch {
3306
+ return;
3307
+ }
3308
+ for (const name of entries) {
3309
+ if (name.startsWith(".")) continue;
3310
+ if (name === "node_modules" || name === "lib" || name === "dist") continue;
3311
+ if (name === "__tests__" || name === "tests") continue;
3312
+ const full = join(dir, name);
3313
+ let isDir = false;
3314
+ try {
3315
+ isDir = statSync(full).isDirectory();
3316
+ } catch {
3317
+ continue;
3318
+ }
3319
+ if (isDir) {
3320
+ walkSourceFiles(full, out, depth + 1);
3321
+ continue;
3322
+ }
3323
+ if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) out.push(full);
3324
+ }
3325
+ }
3326
+ function lineColAt(sf, pos) {
3327
+ const lc = sf.getLineAndCharacterOfPosition(pos);
3328
+ return {
3329
+ line: lc.line + 1,
3330
+ column: lc.character + 1
3331
+ };
3332
+ }
3333
+ /** Strip surrounding quotes from a string literal as parsed by TS. */
3334
+ function stringLiteralValue(node) {
3335
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
3336
+ }
3337
+ /**
3338
+ * Extract `island()` declarations recognized in the file. Mirrors the
3339
+ * shape recognized by `@pyreon/vite-plugin`'s `scanIslandDeclarations`
3340
+ * and PR G's `collectNeverIslandNames` — only inline-arrow loaders +
3341
+ * string-literal options are captured. Other shapes fall through (false
3342
+ * negatives, by design).
3343
+ */
3344
+ function extractIslandDecls(sf, absPath, root) {
3345
+ const decls = [];
3346
+ const fileDir = dirname(absPath);
3347
+ const relPath = relative(root, absPath);
3348
+ function visit(node) {
3349
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
3350
+ const loaderArg = node.arguments[0];
3351
+ const optsArg = node.arguments[1];
3352
+ let importPath;
3353
+ if (loaderArg && ts.isArrowFunction(loaderArg)) {
3354
+ const body = loaderArg.body;
3355
+ const callTarget = ts.isCallExpression(body) ? body : void 0;
3356
+ if (callTarget && callTarget.expression.kind === ts.SyntaxKind.ImportKeyword) {
3357
+ const arg0 = callTarget.arguments[0];
3358
+ if (arg0) importPath = stringLiteralValue(arg0);
3359
+ }
3360
+ }
3361
+ if (optsArg && ts.isObjectLiteralExpression(optsArg)) {
3362
+ let nameVal;
3363
+ let hydrateVal;
3364
+ for (const prop of optsArg.properties) {
3365
+ if (!ts.isPropertyAssignment(prop)) continue;
3366
+ const keyText = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : "";
3367
+ if (keyText === "name") nameVal = stringLiteralValue(prop.initializer);
3368
+ else if (keyText === "hydrate") {
3369
+ const v = stringLiteralValue(prop.initializer);
3370
+ hydrateVal = v?.startsWith("interaction") ? "interaction" : v;
3371
+ }
3372
+ }
3373
+ if (nameVal) {
3374
+ const lc = lineColAt(sf, node.getStart(sf));
3375
+ decls.push({
3376
+ name: nameVal,
3377
+ hydrate: hydrateVal ?? "load",
3378
+ importPath,
3379
+ loc: {
3380
+ path: absPath,
3381
+ relPath,
3382
+ line: lc.line,
3383
+ column: lc.column
3384
+ },
3385
+ fileDir
3386
+ });
3387
+ }
3388
+ }
3389
+ }
3390
+ ts.forEachChild(node, visit);
3391
+ }
3392
+ visit(sf);
3393
+ return decls;
3394
+ }
3395
+ /**
3396
+ * Extract `hydrateIslands({...})` registry entries. Recognizes both
3397
+ * shorthand (`{ Counter }`) and property-assignment (`{ Counter: () =>
3398
+ * import('./Counter') }`) forms.
3399
+ */
3400
+ function extractRegistryEntries(sf, absPath, root) {
3401
+ const entries = [];
3402
+ const relPath = relative(root, absPath);
3403
+ function visit(node) {
3404
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "hydrateIslands" && node.arguments.length >= 1) {
3405
+ const arg = node.arguments[0];
3406
+ if (arg && ts.isObjectLiteralExpression(arg)) for (const prop of arg.properties) {
3407
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
3408
+ const keyNode = prop.name;
3409
+ const key = ts.isIdentifier(keyNode) ? keyNode.text : ts.isStringLiteral(keyNode) ? keyNode.text : "";
3410
+ if (!key) continue;
3411
+ const lc = lineColAt(sf, prop.getStart(sf));
3412
+ entries.push({
3413
+ key,
3414
+ loc: {
3415
+ path: absPath,
3416
+ relPath,
3417
+ line: lc.line,
3418
+ column: lc.column
3419
+ }
3420
+ });
3421
+ }
3422
+ }
3423
+ ts.forEachChild(node, visit);
3424
+ }
3425
+ visit(sf);
3426
+ return entries;
3427
+ }
3428
+ /**
3429
+ * Extract every import target (static `import` declarations + dynamic
3430
+ * `import()` expressions) and resolve relative paths to absolute paths
3431
+ * for the imports map. Bare specifiers (`@pyreon/server`) are kept as-is
3432
+ * — we only use this for the dead-island heuristic, which compares
3433
+ * against absolute file paths of declared islands, so bare specs simply
3434
+ * never match.
3435
+ */
3436
+ function extractImports(sf, absPath) {
3437
+ const out = /* @__PURE__ */ new Set();
3438
+ const fileDir = dirname(absPath);
3439
+ function record(spec) {
3440
+ if (spec.startsWith(".")) out.add(resolve(fileDir, spec));
3441
+ else out.add(spec);
3442
+ }
3443
+ function visit(node) {
3444
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) record(node.moduleSpecifier.text);
3445
+ else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
3446
+ const arg0 = node.arguments[0];
3447
+ const v = arg0 ? stringLiteralValue(arg0) : void 0;
3448
+ if (v) record(v);
3449
+ } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) record(node.moduleSpecifier.text);
3450
+ ts.forEachChild(node, visit);
3451
+ }
3452
+ visit(sf);
3453
+ return out;
3454
+ }
3455
+ function extractFromFile(absPath, root) {
3456
+ let code = "";
3457
+ try {
3458
+ code = readFileSync(absPath, "utf8");
3459
+ } catch {
3460
+ return {
3461
+ islands: [],
3462
+ registryEntries: [],
3463
+ imports: /* @__PURE__ */ new Set()
3464
+ };
3465
+ }
3466
+ const sf = ts.createSourceFile(absPath, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
3467
+ return {
3468
+ islands: extractIslandDecls(sf, absPath, root),
3469
+ registryEntries: extractRegistryEntries(sf, absPath, root),
3470
+ imports: extractImports(sf, absPath)
3471
+ };
3472
+ }
3473
+ const TS_EXTS = [
3474
+ ".ts",
3475
+ ".tsx",
3476
+ ".js",
3477
+ ".jsx"
3478
+ ];
3479
+ /**
3480
+ * Try common extensions + index files to land an absolute path on a
3481
+ * concrete file. Used by both helpers below.
3482
+ */
3483
+ function resolveAbsToFile(absBase) {
3484
+ try {
3485
+ if (statSync(absBase).isFile()) return absBase;
3486
+ } catch {}
3487
+ for (const ext of TS_EXTS) try {
3488
+ const candidate = `${absBase}${ext}`;
3489
+ if (statSync(candidate).isFile()) return candidate;
3490
+ } catch {}
3491
+ for (const ext of TS_EXTS) try {
3492
+ const candidate = join(absBase, `index${ext}`);
3493
+ if (statSync(candidate).isFile()) return candidate;
3494
+ } catch {}
3495
+ return null;
3496
+ }
3497
+ /**
3498
+ * Resolve `import './Counter'` (or similar) to an absolute file path.
3499
+ * Used by `nested-island` to follow an island's loader to its target.
3500
+ */
3501
+ function resolveImport(fromDir, spec) {
3502
+ if (!spec.startsWith(".")) return null;
3503
+ return resolveAbsToFile(resolve(fromDir, spec));
3504
+ }
3505
+ function detectDuplicateName(declsByFile, findings) {
3506
+ const byName = /* @__PURE__ */ new Map();
3507
+ for (const decls of declsByFile.values()) for (const d of decls) {
3508
+ const list = byName.get(d.name) ?? [];
3509
+ list.push(d);
3510
+ byName.set(d.name, list);
3511
+ }
3512
+ for (const [name, list] of byName) {
3513
+ if (list.length < 2) continue;
3514
+ for (let i = 0; i < list.length; i++) {
3515
+ const self = list[i];
3516
+ if (!self) continue;
3517
+ const others = list.filter((_, j) => j !== i).map((d) => d.loc);
3518
+ findings.push({
3519
+ code: "duplicate-name",
3520
+ message: `Two or more \`island()\` declarations share the name "${name}". The client-side hydration registry is keyed by name; only the FIRST loader fires — every other declaration fails silently with no error flag, and the user sees broken interactivity on the second component without any signal pointing at the cause. Rename one to make the names unique.`,
3521
+ location: self.loc,
3522
+ related: others
3523
+ });
3524
+ }
3525
+ }
3526
+ }
3527
+ function detectNeverWithRegistry(decls, registry, findings) {
3528
+ const neverByName = /* @__PURE__ */ new Map();
3529
+ for (const d of decls) if (d.hydrate === "never") neverByName.set(d.name, d);
3530
+ for (const entry of registry) {
3531
+ const decl = neverByName.get(entry.key);
3532
+ if (!decl) continue;
3533
+ findings.push({
3534
+ code: "never-with-registry-entry",
3535
+ message: `island "${entry.key}" was declared with \`hydrate: 'never'\` (at ${decl.loc.relPath}:${decl.loc.line}) but is registered in \`hydrateIslands({...})\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, so the loader never fires, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring. Auto-registry under \`@pyreon/vite-plugin\` (\`pyreon({ islands: true })\`) automatically omits never-strategy islands — switch to \`hydrateIslandsAuto(registry)\` to eliminate the manual sync entirely.`,
3536
+ location: entry.loc,
3537
+ related: [decl.loc]
3538
+ });
3539
+ }
3540
+ }
3541
+ function detectRegistryMismatch(decls, registry, findings) {
3542
+ const declaredNames = new Set(decls.map((d) => d.name));
3543
+ for (const entry of registry) {
3544
+ if (declaredNames.has(entry.key)) continue;
3545
+ findings.push({
3546
+ code: "registry-mismatch",
3547
+ message: `\`hydrateIslands({ ${entry.key}: ... })\` references "${entry.key}" but no \`island()\` in the project declares this name. Common causes: (1) typo (the registry key must EXACTLY match the \`name\` field on the \`island()\` declaration, including case), (2) the \`island()\` was renamed or deleted but the registry entry wasn't updated, (3) the file declaring the island isn't part of the scanned source tree (audit walks \`packages/\` and \`examples/\` by default). Switch to \`hydrateIslandsAuto(registry)\` from \`@pyreon/server/client\` (with \`@pyreon/vite-plugin\` \`islands: true\`) to eliminate manual-sync drift.`,
3548
+ location: entry.loc
3549
+ });
3550
+ }
3551
+ }
3552
+ function detectNestedIsland(decls, declsByFile, findings) {
3553
+ for (const outer of decls) {
3554
+ if (!outer.importPath) continue;
3555
+ const resolved = resolveImport(outer.fileDir, outer.importPath);
3556
+ if (!resolved) continue;
3557
+ const innerDecls = declsByFile.get(resolved);
3558
+ if (!innerDecls || innerDecls.length === 0) continue;
3559
+ for (const inner of innerDecls) findings.push({
3560
+ code: "nested-island",
3561
+ message: `island "${outer.name}" loads a file that ALSO contains an \`island()\` declaration ("${inner.name}" at ${inner.loc.relPath}:${inner.loc.line}). Nested islands are unsupported — the outer's \`hydrateRoot\` would replace the inner subtree before its loader runs, so the inner never hydrates. Refactor to flatten (move the inner island's content into the outer, OR remove the inner \`island()\` wrapper and let the outer render the component directly).`,
3562
+ location: outer.loc,
3563
+ related: [inner.loc]
3564
+ });
3565
+ }
3566
+ }
3567
+ function detectDeadIslands(decls, importedFiles, findings) {
3568
+ for (const d of decls) {
3569
+ if (importedFiles.has(d.loc.path)) continue;
3570
+ findings.push({
3571
+ code: "dead-island",
3572
+ message: `island "${d.name}" is declared in ${d.loc.relPath} but no other file in the project imports from this module (statically OR dynamically). The island's component will never reach a rendered tree — it's effectively unreachable code. Either (1) wire it up by importing + rendering the component from a route, or (2) remove the \`island()\` declaration. Note: the audit's heuristic flags files that no other source imports; if your island is registered via \`hydrateIslandsAuto()\`, the auto-registry's \`() => import('PATH')\` loader DOES count as an import, so a flagged island is genuinely orphaned.`,
3573
+ location: d.loc
3574
+ });
3575
+ }
3576
+ }
3577
+ function auditIslands(rootDir) {
3578
+ const root = findMonorepoRoot$1(rootDir);
3579
+ const findings = [];
3580
+ const summary = {
3581
+ filesScanned: 0,
3582
+ islandsDeclared: 0,
3583
+ registryEntries: 0,
3584
+ findingsByCode: {
3585
+ "never-with-registry-entry": 0,
3586
+ "duplicate-name": 0,
3587
+ "registry-mismatch": 0,
3588
+ "nested-island": 0,
3589
+ "dead-island": 0
3590
+ }
3591
+ };
3592
+ if (!root) return {
3593
+ root: null,
3594
+ findings,
3595
+ summary
3596
+ };
3597
+ const files = [];
3598
+ walkSourceFiles(join(root, "packages"), files);
3599
+ walkSourceFiles(join(root, "examples"), files);
3600
+ summary.filesScanned = files.length;
3601
+ const declsByFile = /* @__PURE__ */ new Map();
3602
+ const allDecls = [];
3603
+ const allRegistry = [];
3604
+ const resolvedImports = /* @__PURE__ */ new Set();
3605
+ for (const file of files) {
3606
+ const ex = extractFromFile(file, root);
3607
+ if (ex.islands.length > 0) {
3608
+ declsByFile.set(file, ex.islands);
3609
+ allDecls.push(...ex.islands);
3610
+ }
3611
+ allRegistry.push(...ex.registryEntries);
3612
+ for (const spec of ex.imports) {
3613
+ if (!spec.startsWith("/")) continue;
3614
+ const resolved = resolveAbsToFile(spec);
3615
+ if (resolved) resolvedImports.add(resolved);
3616
+ }
3617
+ }
3618
+ summary.islandsDeclared = allDecls.length;
3619
+ summary.registryEntries = allRegistry.length;
3620
+ detectDuplicateName(declsByFile, findings);
3621
+ detectNeverWithRegistry(allDecls, allRegistry, findings);
3622
+ detectRegistryMismatch(allDecls, allRegistry, findings);
3623
+ detectNestedIsland(allDecls, declsByFile, findings);
3624
+ detectDeadIslands(allDecls, resolvedImports, findings);
3625
+ for (const f of findings) summary.findingsByCode[f.code] = (summary.findingsByCode[f.code] ?? 0) + 1;
3626
+ findings.sort((a, b) => {
3627
+ const pathCmp = a.location.relPath.localeCompare(b.location.relPath);
3628
+ if (pathCmp !== 0) return pathCmp;
3629
+ return a.location.line - b.location.line || a.location.column - b.location.column;
3630
+ });
3631
+ return {
3632
+ root,
3633
+ findings,
3634
+ summary
3635
+ };
3636
+ }
3637
+ const CODE_HEADERS = {
3638
+ "never-with-registry-entry": "Never-strategy island in client registry — defeats zero-JS strategy",
3639
+ "duplicate-name": "Duplicate island names — only the first hydrates",
3640
+ "registry-mismatch": "Registry references unknown island — runtime warns + skips",
3641
+ "nested-island": "Nested island — outer hydrateRoot replaces inner before its loader runs",
3642
+ "dead-island": "Declared but unused island — no other file imports its module"
3643
+ };
3644
+ function formatIslandAudit(result, options = {}) {
3645
+ if (options.json) return JSON.stringify(result, null, 2);
3646
+ if (!result.root) return "No monorepo root found. The islands audit walks `packages/` and `examples/` starting from the cwd. Run `pyreon doctor --check-islands` from the Pyreon repo root.";
3647
+ const parts = [];
3648
+ parts.push(`# Islands audit — ${result.summary.filesScanned} files scanned, ${result.summary.islandsDeclared} \`island()\` declaration${result.summary.islandsDeclared === 1 ? "" : "s"}, ${result.summary.registryEntries} \`hydrateIslands\` registry entr${result.summary.registryEntries === 1 ? "y" : "ies"}`);
3649
+ parts.push("");
3650
+ if (result.findings.length === 0) {
3651
+ parts.push("✓ No island findings. Project-wide cross-file checks are clean:");
3652
+ parts.push(" - No duplicate names");
3653
+ parts.push(" - No `hydrate: \"never\"` islands in any client registry");
3654
+ parts.push(" - No registry entries pointing at undeclared names");
3655
+ parts.push(" - No nested islands");
3656
+ parts.push(" - No declared-but-unimported islands");
3657
+ return parts.join("\n");
3658
+ }
3659
+ parts.push(`Findings: ${result.findings.length} (` + Object.entries(result.summary.findingsByCode).filter(([, n]) => n > 0).map(([code, n]) => `${code}: ${n}`).join(", ") + ")");
3660
+ parts.push("");
3661
+ const byCode = /* @__PURE__ */ new Map();
3662
+ for (const f of result.findings) {
3663
+ const list = byCode.get(f.code) ?? [];
3664
+ list.push(f);
3665
+ byCode.set(f.code, list);
3666
+ }
3667
+ for (const [code, list] of byCode) {
3668
+ parts.push(`## ${code} — ${list.length} finding${list.length === 1 ? "" : "s"}`);
3669
+ parts.push("");
3670
+ parts.push(`> ${CODE_HEADERS[code]}`);
3671
+ parts.push("");
3672
+ for (const f of list) {
3673
+ parts.push(` ${f.location.relPath}:${f.location.line}:${f.location.column}`);
3674
+ parts.push(` ${f.message}`);
3675
+ if (f.related && f.related.length > 0) for (const r of f.related) parts.push(` related: ${r.relPath}:${r.line}:${r.column}`);
3676
+ parts.push("");
3677
+ }
3678
+ }
3679
+ return parts.join("\n");
3680
+ }
3681
+
3682
+ //#endregion
3683
+ //#region src/ssg-audit.ts
3684
+ /**
3685
+ * Project-wide SSG audit — scans route files for SSG / ISR foot-guns
3686
+ * surfaced by the SSG roadmap PRs (L5, A, I). Three detector codes ship
3687
+ * today:
3688
+ *
3689
+ * - **`404-outside-layout-dir`** (PR L5 carve-out): a `_404.tsx` (or
3690
+ * `_not-found.tsx`) file NOT co-located with a `_layout.tsx`. PR L5's
3691
+ * `findNotFoundFallback` filters to layout records with `children`;
3692
+ * a standalone `_404.tsx` outside a layout directory renders via the
3693
+ * SSG entry's pre-L5 standalone path (no layout chrome). The audit
3694
+ * catches this at the filesystem level so users move their
3695
+ * `_404.tsx` into the canonical `_layout` directory.
3696
+ *
3697
+ * - **`dynamic-route-missing-get-static-paths`** (PR A consequence): a
3698
+ * dynamic route file (`[id].tsx`, `[...slug].tsx`) that lacks a
3699
+ * `getStaticPaths` export. The SSG plugin silently SKIPS the route
3700
+ * during auto-detect — the user thinks `/posts/1` etc. are
3701
+ * prerendered but the dist has no `dist/posts/<id>/index.html`. The
3702
+ * audit catches this at scan time so users add the enumerator OR
3703
+ * declare the route as runtime-only.
3704
+ *
3705
+ * - **`non-literal-revalidate-export`** (PR I limitation): a route
3706
+ * file exports `export const revalidate = TTL` (variable reference)
3707
+ * or `export const revalidate = ...` (expression). The literal-
3708
+ * capture path in `extractLiteralExport` skips non-literals — the
3709
+ * manifest's revalidate entry is omitted, platform-driven ISR is
3710
+ * silently unconfigured for that route. The audit catches this so
3711
+ * users inline the literal (`export const revalidate = 60`).
3712
+ *
3713
+ * Real-app coverage:
3714
+ * - Per-code synthetic-fixture tests in `tests/ssg-audit.test.ts`
3715
+ * (one fixture per finding type, bisect-verified by reverting the
3716
+ * detector's match condition)
3717
+ * - Doctor wiring at `packages/tools/cli/src/doctor.ts:checkSsg`,
3718
+ * CLI flag `pyreon doctor --check-ssg [--json]`
3719
+ *
3720
+ * Same syntactic-only style as `island-audit.ts` — no type-check pass,
3721
+ * no module resolution. False negatives acceptable; false positives
3722
+ * must be rare. Every finding ships with file path + line/column +
3723
+ * actionable fix suggestion.
3724
+ */
3725
+ function findMonorepoRoot(startDir) {
3726
+ let dir = resolve(startDir);
3727
+ for (let i = 0; i < 30; i++) {
3728
+ try {
3729
+ if (statSync(join(dir, "packages")).isDirectory()) return dir;
3730
+ } catch {}
3731
+ const parent = dirname(dir);
3732
+ if (parent === dir) return null;
3733
+ dir = parent;
3734
+ }
3735
+ return null;
3736
+ }
3737
+ /**
3738
+ * Walk a directory looking for files under any `routes/` subdirectory.
3739
+ * fs-router treats files under `src/routes/` as routes; we mirror the
3740
+ * convention. Skips node_modules / lib / dist / test directories.
3741
+ */
3742
+ function findRouteFiles(rootDir, out, depth = 0) {
3743
+ if (depth > 12) return;
3744
+ let entries;
3745
+ try {
3746
+ entries = readdirSync(rootDir);
3747
+ } catch {
3748
+ return;
3749
+ }
3750
+ for (const name of entries) {
3751
+ if (name.startsWith(".")) continue;
3752
+ if (name === "node_modules" || name === "lib" || name === "dist") continue;
3753
+ if (name === "__tests__" || name === "tests") continue;
3754
+ const full = join(rootDir, name);
3755
+ let isDir = false;
3756
+ try {
3757
+ isDir = statSync(full).isDirectory();
3758
+ } catch {
3759
+ continue;
3760
+ }
3761
+ if (isDir) {
3762
+ if (name === "routes") walkRoutesDir(full, out);
3763
+ else findRouteFiles(full, out, depth + 1);
3764
+ continue;
3765
+ }
3766
+ }
3767
+ }
3768
+ function walkRoutesDir(dir, out) {
3769
+ let entries;
3770
+ try {
3771
+ entries = readdirSync(dir);
3772
+ } catch {
3773
+ return;
3774
+ }
3775
+ for (const name of entries) {
3776
+ if (name.startsWith(".")) continue;
3777
+ if (name === "node_modules") continue;
3778
+ const full = join(dir, name);
3779
+ let stat;
3780
+ try {
3781
+ stat = statSync(full);
3782
+ } catch {
3783
+ continue;
3784
+ }
3785
+ if (stat.isDirectory()) {
3786
+ walkRoutesDir(full, out);
3787
+ continue;
3788
+ }
3789
+ if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) out.push(full);
3790
+ }
3791
+ }
3792
+ function parseSourceFile(filePath) {
3793
+ let source;
3794
+ try {
3795
+ source = readFileSync(filePath, "utf8");
3796
+ } catch {
3797
+ return null;
3798
+ }
3799
+ return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
3800
+ }
3801
+ function locOf(source, node) {
3802
+ const pos = source.getLineAndCharacterOfPosition(node.getStart(source));
3803
+ return {
3804
+ line: pos.line + 1,
3805
+ column: pos.character + 1
3806
+ };
3807
+ }
3808
+ function makeLocation(absPath, source, node, rootForRel) {
3809
+ const { line, column } = locOf(source, node);
3810
+ return {
3811
+ path: absPath,
3812
+ relPath: relative(rootForRel, absPath),
3813
+ line,
3814
+ column
3815
+ };
3816
+ }
3817
+ /**
3818
+ * 1) `_404.tsx` / `_not-found.tsx` outside a `_layout.tsx` directory.
3819
+ *
3820
+ * fs-router scans `_404.tsx` / `_not-found.tsx` and attaches the default
3821
+ * export as `notFoundComponent` on its parent layout's RouteRecord. PR L5's
3822
+ * `findNotFoundFallback` filters to records with `Array.isArray(r.children)
3823
+ * && r.children.length > 0` — i.e. layouts only. A standalone `_404.tsx`
3824
+ * outside a layout directory:
3825
+ * - Becomes attached to a page record (no children)
3826
+ * - PR L5's walker skips it
3827
+ * - SSG entry falls back to the pre-L5 standalone render (no chrome)
3828
+ *
3829
+ * The audit catches this at filesystem-walk time, fast and structural.
3830
+ */
3831
+ function detect404OutsideLayoutDir(routeFiles, rootForRel) {
3832
+ const findings = [];
3833
+ const layoutDirs = /* @__PURE__ */ new Set();
3834
+ for (const file of routeFiles) {
3835
+ const base = file.split("/").pop() ?? "";
3836
+ if (/^_layout\.(tsx?|jsx?)$/.test(base)) layoutDirs.add(dirname(file));
3837
+ }
3838
+ for (const file of routeFiles) {
3839
+ const base = file.split("/").pop() ?? "";
3840
+ if (!/^_(404|not-found)\.(tsx?|jsx?)$/.test(base)) continue;
3841
+ const dir = dirname(file);
3842
+ if (layoutDirs.has(dir)) continue;
3843
+ findings.push({
3844
+ code: "404-outside-layout-dir",
3845
+ message: `${base} is not co-located with a _layout.tsx — without a parent layout, PR L5's findNotFoundFallback won't pick it up at SSG time and the 404 will render WITHOUT layout chrome (nav, footer, providers). Move ${base} into a directory that contains _layout.tsx (the canonical pattern: src/routes/_layout.tsx + src/routes/_404.tsx).`,
3846
+ location: {
3847
+ path: file,
3848
+ relPath: relative(rootForRel, file),
3849
+ line: 1,
3850
+ column: 1
3851
+ }
3852
+ });
3853
+ }
3854
+ return findings;
3855
+ }
3856
+ /**
3857
+ * 2) Dynamic route file missing `getStaticPaths` export.
3858
+ *
3859
+ * `[id].tsx`, `[...slug].tsx` — under SSG mode without a `getStaticPaths`,
3860
+ * the auto-detect step silently skips the route. User expects
3861
+ * `dist/posts/1/index.html` but never gets it.
3862
+ *
3863
+ * We syntactically scan for `export const getStaticPaths` or
3864
+ * `export function getStaticPaths`. Re-exports / async-function form
3865
+ * supported. Same literal-extraction shape used in fs-router's scanner.
3866
+ */
3867
+ function detectDynamicRouteMissingGetStaticPaths(routeFiles, rootForRel) {
3868
+ const findings = [];
3869
+ for (const file of routeFiles) {
3870
+ const base = file.split("/").pop() ?? "";
3871
+ if (!/\[.+\]/.test(base)) continue;
3872
+ if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue;
3873
+ if (/[/\\]routes[/\\]api[/\\]/.test(file)) continue;
3874
+ const source = parseSourceFile(file);
3875
+ if (!source) continue;
3876
+ let hasGetStaticPaths = false;
3877
+ let hasDefaultExport = false;
3878
+ function visit(node) {
3879
+ if (hasGetStaticPaths && hasDefaultExport) return;
3880
+ if (ts.isVariableStatement(node)) {
3881
+ if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
3882
+ for (const decl of node.declarationList.declarations) if (ts.isIdentifier(decl.name) && decl.name.text === "getStaticPaths") hasGetStaticPaths = true;
3883
+ }
3884
+ }
3885
+ if (ts.isFunctionDeclaration(node)) {
3886
+ const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
3887
+ const isDefault = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
3888
+ if (hasExport && node.name?.text === "getStaticPaths") hasGetStaticPaths = true;
3889
+ if (hasExport && isDefault) hasDefaultExport = true;
3890
+ }
3891
+ if (ts.isExportAssignment(node) && !node.isExportEquals) hasDefaultExport = true;
3892
+ ts.forEachChild(node, visit);
3893
+ }
3894
+ visit(source);
3895
+ if (!hasDefaultExport) continue;
3896
+ if (!hasGetStaticPaths) findings.push({
3897
+ code: "dynamic-route-missing-get-static-paths",
3898
+ message: `Dynamic route "${base}" has no \`getStaticPaths\` export — under \`mode: 'ssg'\` the auto-detect step SILENTLY SKIPS this route, so the dist won't contain prerendered HTML. Either add \`export const getStaticPaths = () => [{ params: { ... } }, ...]\` enumerating the concrete values, OR declare the route as runtime-only by switching to mode: 'ssr' / 'isr'.`,
3899
+ location: {
3900
+ path: file,
3901
+ relPath: relative(rootForRel, file),
3902
+ line: 1,
3903
+ column: 1
3904
+ }
3905
+ });
3906
+ }
3907
+ return findings;
3908
+ }
3909
+ /**
3910
+ * 3) `export const revalidate = X` where X is NOT a pure literal.
3911
+ *
3912
+ * PR I's `extractLiteralExport` skips re-export forms (`const x = 60;
3913
+ * export { x as revalidate }`) and non-literal expressions
3914
+ * (`export const revalidate = TTL` where TTL is a const elsewhere). The
3915
+ * manifest emission skips the entry silently — user thinks ISR is wired
3916
+ * but `_pyreon-revalidate.json` is missing the path. The audit catches
3917
+ * the syntactic shape and warns.
3918
+ *
3919
+ * Valid literals: NumericLiteral (`60`), FalseKeyword (`false`).
3920
+ * Anything else — Identifier reference, BinaryExpression, CallExpression,
3921
+ * TemplateLiteral — flagged.
3922
+ */
3923
+ function detectNonLiteralRevalidateExport(routeFiles, rootForRel) {
3924
+ const findings = [];
3925
+ for (const file of routeFiles) {
3926
+ const parsed = parseSourceFile(file);
3927
+ if (!parsed) continue;
3928
+ const source = parsed;
3929
+ function visit(node) {
3930
+ if (ts.isVariableStatement(node)) {
3931
+ if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
3932
+ ts.forEachChild(node, visit);
3933
+ return;
3934
+ }
3935
+ for (const decl of node.declarationList.declarations) {
3936
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== "revalidate") continue;
3937
+ const init = decl.initializer;
3938
+ if (!init) continue;
3939
+ if (ts.isNumericLiteral(init)) continue;
3940
+ if (init.kind === ts.SyntaxKind.FalseKeyword) continue;
3941
+ findings.push({
3942
+ code: "non-literal-revalidate-export",
3943
+ message: "`export const revalidate` must be a NUMERIC LITERAL (e.g. `60`, `3600`) or `false` — non-literal expressions (variable references, math, function calls, template literals) are silently dropped from the build-time ISR manifest (PR I's extractLiteralExport limitation). Inline the value: `export const revalidate = 60`.",
3944
+ location: makeLocation(file, source, init, rootForRel)
3945
+ });
3946
+ }
3947
+ }
3948
+ ts.forEachChild(node, visit);
3949
+ }
3950
+ visit(source);
3951
+ }
3952
+ return findings;
3953
+ }
3954
+ function auditSsg(rootDir) {
3955
+ const root = findMonorepoRoot(rootDir) ?? rootDir;
3956
+ const routeFiles = [];
3957
+ findRouteFiles(rootDir, routeFiles);
3958
+ let dynamicRoutes = 0;
3959
+ let revalidateExports = 0;
3960
+ for (const file of routeFiles) {
3961
+ const base = file.split("/").pop() ?? "";
3962
+ if (/\[.+\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) dynamicRoutes++;
3963
+ const source = parseSourceFile(file);
3964
+ if (!source) continue;
3965
+ function visit(node) {
3966
+ if (ts.isVariableStatement(node)) {
3967
+ if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
3968
+ for (const decl of node.declarationList.declarations) if (ts.isIdentifier(decl.name) && decl.name.text === "revalidate") revalidateExports++;
3969
+ }
3970
+ }
3971
+ ts.forEachChild(node, visit);
3972
+ }
3973
+ visit(source);
3974
+ }
3975
+ const findings = [
3976
+ ...detect404OutsideLayoutDir(routeFiles, root),
3977
+ ...detectDynamicRouteMissingGetStaticPaths(routeFiles, root),
3978
+ ...detectNonLiteralRevalidateExport(routeFiles, root)
3979
+ ];
3980
+ const findingsByCode = {
3981
+ "404-outside-layout-dir": 0,
3982
+ "dynamic-route-missing-get-static-paths": 0,
3983
+ "non-literal-revalidate-export": 0
3984
+ };
3985
+ for (const f of findings) findingsByCode[f.code]++;
3986
+ return {
3987
+ root,
3988
+ findings,
3989
+ summary: {
3990
+ filesScanned: routeFiles.length,
3991
+ routesScanned: routeFiles.length,
3992
+ dynamicRoutes,
3993
+ revalidateExports,
3994
+ findingsByCode
3995
+ }
3996
+ };
3997
+ }
3998
+ function formatSsgAudit(result, _options = {}) {
3999
+ const lines = [];
4000
+ lines.push("── SSG audit ─────────────────────────────────────────────────────");
4001
+ lines.push("");
4002
+ lines.push(`Scanned ${result.summary.routesScanned} route file(s), ${result.summary.dynamicRoutes} dynamic route(s), ${result.summary.revalidateExports} revalidate export(s).`);
4003
+ lines.push("");
4004
+ if (result.findings.length === 0) {
4005
+ lines.push("✓ No SSG / ISR issues found.");
4006
+ lines.push("");
4007
+ return lines.join("\n");
4008
+ }
4009
+ lines.push(`Found ${result.findings.length} issue(s):`);
4010
+ for (const f of result.findings) {
4011
+ lines.push("");
4012
+ lines.push(` [${f.code}] ${f.location.relPath}:${f.location.line}:${f.location.column}`);
4013
+ lines.push(` ${f.message}`);
4014
+ }
4015
+ lines.push("");
4016
+ lines.push("Run `pyreon doctor --check-ssg --json` for machine-readable output.");
4017
+ lines.push("");
4018
+ return lines.join("\n");
4019
+ }
4020
+
4021
+ //#endregion
4022
+ export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
2730
4023
  //# sourceMappingURL=index.js.map