@pyreon/compiler 0.14.0 → 0.16.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/README.md +17 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1189 -30
- package/lib/types/index.d.ts +109 -2
- package/package.json +20 -2
- package/src/event-names.ts +65 -0
- package/src/index.ts +17 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +162 -39
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +352 -2
- package/src/ssg-audit.ts +513 -0
- package/src/tests/detector-tag-consistency.test.ts +31 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +296 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/ssg-audit.test.ts +402 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/lib/index.js
CHANGED
|
@@ -7,6 +7,152 @@ 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/event-names.ts
|
|
11
|
+
/**
|
|
12
|
+
* React-style → DOM event-name remap.
|
|
13
|
+
*
|
|
14
|
+
* The compiler translates JSX event handler attributes (`onClick`,
|
|
15
|
+
* `onMouseEnter`, ...) to DOM event names by stripping the `on` prefix
|
|
16
|
+
* and lowercasing. That rule covers MOST React event-name conventions
|
|
17
|
+
* because the underlying DOM event name happens to be the lowercased
|
|
18
|
+
* multi-word form (e.g. `onKeyDown` → `keydown`, `onMouseEnter` →
|
|
19
|
+
* `mouseenter`, `onPointerLeave` → `pointerleave`,
|
|
20
|
+
* `onAnimationStart` → `animationstart`, `onContextMenu` → `contextmenu`).
|
|
21
|
+
*
|
|
22
|
+
* **The exceptions** — where lowercasing produces the WRONG DOM event
|
|
23
|
+
* name — are listed in `REACT_EVENT_REMAP` below. Each entry maps the
|
|
24
|
+
* lowercased React form to the actual DOM event name.
|
|
25
|
+
*
|
|
26
|
+
* Today there is exactly ONE remap: `doubleclick → dblclick`. React
|
|
27
|
+
* inherits this mismatch from the DOM spec — `dblclick` is the canonical
|
|
28
|
+
* event name (RFC at `https://dom.spec.whatwg.org/#interface-mouseevent`),
|
|
29
|
+
* while React's component-prop convention is the `onDoubleClick` shape.
|
|
30
|
+
*
|
|
31
|
+
* **Audit completeness.** The full React event-prop list from
|
|
32
|
+
* `https://react.dev/reference/react-dom/components/common` was checked
|
|
33
|
+
* against canonical DOM event names. Every multi-word event other than
|
|
34
|
+
* `onDoubleClick` lowercases correctly:
|
|
35
|
+
* - Pointer family: `onPointerDown` → `pointerdown`, `onGotPointerCapture` → `gotpointercapture`, …
|
|
36
|
+
* - Mouse family: `onMouseEnter` → `mouseenter`, `onMouseLeave` → `mouseleave`, …
|
|
37
|
+
* - Drag family: `onDragStart` → `dragstart`, `onDragEnd` → `dragend`, …
|
|
38
|
+
* - Touch family: `onTouchStart` → `touchstart`, `onTouchEnd` → `touchend`, …
|
|
39
|
+
* - Composition family: `onCompositionEnd` → `compositionend`, …
|
|
40
|
+
* - Animation/transition: `onAnimationStart` → `animationstart`, `onTransitionEnd` → `transitionend`, …
|
|
41
|
+
* - Media family: `onCanPlayThrough` → `canplaythrough`, `onLoadedData` → `loadeddata`, `onTimeUpdate` → `timeupdate`, `onVolumeChange` → `volumechange`, …
|
|
42
|
+
* - Form family: `onContextMenu` → `contextmenu`, `onBeforeInput` → `beforeinput`, …
|
|
43
|
+
*
|
|
44
|
+
* If a future React release adds a new event-prop with a non-trivial
|
|
45
|
+
* mismatch, append the entry here. Both compiler backends (JS and Rust)
|
|
46
|
+
* read the same shape — the Rust port lives in `native/src/lib.rs` next
|
|
47
|
+
* to `emit_event_listener`. Keep them in sync.
|
|
48
|
+
*
|
|
49
|
+
* **Testing.** `packages/core/compiler/src/tests/runtime/events.test.ts`
|
|
50
|
+
* exercises this table end-to-end via a real-Chromium harness:
|
|
51
|
+
* - `onDoubleClick fires (multi-word + delegated)` — locks in the remap.
|
|
52
|
+
* - `onContextMenu fires (multi-word, lowercases to contextmenu)` —
|
|
53
|
+
* locks in the no-remap default for an adjacent multi-word event.
|
|
54
|
+
* - `event-name-remap-table sanity` — asserts that every entry in
|
|
55
|
+
* `REACT_EVENT_REMAP` has a corresponding runtime test.
|
|
56
|
+
*/
|
|
57
|
+
const REACT_EVENT_REMAP = Object.freeze({ doubleclick: "dblclick" });
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/load-native.ts
|
|
61
|
+
/**
|
|
62
|
+
* Native binding loader — resolves the @pyreon/compiler napi-rs binary
|
|
63
|
+
* via two paths in priority order:
|
|
64
|
+
*
|
|
65
|
+
* 1. **In-tree binary** at `<package>/native/pyreon-compiler.node`.
|
|
66
|
+
* Populated by `scripts/build-native.ts` during local development
|
|
67
|
+
* (Phase 2). Faster path because it skips npm-package resolution.
|
|
68
|
+
*
|
|
69
|
+
* 2. **Per-platform npm package** (Phase 5b — not active until per-
|
|
70
|
+
* platform packages are published). Resolves `@pyreon/compiler-
|
|
71
|
+
* <platform>-<arch>[-<libc>]` via the standard Node module
|
|
72
|
+
* resolution algorithm. End users on machines without a local
|
|
73
|
+
* `cargo` install will hit this path: `bun install` resolves
|
|
74
|
+
* `optionalDependencies` to the matching per-platform package and
|
|
75
|
+
* this loader picks it up.
|
|
76
|
+
*
|
|
77
|
+
* 3. **JS fallback** (caller's responsibility) — if both paths fail,
|
|
78
|
+
* `loadNativeBinding()` returns `null` and the caller uses the
|
|
79
|
+
* pure-JS implementation. Slower but correctness-equivalent.
|
|
80
|
+
*
|
|
81
|
+
* Platform detection follows the napi-rs convention. Linux variants
|
|
82
|
+
* include a `libc` suffix (`gnu` for glibc, `musl` for musl) per
|
|
83
|
+
* https://napi.rs/docs/cli/build#deployment.
|
|
84
|
+
*
|
|
85
|
+
* The two-path resolution lets dev-mode (where `cargo build` produced
|
|
86
|
+
* an in-tree binary) and production-mode (where the user has only the
|
|
87
|
+
* published per-platform package) coexist with no flag flipping.
|
|
88
|
+
*/
|
|
89
|
+
const nodeProcess = process;
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the per-platform package name following the napi-rs naming
|
|
92
|
+
* convention: `@pyreon/compiler-<platform>-<arch>[-<libc>]`.
|
|
93
|
+
*
|
|
94
|
+
* Examples:
|
|
95
|
+
* darwin + arm64 → @pyreon/compiler-darwin-arm64
|
|
96
|
+
* darwin + x64 → @pyreon/compiler-darwin-x64
|
|
97
|
+
* linux + x64 + gnu → @pyreon/compiler-linux-x64-gnu
|
|
98
|
+
* linux + arm64 + gnu → @pyreon/compiler-linux-arm64-gnu
|
|
99
|
+
* win32 + x64 + msvc → @pyreon/compiler-win32-x64-msvc
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` for unsupported (platform, arch) combinations — caller
|
|
102
|
+
* skips per-platform resolution entirely and falls through to JS.
|
|
103
|
+
*/
|
|
104
|
+
function getPlatformPackageName(platform = nodeProcess.platform, arch = nodeProcess.arch, libc = detectLibc(platform)) {
|
|
105
|
+
const suffix = libc ? `-${libc}` : "";
|
|
106
|
+
if (!{
|
|
107
|
+
darwin: ["arm64", "x64"],
|
|
108
|
+
linux: ["x64", "arm64"],
|
|
109
|
+
win32: ["x64"]
|
|
110
|
+
}[platform]?.includes(arch)) return null;
|
|
111
|
+
return `@pyreon/compiler-${platform}-${arch}${suffix}`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Detect the libc family for the current Linux runtime. Returns:
|
|
115
|
+
* - `'gnu'` on glibc-based distros (Debian, Ubuntu, RHEL, …)
|
|
116
|
+
* - `'musl'` on musl-based distros (Alpine, …)
|
|
117
|
+
* - `null` on macOS / Windows (no libc differentiation)
|
|
118
|
+
* - `'msvc'` on Windows (we only ship MSVC binaries)
|
|
119
|
+
*
|
|
120
|
+
* `process.report.getReport().header.glibcVersionRuntime` is the
|
|
121
|
+
* Node-canonical detection: present on glibc, absent on musl. Falls
|
|
122
|
+
* back to `gnu` on read failure since glibc is the more common case.
|
|
123
|
+
*/
|
|
124
|
+
function detectLibc(platform) {
|
|
125
|
+
if (platform === "win32") return "msvc";
|
|
126
|
+
if (platform !== "linux") return null;
|
|
127
|
+
try {
|
|
128
|
+
const report = nodeProcess.report?.getReport();
|
|
129
|
+
if (typeof report === "object" && report !== null) return report.header?.glibcVersionRuntime ? "gnu" : "musl";
|
|
130
|
+
} catch {}
|
|
131
|
+
return "gnu";
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Load the native binding by trying paths in order:
|
|
135
|
+
* 1. In-tree binary (`<package>/native/pyreon-compiler.node`)
|
|
136
|
+
* 2. Per-platform npm package (`@pyreon/compiler-<triple>`)
|
|
137
|
+
*
|
|
138
|
+
* Returns `null` if both paths fail — caller falls back to the
|
|
139
|
+
* pure-JS implementation. NEVER throws — every error path swallows
|
|
140
|
+
* silently because a missing native binary is a perf optimization
|
|
141
|
+
* miss, not a correctness failure.
|
|
142
|
+
*/
|
|
143
|
+
function loadNativeBinding(metaUrl) {
|
|
144
|
+
const nativeRequire = createRequire(metaUrl);
|
|
145
|
+
try {
|
|
146
|
+
return nativeRequire(join(dirname(fileURLToPath(metaUrl)), "..", "native", "pyreon-compiler.node"));
|
|
147
|
+
} catch {}
|
|
148
|
+
const pkgName = getPlatformPackageName();
|
|
149
|
+
if (pkgName !== null) try {
|
|
150
|
+
return nativeRequire(pkgName);
|
|
151
|
+
} catch {}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
10
156
|
//#region src/jsx.ts
|
|
11
157
|
/**
|
|
12
158
|
* JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
|
|
@@ -37,11 +183,8 @@ import ts from "typescript";
|
|
|
37
183
|
*
|
|
38
184
|
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
39
185
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
nativeTransformJsx = createRequire(import.meta.url)(join(__dirname, "..", "native", "pyreon-compiler.node")).transformJsx;
|
|
44
|
-
} catch {}
|
|
186
|
+
const nativeBinding = loadNativeBinding(import.meta.url);
|
|
187
|
+
const nativeTransformJsx = nativeBinding ? nativeBinding.transformJsx : null;
|
|
45
188
|
const SKIP_PROPS = new Set(["key", "ref"]);
|
|
46
189
|
const EVENT_RE = /^on[A-Z]/;
|
|
47
190
|
const DELEGATED_EVENTS = new Set([
|
|
@@ -555,16 +698,16 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
555
698
|
warnings
|
|
556
699
|
};
|
|
557
700
|
replacements.sort((a, b) => a.start - b.start);
|
|
558
|
-
const
|
|
559
|
-
let
|
|
701
|
+
const outParts = [];
|
|
702
|
+
let outPos = 0;
|
|
560
703
|
for (const r of replacements) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
704
|
+
outParts.push(code.slice(outPos, r.start));
|
|
705
|
+
outParts.push(r.text);
|
|
706
|
+
outPos = r.end;
|
|
564
707
|
}
|
|
565
|
-
|
|
566
|
-
let
|
|
567
|
-
if (hoists.length > 0)
|
|
708
|
+
outParts.push(code.slice(outPos));
|
|
709
|
+
let output = outParts.join("");
|
|
710
|
+
if (hoists.length > 0) output = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + output;
|
|
568
711
|
if (needsTplImport) {
|
|
569
712
|
const runtimeDomImports = ["_tpl"];
|
|
570
713
|
if (needsBindDirectImportGlobal) runtimeDomImports.push("_bindDirect");
|
|
@@ -572,11 +715,11 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
572
715
|
if (needsApplyPropsImportGlobal) runtimeDomImports.push("_applyProps");
|
|
573
716
|
if (needsMountSlotImportGlobal) runtimeDomImports.push("_mountSlot");
|
|
574
717
|
const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
|
|
575
|
-
|
|
718
|
+
output = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + output;
|
|
576
719
|
}
|
|
577
|
-
if (needsRpImport)
|
|
720
|
+
if (needsRpImport) output = `import { _rp } from "@pyreon/core";\n` + output;
|
|
578
721
|
return {
|
|
579
|
-
code:
|
|
722
|
+
code: output,
|
|
580
723
|
usesTemplates: needsTplImport,
|
|
581
724
|
warnings
|
|
582
725
|
};
|
|
@@ -661,7 +804,8 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
661
804
|
else bindLines.push(`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`);
|
|
662
805
|
}
|
|
663
806
|
function emitEventListener(attr, attrName, varName) {
|
|
664
|
-
const
|
|
807
|
+
const lowered = attrName.slice(2).toLowerCase();
|
|
808
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered;
|
|
665
809
|
if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
|
|
666
810
|
const expr = attr.value.expression;
|
|
667
811
|
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
@@ -702,6 +846,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
702
846
|
function attrSetter(htmlAttrName, varName, expr) {
|
|
703
847
|
if (htmlAttrName === "class") return `${varName}.className = ${expr}`;
|
|
704
848
|
if (htmlAttrName === "style") return `${varName}.style.cssText = ${expr}`;
|
|
849
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`;
|
|
705
850
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`;
|
|
706
851
|
}
|
|
707
852
|
function emitDynamicAttr(_expr, exprNode, htmlAttrName, varName) {
|
|
@@ -714,7 +859,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
714
859
|
if (directRef) {
|
|
715
860
|
needsBindDirectImport = true;
|
|
716
861
|
const d = nextDisp();
|
|
717
|
-
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
862
|
+
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : DOM_PROPS.has(htmlAttrName) ? `(v) => { ${varName}.${htmlAttrName} = v }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
718
863
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`);
|
|
719
864
|
return;
|
|
720
865
|
}
|
|
@@ -798,10 +943,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
798
943
|
}
|
|
799
944
|
function classifyJsxChild(child, out, elemIdxRef, recurse) {
|
|
800
945
|
if (child.type === "JSXText") {
|
|
801
|
-
const
|
|
802
|
-
if (
|
|
946
|
+
const cleaned = cleanJsxText(child.value ?? child.raw ?? "");
|
|
947
|
+
if (cleaned) out.push({
|
|
803
948
|
kind: "text",
|
|
804
|
-
text:
|
|
949
|
+
text: cleaned
|
|
805
950
|
});
|
|
806
951
|
return;
|
|
807
952
|
}
|
|
@@ -834,10 +979,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
834
979
|
}
|
|
835
980
|
function analyzeChildren(flatChildren) {
|
|
836
981
|
const hasElem = flatChildren.some((c) => c.kind === "element");
|
|
837
|
-
const
|
|
982
|
+
const hasText = flatChildren.some((c) => c.kind === "text");
|
|
838
983
|
const exprCount = flatChildren.filter((c) => c.kind === "expression").length;
|
|
839
984
|
return {
|
|
840
|
-
useMixed: hasElem
|
|
985
|
+
useMixed: (hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0) > 1,
|
|
841
986
|
useMultiExpr: exprCount > 1
|
|
842
987
|
};
|
|
843
988
|
}
|
|
@@ -914,7 +1059,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
914
1059
|
bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`);
|
|
915
1060
|
}
|
|
916
1061
|
if (bindLines.length === 0 && disposerNames.length === 0) return `_tpl("${escaped}", () => null)`;
|
|
917
|
-
let body = bindLines.map((l) => ` ${l}
|
|
1062
|
+
let body = bindLines.map((l) => ` ${l};`).join("\n");
|
|
918
1063
|
if (disposerNames.length > 0) body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join("; ")} }`;
|
|
919
1064
|
else body += "\n return null";
|
|
920
1065
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`;
|
|
@@ -934,6 +1079,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
934
1079
|
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
935
1080
|
const parent = findParent(node);
|
|
936
1081
|
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
|
|
1082
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
1083
|
+
const grand = findParent(parent);
|
|
1084
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return false;
|
|
1085
|
+
}
|
|
937
1086
|
if (parent && parent.type === "CallExpression" && parent.callee === node) return false;
|
|
938
1087
|
return true;
|
|
939
1088
|
}
|
|
@@ -955,6 +1104,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
955
1104
|
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
956
1105
|
const parent = findParent(node);
|
|
957
1106
|
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
1107
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
1108
|
+
const grand = findParent(parent);
|
|
1109
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return;
|
|
1110
|
+
}
|
|
958
1111
|
if (parent && parent.type === "CallExpression" && parent.callee === node) return;
|
|
959
1112
|
if (parent && parent.type === "VariableDeclarator" && parent.id === node) return;
|
|
960
1113
|
if (parent && (parent.type === "Property" || parent.type === "ObjectProperty")) {
|
|
@@ -1002,6 +1155,15 @@ const JSX_TO_HTML_ATTR = {
|
|
|
1002
1155
|
className: "class",
|
|
1003
1156
|
htmlFor: "for"
|
|
1004
1157
|
};
|
|
1158
|
+
const DOM_PROPS = new Set([
|
|
1159
|
+
"value",
|
|
1160
|
+
"checked",
|
|
1161
|
+
"selected",
|
|
1162
|
+
"disabled",
|
|
1163
|
+
"multiple",
|
|
1164
|
+
"readOnly",
|
|
1165
|
+
"indeterminate"
|
|
1166
|
+
]);
|
|
1005
1167
|
const STATEFUL_CALLS = new Set([
|
|
1006
1168
|
"signal",
|
|
1007
1169
|
"computed",
|
|
@@ -1054,6 +1216,23 @@ function escapeHtmlAttr(s) {
|
|
|
1054
1216
|
function escapeHtmlText(s) {
|
|
1055
1217
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, "&").replace(/</g, "<");
|
|
1056
1218
|
}
|
|
1219
|
+
function cleanJsxText(raw) {
|
|
1220
|
+
if (!raw.includes("\n") && !raw.includes("\r")) return raw;
|
|
1221
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
1222
|
+
let lastNonEmpty = -1;
|
|
1223
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] ?? "")) lastNonEmpty = i;
|
|
1224
|
+
let str = "";
|
|
1225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1226
|
+
let line = (lines[i] ?? "").replace(/\t/g, " ");
|
|
1227
|
+
if (i !== 0) line = line.replace(/^ +/, "");
|
|
1228
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, "");
|
|
1229
|
+
if (line) {
|
|
1230
|
+
if (i !== lastNonEmpty) line += " ";
|
|
1231
|
+
str += line;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return str;
|
|
1235
|
+
}
|
|
1057
1236
|
function isStaticJSXNode(node) {
|
|
1058
1237
|
if (node.type === "JSXElement" && node.openingElement?.selfClosing) return isStaticAttrs(node.openingElement.attributes ?? []);
|
|
1059
1238
|
if (node.type === "JSXFragment") return (node.children ?? []).every(isStaticChild);
|
|
@@ -2004,6 +2183,26 @@ function diagnoseError(error) {
|
|
|
2004
2183
|
* monotonic counter.
|
|
2005
2184
|
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2006
2185
|
* used to crash on this pattern. Omit the prop.
|
|
2186
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2187
|
+
* its argument; the runtime warns in dev. Static
|
|
2188
|
+
* detector spots it pre-runtime when `sig` was
|
|
2189
|
+
* declared as `const sig = signal(...)` /
|
|
2190
|
+
* `computed(...)` and called with ≥1 argument.
|
|
2191
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2192
|
+
* top of a component body runs ONCE; signal changes
|
|
2193
|
+
* in `cond` never re-evaluate the early-return.
|
|
2194
|
+
* Wrap in a returned reactive accessor.
|
|
2195
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2196
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2197
|
+
* is already assignable to `VNodeChild`).
|
|
2198
|
+
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
2199
|
+
* `hydrate: 'never'` is also registered in the same
|
|
2200
|
+
* file's `hydrateIslands({ ... })` call. The whole
|
|
2201
|
+
* point of `'never'` is shipping zero client JS;
|
|
2202
|
+
* registering pulls the component module into the
|
|
2203
|
+
* client bundle graph (the runtime short-circuits
|
|
2204
|
+
* and never calls the loader, but the bundler still
|
|
2205
|
+
* includes the import). Drop the registry entry.
|
|
2007
2206
|
*
|
|
2008
2207
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2009
2208
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -2164,9 +2363,185 @@ function detectOnClickUndefined(ctx, node) {
|
|
|
2164
2363
|
if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
|
|
2165
2364
|
pushDiag(ctx, node, "on-click-undefined", `\`${attrName}={undefined}\` explicitly passes undefined as a listener. Pyreon's runtime guards against this, but the cleanest pattern is to omit the attribute entirely or use a conditional: \`${attrName}={condition ? handler : undefined}\`.`, getNodeText(ctx, node), `/* omit ${attrName} when the handler is not defined */`, false);
|
|
2166
2365
|
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
2368
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
2369
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
2370
|
+
* reliable signal-write.
|
|
2371
|
+
*
|
|
2372
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
2373
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
2374
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
2375
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2376
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2377
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
2378
|
+
*/
|
|
2379
|
+
function collectSignalBindings(sf) {
|
|
2380
|
+
const names = /* @__PURE__ */ new Set();
|
|
2381
|
+
function isSignalFactoryCall(init) {
|
|
2382
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2383
|
+
const callee = init.expression;
|
|
2384
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2385
|
+
return callee.text === "signal" || callee.text === "computed";
|
|
2386
|
+
}
|
|
2387
|
+
function walk(node) {
|
|
2388
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2389
|
+
const list = node.parent;
|
|
2390
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2391
|
+
}
|
|
2392
|
+
ts.forEachChild(node, walk);
|
|
2393
|
+
}
|
|
2394
|
+
walk(sf);
|
|
2395
|
+
return names;
|
|
2396
|
+
}
|
|
2397
|
+
function detectSignalWriteAsCall(ctx, node) {
|
|
2398
|
+
if (ctx.signalBindings.size === 0) return;
|
|
2399
|
+
const callee = node.expression;
|
|
2400
|
+
if (!ts.isIdentifier(callee)) return;
|
|
2401
|
+
if (!ctx.signalBindings.has(callee.text)) return;
|
|
2402
|
+
if (node.arguments.length === 0) return;
|
|
2403
|
+
pushDiag(ctx, node, "signal-write-as-call", `\`${callee.text}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.text}.set(value)\` to assign or \`${callee.text}.update((prev) => …)\` to derive from the previous value. Pyreon's runtime warns about this pattern in dev, but the warning fires AFTER the silent no-op.`, getNodeText(ctx, node), `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(", ")})`, false);
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
2407
|
+
* components mount and never re-execute their function bodies. A signal
|
|
2408
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2409
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
2410
|
+
*
|
|
2411
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2412
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
2413
|
+
*
|
|
2414
|
+
* Detection:
|
|
2415
|
+
* - The function contains JSX (i.e. it's a component)
|
|
2416
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2417
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
2418
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2419
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2420
|
+
* false positive)
|
|
2421
|
+
*/
|
|
2422
|
+
function returnsNullStatement(stmt) {
|
|
2423
|
+
if (ts.isReturnStatement(stmt)) {
|
|
2424
|
+
const expr = stmt.expression;
|
|
2425
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
2426
|
+
}
|
|
2427
|
+
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2428
|
+
return false;
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Returns true if the function looks like a top-level component:
|
|
2432
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2433
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2434
|
+
*
|
|
2435
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2436
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2437
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
2438
|
+
* this filter the detector would fire on the very pattern the
|
|
2439
|
+
* diagnostic recommends as the fix.
|
|
2440
|
+
*/
|
|
2441
|
+
function isComponentShapedFunction(node) {
|
|
2442
|
+
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2443
|
+
const parent = node.parent;
|
|
2444
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2445
|
+
return false;
|
|
2446
|
+
}
|
|
2447
|
+
function detectStaticReturnNullConditional(ctx, node) {
|
|
2448
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2449
|
+
if (!containsJsx(node)) return;
|
|
2450
|
+
const body = node.body;
|
|
2451
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2452
|
+
for (const stmt of body.statements) {
|
|
2453
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
2454
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue;
|
|
2455
|
+
pushDiag(ctx, stmt, "static-return-null-conditional", "Pyreon components run ONCE — `if (cond) return null` at the top of a component body is evaluated exactly once at mount. Reading a signal inside `cond` will NOT re-trigger the early return when the signal changes; the component is stuck on whichever branch the first run picked. Wrap the conditional in a returned reactive accessor: `return (() => { if (!cond()) return null; return <div /> })` — the accessor re-runs whenever its tracked signals change.", getNodeText(ctx, stmt), "return (() => { if (!cond()) return null; return <JSX /> })", false);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
2461
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
2462
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
2463
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
2464
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
2465
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
2466
|
+
*/
|
|
2467
|
+
function detectAsUnknownAsVNodeChild(ctx, node) {
|
|
2468
|
+
const outerType = node.type;
|
|
2469
|
+
if (!ts.isTypeReferenceNode(outerType)) return;
|
|
2470
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
|
|
2471
|
+
const inner = node.expression;
|
|
2472
|
+
if (!ts.isAsExpression(inner)) return;
|
|
2473
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
|
|
2474
|
+
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);
|
|
2475
|
+
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
|
|
2478
|
+
* call expressions and collect the `name` field of each never-strategy island.
|
|
2479
|
+
*
|
|
2480
|
+
* Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
|
|
2481
|
+
*
|
|
2482
|
+
* island(() => import('./X'), { name: 'X', hydrate: 'never' })
|
|
2483
|
+
*
|
|
2484
|
+
* Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
|
|
2485
|
+
* fall through and don't populate the set — false-negatives, not false
|
|
2486
|
+
* positives):
|
|
2487
|
+
*
|
|
2488
|
+
* - Loader is a variable, not an inline arrow
|
|
2489
|
+
* - Name is a variable / template / spread, not a string literal
|
|
2490
|
+
* - Options come from a spread (`island(loader, opts)`)
|
|
2491
|
+
*
|
|
2492
|
+
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
2493
|
+
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
2494
|
+
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
2495
|
+
*/
|
|
2496
|
+
function collectNeverIslandNames(sf) {
|
|
2497
|
+
const names = /* @__PURE__ */ new Set();
|
|
2498
|
+
function walk(node) {
|
|
2499
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
2500
|
+
const opts = node.arguments[1];
|
|
2501
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
2502
|
+
let nameVal;
|
|
2503
|
+
let hydrateVal;
|
|
2504
|
+
for (const prop of opts.properties) {
|
|
2505
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
2506
|
+
const key = prop.name;
|
|
2507
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2508
|
+
if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
|
|
2509
|
+
else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
|
|
2510
|
+
}
|
|
2511
|
+
if (nameVal && hydrateVal === "never") names.add(nameVal);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
ts.forEachChild(node, walk);
|
|
2515
|
+
}
|
|
2516
|
+
walk(sf);
|
|
2517
|
+
return names;
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
|
|
2521
|
+
* key matches an `island()` name declared with `hydrate: 'never'` in the
|
|
2522
|
+
* same file. Each matching entry produces one diagnostic at the property's
|
|
2523
|
+
* location so the IDE highlights exactly which key needs to go.
|
|
2524
|
+
*/
|
|
2525
|
+
function detectIslandNeverWithRegistry(ctx, node) {
|
|
2526
|
+
if (ctx.neverIslandNames.size === 0) return;
|
|
2527
|
+
const callee = node.expression;
|
|
2528
|
+
if (!ts.isIdentifier(callee) || callee.text !== "hydrateIslands") return;
|
|
2529
|
+
const arg = node.arguments[0];
|
|
2530
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2531
|
+
for (const prop of arg.properties) {
|
|
2532
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
2533
|
+
const key = prop.name;
|
|
2534
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2535
|
+
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
|
|
2536
|
+
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);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2167
2539
|
function visitNode(ctx, node) {
|
|
2168
2540
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
|
|
2169
|
-
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node))
|
|
2541
|
+
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
|
|
2542
|
+
detectPropsDestructured(ctx, node);
|
|
2543
|
+
detectStaticReturnNullConditional(ctx, node);
|
|
2544
|
+
}
|
|
2170
2545
|
if (ts.isBinaryExpression(node)) {
|
|
2171
2546
|
detectProcessDevGate(ctx, node);
|
|
2172
2547
|
detectDateMathRandomId(ctx, node);
|
|
@@ -2175,8 +2550,11 @@ function visitNode(ctx, node) {
|
|
|
2175
2550
|
if (ts.isCallExpression(node)) {
|
|
2176
2551
|
detectEmptyTheme(ctx, node);
|
|
2177
2552
|
detectRawEventListener(ctx, node);
|
|
2553
|
+
detectSignalWriteAsCall(ctx, node);
|
|
2554
|
+
detectIslandNeverWithRegistry(ctx, node);
|
|
2178
2555
|
}
|
|
2179
2556
|
if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
|
|
2557
|
+
if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
|
|
2180
2558
|
}
|
|
2181
2559
|
function visit(ctx, node) {
|
|
2182
2560
|
ts.forEachChild(node, (child) => {
|
|
@@ -2189,7 +2567,9 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
|
2189
2567
|
const ctx = {
|
|
2190
2568
|
sf,
|
|
2191
2569
|
code,
|
|
2192
|
-
diagnostics: []
|
|
2570
|
+
diagnostics: [],
|
|
2571
|
+
signalBindings: collectSignalBindings(sf),
|
|
2572
|
+
neverIslandNames: collectNeverIslandNames(sf)
|
|
2193
2573
|
};
|
|
2194
2574
|
visit(ctx, sf);
|
|
2195
2575
|
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
@@ -2197,7 +2577,7 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
|
2197
2577
|
}
|
|
2198
2578
|
/** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
|
|
2199
2579
|
function hasPyreonPatterns(code) {
|
|
2200
|
-
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);
|
|
2580
|
+
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);
|
|
2201
2581
|
}
|
|
2202
2582
|
|
|
2203
2583
|
//#endregion
|
|
@@ -2231,7 +2611,7 @@ function hasPyreonPatterns(code) {
|
|
|
2231
2611
|
* tell an agent what to write; this one tells an agent which existing
|
|
2232
2612
|
* tests need strengthening.
|
|
2233
2613
|
*/
|
|
2234
|
-
function findMonorepoRoot(startDir) {
|
|
2614
|
+
function findMonorepoRoot$2(startDir) {
|
|
2235
2615
|
let dir = resolve(startDir);
|
|
2236
2616
|
for (let i = 0; i < 30; i++) {
|
|
2237
2617
|
try {
|
|
@@ -2397,7 +2777,7 @@ function classifyRisk(entry) {
|
|
|
2397
2777
|
return "medium";
|
|
2398
2778
|
}
|
|
2399
2779
|
function auditTestEnvironment(startDir) {
|
|
2400
|
-
const root = findMonorepoRoot(startDir);
|
|
2780
|
+
const root = findMonorepoRoot$2(startDir);
|
|
2401
2781
|
if (!root) return {
|
|
2402
2782
|
root: null,
|
|
2403
2783
|
entries: [],
|
|
@@ -2512,5 +2892,784 @@ function describeRisk(risk) {
|
|
|
2512
2892
|
}
|
|
2513
2893
|
|
|
2514
2894
|
//#endregion
|
|
2515
|
-
|
|
2895
|
+
//#region src/island-audit.ts
|
|
2896
|
+
/**
|
|
2897
|
+
* Project-wide islands audit for the `audit_islands` MCP tool +
|
|
2898
|
+
* `pyreon doctor --check-islands` CLI flag (PR C of the islands DX
|
|
2899
|
+
* roadmap).
|
|
2900
|
+
*
|
|
2901
|
+
* Companion gates that pre-date this module:
|
|
2902
|
+
*
|
|
2903
|
+
* - PR G's `island-never-with-registry-entry` detector (in
|
|
2904
|
+
* `pyreon-intercept.ts`) catches the same shape per FILE — it only
|
|
2905
|
+
* fires when the `island()` declaration AND `hydrateIslands({...})`
|
|
2906
|
+
* call are in the same source.
|
|
2907
|
+
* - PR B's auto-registry (`@pyreon/vite-plugin` `islands: true`)
|
|
2908
|
+
* eliminates the manual sync entirely — the registry is generated
|
|
2909
|
+
* from `island()` declarations, so it can't drift.
|
|
2910
|
+
*
|
|
2911
|
+
* What this audit adds: cross-file analysis. Five findings:
|
|
2912
|
+
*
|
|
2913
|
+
* 1. `never-with-registry-entry` — project-wide cross-file version of
|
|
2914
|
+
* the per-file detector. Fires when ANY file's `island()` with
|
|
2915
|
+
* `hydrate: 'never'` matches a key in ANY file's `hydrateIslands`
|
|
2916
|
+
* call.
|
|
2917
|
+
* 2. `duplicate-name` — two `island()` declarations with the same
|
|
2918
|
+
* `name`. Runtime would only hydrate the first; the second fails
|
|
2919
|
+
* silently.
|
|
2920
|
+
* 3. `registry-mismatch` — a `hydrateIslands({ X })` entry with no
|
|
2921
|
+
* matching `island()` declaration anywhere in the project. Catches
|
|
2922
|
+
* the manual-form drift foot-gun (typo / removed island /
|
|
2923
|
+
* forgotten import).
|
|
2924
|
+
* 4. `nested-island` — an `island()` whose loader-imported file ALSO
|
|
2925
|
+
* contains an `island()` call. Statically reachable nesting; the
|
|
2926
|
+
* outer's `hydrateRoot` would replace the inner before its loader
|
|
2927
|
+
* runs.
|
|
2928
|
+
* 5. `dead-island` — an `island()` declared in a file that no other
|
|
2929
|
+
* file imports (statically OR dynamically). Heuristic catches the
|
|
2930
|
+
* common shape of "declared but never wired up." False negatives
|
|
2931
|
+
* possible (file imported but the island binding within it isn't
|
|
2932
|
+
* used) — that's the cost of staying syntactic + cheap.
|
|
2933
|
+
*
|
|
2934
|
+
* Architectural note. This is intentionally syntactic, not semantic.
|
|
2935
|
+
* The audit reads source files as text + AST and never resolves through
|
|
2936
|
+
* type-checking. False negatives are acceptable; false positives must
|
|
2937
|
+
* be rare. Every finding includes file paths + line/column + actionable
|
|
2938
|
+
* fix suggestion so the user can verify in seconds.
|
|
2939
|
+
*/
|
|
2940
|
+
function findMonorepoRoot$1(startDir) {
|
|
2941
|
+
let dir = resolve(startDir);
|
|
2942
|
+
for (let i = 0; i < 30; i++) {
|
|
2943
|
+
try {
|
|
2944
|
+
if (statSync(join(dir, "packages")).isDirectory()) return dir;
|
|
2945
|
+
} catch {}
|
|
2946
|
+
const parent = dirname(dir);
|
|
2947
|
+
if (parent === dir) return null;
|
|
2948
|
+
dir = parent;
|
|
2949
|
+
}
|
|
2950
|
+
return null;
|
|
2951
|
+
}
|
|
2952
|
+
function walkSourceFiles(dir, out, depth = 0) {
|
|
2953
|
+
if (depth > 12) return;
|
|
2954
|
+
let entries;
|
|
2955
|
+
try {
|
|
2956
|
+
entries = readdirSync(dir);
|
|
2957
|
+
} catch {
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
for (const name of entries) {
|
|
2961
|
+
if (name.startsWith(".")) continue;
|
|
2962
|
+
if (name === "node_modules" || name === "lib" || name === "dist") continue;
|
|
2963
|
+
if (name === "__tests__" || name === "tests") continue;
|
|
2964
|
+
const full = join(dir, name);
|
|
2965
|
+
let isDir = false;
|
|
2966
|
+
try {
|
|
2967
|
+
isDir = statSync(full).isDirectory();
|
|
2968
|
+
} catch {
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
if (isDir) {
|
|
2972
|
+
walkSourceFiles(full, out, depth + 1);
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) out.push(full);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
function lineColAt(sf, pos) {
|
|
2979
|
+
const lc = sf.getLineAndCharacterOfPosition(pos);
|
|
2980
|
+
return {
|
|
2981
|
+
line: lc.line + 1,
|
|
2982
|
+
column: lc.character + 1
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
/** Strip surrounding quotes from a string literal as parsed by TS. */
|
|
2986
|
+
function stringLiteralValue(node) {
|
|
2987
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Extract `island()` declarations recognized in the file. Mirrors the
|
|
2991
|
+
* shape recognized by `@pyreon/vite-plugin`'s `scanIslandDeclarations`
|
|
2992
|
+
* and PR G's `collectNeverIslandNames` — only inline-arrow loaders +
|
|
2993
|
+
* string-literal options are captured. Other shapes fall through (false
|
|
2994
|
+
* negatives, by design).
|
|
2995
|
+
*/
|
|
2996
|
+
function extractIslandDecls(sf, absPath, root) {
|
|
2997
|
+
const decls = [];
|
|
2998
|
+
const fileDir = dirname(absPath);
|
|
2999
|
+
const relPath = relative(root, absPath);
|
|
3000
|
+
function visit(node) {
|
|
3001
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
3002
|
+
const loaderArg = node.arguments[0];
|
|
3003
|
+
const optsArg = node.arguments[1];
|
|
3004
|
+
let importPath;
|
|
3005
|
+
if (loaderArg && ts.isArrowFunction(loaderArg)) {
|
|
3006
|
+
const body = loaderArg.body;
|
|
3007
|
+
const callTarget = ts.isCallExpression(body) ? body : void 0;
|
|
3008
|
+
if (callTarget && callTarget.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
3009
|
+
const arg0 = callTarget.arguments[0];
|
|
3010
|
+
if (arg0) importPath = stringLiteralValue(arg0);
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
if (optsArg && ts.isObjectLiteralExpression(optsArg)) {
|
|
3014
|
+
let nameVal;
|
|
3015
|
+
let hydrateVal;
|
|
3016
|
+
for (const prop of optsArg.properties) {
|
|
3017
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
3018
|
+
const keyText = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : "";
|
|
3019
|
+
if (keyText === "name") nameVal = stringLiteralValue(prop.initializer);
|
|
3020
|
+
else if (keyText === "hydrate") {
|
|
3021
|
+
const v = stringLiteralValue(prop.initializer);
|
|
3022
|
+
hydrateVal = v?.startsWith("interaction") ? "interaction" : v;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
if (nameVal) {
|
|
3026
|
+
const lc = lineColAt(sf, node.getStart(sf));
|
|
3027
|
+
decls.push({
|
|
3028
|
+
name: nameVal,
|
|
3029
|
+
hydrate: hydrateVal ?? "load",
|
|
3030
|
+
importPath,
|
|
3031
|
+
loc: {
|
|
3032
|
+
path: absPath,
|
|
3033
|
+
relPath,
|
|
3034
|
+
line: lc.line,
|
|
3035
|
+
column: lc.column
|
|
3036
|
+
},
|
|
3037
|
+
fileDir
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
ts.forEachChild(node, visit);
|
|
3043
|
+
}
|
|
3044
|
+
visit(sf);
|
|
3045
|
+
return decls;
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Extract `hydrateIslands({...})` registry entries. Recognizes both
|
|
3049
|
+
* shorthand (`{ Counter }`) and property-assignment (`{ Counter: () =>
|
|
3050
|
+
* import('./Counter') }`) forms.
|
|
3051
|
+
*/
|
|
3052
|
+
function extractRegistryEntries(sf, absPath, root) {
|
|
3053
|
+
const entries = [];
|
|
3054
|
+
const relPath = relative(root, absPath);
|
|
3055
|
+
function visit(node) {
|
|
3056
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "hydrateIslands" && node.arguments.length >= 1) {
|
|
3057
|
+
const arg = node.arguments[0];
|
|
3058
|
+
if (arg && ts.isObjectLiteralExpression(arg)) for (const prop of arg.properties) {
|
|
3059
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
3060
|
+
const keyNode = prop.name;
|
|
3061
|
+
const key = ts.isIdentifier(keyNode) ? keyNode.text : ts.isStringLiteral(keyNode) ? keyNode.text : "";
|
|
3062
|
+
if (!key) continue;
|
|
3063
|
+
const lc = lineColAt(sf, prop.getStart(sf));
|
|
3064
|
+
entries.push({
|
|
3065
|
+
key,
|
|
3066
|
+
loc: {
|
|
3067
|
+
path: absPath,
|
|
3068
|
+
relPath,
|
|
3069
|
+
line: lc.line,
|
|
3070
|
+
column: lc.column
|
|
3071
|
+
}
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
ts.forEachChild(node, visit);
|
|
3076
|
+
}
|
|
3077
|
+
visit(sf);
|
|
3078
|
+
return entries;
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Extract every import target (static `import` declarations + dynamic
|
|
3082
|
+
* `import()` expressions) and resolve relative paths to absolute paths
|
|
3083
|
+
* for the imports map. Bare specifiers (`@pyreon/server`) are kept as-is
|
|
3084
|
+
* — we only use this for the dead-island heuristic, which compares
|
|
3085
|
+
* against absolute file paths of declared islands, so bare specs simply
|
|
3086
|
+
* never match.
|
|
3087
|
+
*/
|
|
3088
|
+
function extractImports(sf, absPath) {
|
|
3089
|
+
const out = /* @__PURE__ */ new Set();
|
|
3090
|
+
const fileDir = dirname(absPath);
|
|
3091
|
+
function record(spec) {
|
|
3092
|
+
if (spec.startsWith(".")) out.add(resolve(fileDir, spec));
|
|
3093
|
+
else out.add(spec);
|
|
3094
|
+
}
|
|
3095
|
+
function visit(node) {
|
|
3096
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) record(node.moduleSpecifier.text);
|
|
3097
|
+
else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
3098
|
+
const arg0 = node.arguments[0];
|
|
3099
|
+
const v = arg0 ? stringLiteralValue(arg0) : void 0;
|
|
3100
|
+
if (v) record(v);
|
|
3101
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) record(node.moduleSpecifier.text);
|
|
3102
|
+
ts.forEachChild(node, visit);
|
|
3103
|
+
}
|
|
3104
|
+
visit(sf);
|
|
3105
|
+
return out;
|
|
3106
|
+
}
|
|
3107
|
+
function extractFromFile(absPath, root) {
|
|
3108
|
+
let code = "";
|
|
3109
|
+
try {
|
|
3110
|
+
code = readFileSync(absPath, "utf8");
|
|
3111
|
+
} catch {
|
|
3112
|
+
return {
|
|
3113
|
+
islands: [],
|
|
3114
|
+
registryEntries: [],
|
|
3115
|
+
imports: /* @__PURE__ */ new Set()
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
const sf = ts.createSourceFile(absPath, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
3119
|
+
return {
|
|
3120
|
+
islands: extractIslandDecls(sf, absPath, root),
|
|
3121
|
+
registryEntries: extractRegistryEntries(sf, absPath, root),
|
|
3122
|
+
imports: extractImports(sf, absPath)
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
const TS_EXTS = [
|
|
3126
|
+
".ts",
|
|
3127
|
+
".tsx",
|
|
3128
|
+
".js",
|
|
3129
|
+
".jsx"
|
|
3130
|
+
];
|
|
3131
|
+
/**
|
|
3132
|
+
* Try common extensions + index files to land an absolute path on a
|
|
3133
|
+
* concrete file. Used by both helpers below.
|
|
3134
|
+
*/
|
|
3135
|
+
function resolveAbsToFile(absBase) {
|
|
3136
|
+
try {
|
|
3137
|
+
if (statSync(absBase).isFile()) return absBase;
|
|
3138
|
+
} catch {}
|
|
3139
|
+
for (const ext of TS_EXTS) try {
|
|
3140
|
+
const candidate = `${absBase}${ext}`;
|
|
3141
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
3142
|
+
} catch {}
|
|
3143
|
+
for (const ext of TS_EXTS) try {
|
|
3144
|
+
const candidate = join(absBase, `index${ext}`);
|
|
3145
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
3146
|
+
} catch {}
|
|
3147
|
+
return null;
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Resolve `import './Counter'` (or similar) to an absolute file path.
|
|
3151
|
+
* Used by `nested-island` to follow an island's loader to its target.
|
|
3152
|
+
*/
|
|
3153
|
+
function resolveImport(fromDir, spec) {
|
|
3154
|
+
if (!spec.startsWith(".")) return null;
|
|
3155
|
+
return resolveAbsToFile(resolve(fromDir, spec));
|
|
3156
|
+
}
|
|
3157
|
+
function detectDuplicateName(declsByFile, findings) {
|
|
3158
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3159
|
+
for (const decls of declsByFile.values()) for (const d of decls) {
|
|
3160
|
+
const list = byName.get(d.name) ?? [];
|
|
3161
|
+
list.push(d);
|
|
3162
|
+
byName.set(d.name, list);
|
|
3163
|
+
}
|
|
3164
|
+
for (const [name, list] of byName) {
|
|
3165
|
+
if (list.length < 2) continue;
|
|
3166
|
+
for (let i = 0; i < list.length; i++) {
|
|
3167
|
+
const self = list[i];
|
|
3168
|
+
if (!self) continue;
|
|
3169
|
+
const others = list.filter((_, j) => j !== i).map((d) => d.loc);
|
|
3170
|
+
findings.push({
|
|
3171
|
+
code: "duplicate-name",
|
|
3172
|
+
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.`,
|
|
3173
|
+
location: self.loc,
|
|
3174
|
+
related: others
|
|
3175
|
+
});
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
function detectNeverWithRegistry(decls, registry, findings) {
|
|
3180
|
+
const neverByName = /* @__PURE__ */ new Map();
|
|
3181
|
+
for (const d of decls) if (d.hydrate === "never") neverByName.set(d.name, d);
|
|
3182
|
+
for (const entry of registry) {
|
|
3183
|
+
const decl = neverByName.get(entry.key);
|
|
3184
|
+
if (!decl) continue;
|
|
3185
|
+
findings.push({
|
|
3186
|
+
code: "never-with-registry-entry",
|
|
3187
|
+
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.`,
|
|
3188
|
+
location: entry.loc,
|
|
3189
|
+
related: [decl.loc]
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
function detectRegistryMismatch(decls, registry, findings) {
|
|
3194
|
+
const declaredNames = new Set(decls.map((d) => d.name));
|
|
3195
|
+
for (const entry of registry) {
|
|
3196
|
+
if (declaredNames.has(entry.key)) continue;
|
|
3197
|
+
findings.push({
|
|
3198
|
+
code: "registry-mismatch",
|
|
3199
|
+
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.`,
|
|
3200
|
+
location: entry.loc
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
function detectNestedIsland(decls, declsByFile, findings) {
|
|
3205
|
+
for (const outer of decls) {
|
|
3206
|
+
if (!outer.importPath) continue;
|
|
3207
|
+
const resolved = resolveImport(outer.fileDir, outer.importPath);
|
|
3208
|
+
if (!resolved) continue;
|
|
3209
|
+
const innerDecls = declsByFile.get(resolved);
|
|
3210
|
+
if (!innerDecls || innerDecls.length === 0) continue;
|
|
3211
|
+
for (const inner of innerDecls) findings.push({
|
|
3212
|
+
code: "nested-island",
|
|
3213
|
+
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).`,
|
|
3214
|
+
location: outer.loc,
|
|
3215
|
+
related: [inner.loc]
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
function detectDeadIslands(decls, importedFiles, findings) {
|
|
3220
|
+
for (const d of decls) {
|
|
3221
|
+
if (importedFiles.has(d.loc.path)) continue;
|
|
3222
|
+
findings.push({
|
|
3223
|
+
code: "dead-island",
|
|
3224
|
+
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.`,
|
|
3225
|
+
location: d.loc
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
function auditIslands(rootDir) {
|
|
3230
|
+
const root = findMonorepoRoot$1(rootDir);
|
|
3231
|
+
const findings = [];
|
|
3232
|
+
const summary = {
|
|
3233
|
+
filesScanned: 0,
|
|
3234
|
+
islandsDeclared: 0,
|
|
3235
|
+
registryEntries: 0,
|
|
3236
|
+
findingsByCode: {
|
|
3237
|
+
"never-with-registry-entry": 0,
|
|
3238
|
+
"duplicate-name": 0,
|
|
3239
|
+
"registry-mismatch": 0,
|
|
3240
|
+
"nested-island": 0,
|
|
3241
|
+
"dead-island": 0
|
|
3242
|
+
}
|
|
3243
|
+
};
|
|
3244
|
+
if (!root) return {
|
|
3245
|
+
root: null,
|
|
3246
|
+
findings,
|
|
3247
|
+
summary
|
|
3248
|
+
};
|
|
3249
|
+
const files = [];
|
|
3250
|
+
walkSourceFiles(join(root, "packages"), files);
|
|
3251
|
+
walkSourceFiles(join(root, "examples"), files);
|
|
3252
|
+
summary.filesScanned = files.length;
|
|
3253
|
+
const declsByFile = /* @__PURE__ */ new Map();
|
|
3254
|
+
const allDecls = [];
|
|
3255
|
+
const allRegistry = [];
|
|
3256
|
+
const resolvedImports = /* @__PURE__ */ new Set();
|
|
3257
|
+
for (const file of files) {
|
|
3258
|
+
const ex = extractFromFile(file, root);
|
|
3259
|
+
if (ex.islands.length > 0) {
|
|
3260
|
+
declsByFile.set(file, ex.islands);
|
|
3261
|
+
allDecls.push(...ex.islands);
|
|
3262
|
+
}
|
|
3263
|
+
allRegistry.push(...ex.registryEntries);
|
|
3264
|
+
for (const spec of ex.imports) {
|
|
3265
|
+
if (!spec.startsWith("/")) continue;
|
|
3266
|
+
const resolved = resolveAbsToFile(spec);
|
|
3267
|
+
if (resolved) resolvedImports.add(resolved);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
summary.islandsDeclared = allDecls.length;
|
|
3271
|
+
summary.registryEntries = allRegistry.length;
|
|
3272
|
+
detectDuplicateName(declsByFile, findings);
|
|
3273
|
+
detectNeverWithRegistry(allDecls, allRegistry, findings);
|
|
3274
|
+
detectRegistryMismatch(allDecls, allRegistry, findings);
|
|
3275
|
+
detectNestedIsland(allDecls, declsByFile, findings);
|
|
3276
|
+
detectDeadIslands(allDecls, resolvedImports, findings);
|
|
3277
|
+
for (const f of findings) summary.findingsByCode[f.code] = (summary.findingsByCode[f.code] ?? 0) + 1;
|
|
3278
|
+
findings.sort((a, b) => {
|
|
3279
|
+
const pathCmp = a.location.relPath.localeCompare(b.location.relPath);
|
|
3280
|
+
if (pathCmp !== 0) return pathCmp;
|
|
3281
|
+
return a.location.line - b.location.line || a.location.column - b.location.column;
|
|
3282
|
+
});
|
|
3283
|
+
return {
|
|
3284
|
+
root,
|
|
3285
|
+
findings,
|
|
3286
|
+
summary
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
3289
|
+
const CODE_HEADERS = {
|
|
3290
|
+
"never-with-registry-entry": "Never-strategy island in client registry — defeats zero-JS strategy",
|
|
3291
|
+
"duplicate-name": "Duplicate island names — only the first hydrates",
|
|
3292
|
+
"registry-mismatch": "Registry references unknown island — runtime warns + skips",
|
|
3293
|
+
"nested-island": "Nested island — outer hydrateRoot replaces inner before its loader runs",
|
|
3294
|
+
"dead-island": "Declared but unused island — no other file imports its module"
|
|
3295
|
+
};
|
|
3296
|
+
function formatIslandAudit(result, options = {}) {
|
|
3297
|
+
if (options.json) return JSON.stringify(result, null, 2);
|
|
3298
|
+
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.";
|
|
3299
|
+
const parts = [];
|
|
3300
|
+
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"}`);
|
|
3301
|
+
parts.push("");
|
|
3302
|
+
if (result.findings.length === 0) {
|
|
3303
|
+
parts.push("✓ No island findings. Project-wide cross-file checks are clean:");
|
|
3304
|
+
parts.push(" - No duplicate names");
|
|
3305
|
+
parts.push(" - No `hydrate: \"never\"` islands in any client registry");
|
|
3306
|
+
parts.push(" - No registry entries pointing at undeclared names");
|
|
3307
|
+
parts.push(" - No nested islands");
|
|
3308
|
+
parts.push(" - No declared-but-unimported islands");
|
|
3309
|
+
return parts.join("\n");
|
|
3310
|
+
}
|
|
3311
|
+
parts.push(`Findings: ${result.findings.length} (` + Object.entries(result.summary.findingsByCode).filter(([, n]) => n > 0).map(([code, n]) => `${code}: ${n}`).join(", ") + ")");
|
|
3312
|
+
parts.push("");
|
|
3313
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
3314
|
+
for (const f of result.findings) {
|
|
3315
|
+
const list = byCode.get(f.code) ?? [];
|
|
3316
|
+
list.push(f);
|
|
3317
|
+
byCode.set(f.code, list);
|
|
3318
|
+
}
|
|
3319
|
+
for (const [code, list] of byCode) {
|
|
3320
|
+
parts.push(`## ${code} — ${list.length} finding${list.length === 1 ? "" : "s"}`);
|
|
3321
|
+
parts.push("");
|
|
3322
|
+
parts.push(`> ${CODE_HEADERS[code]}`);
|
|
3323
|
+
parts.push("");
|
|
3324
|
+
for (const f of list) {
|
|
3325
|
+
parts.push(` ${f.location.relPath}:${f.location.line}:${f.location.column}`);
|
|
3326
|
+
parts.push(` ${f.message}`);
|
|
3327
|
+
if (f.related && f.related.length > 0) for (const r of f.related) parts.push(` related: ${r.relPath}:${r.line}:${r.column}`);
|
|
3328
|
+
parts.push("");
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
return parts.join("\n");
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
//#endregion
|
|
3335
|
+
//#region src/ssg-audit.ts
|
|
3336
|
+
/**
|
|
3337
|
+
* Project-wide SSG audit — scans route files for SSG / ISR foot-guns
|
|
3338
|
+
* surfaced by the SSG roadmap PRs (L5, A, I). Three detector codes ship
|
|
3339
|
+
* today:
|
|
3340
|
+
*
|
|
3341
|
+
* - **`404-outside-layout-dir`** (PR L5 carve-out): a `_404.tsx` (or
|
|
3342
|
+
* `_not-found.tsx`) file NOT co-located with a `_layout.tsx`. PR L5's
|
|
3343
|
+
* `findNotFoundFallback` filters to layout records with `children`;
|
|
3344
|
+
* a standalone `_404.tsx` outside a layout directory renders via the
|
|
3345
|
+
* SSG entry's pre-L5 standalone path (no layout chrome). The audit
|
|
3346
|
+
* catches this at the filesystem level so users move their
|
|
3347
|
+
* `_404.tsx` into the canonical `_layout` directory.
|
|
3348
|
+
*
|
|
3349
|
+
* - **`dynamic-route-missing-get-static-paths`** (PR A consequence): a
|
|
3350
|
+
* dynamic route file (`[id].tsx`, `[...slug].tsx`) that lacks a
|
|
3351
|
+
* `getStaticPaths` export. The SSG plugin silently SKIPS the route
|
|
3352
|
+
* during auto-detect — the user thinks `/posts/1` etc. are
|
|
3353
|
+
* prerendered but the dist has no `dist/posts/<id>/index.html`. The
|
|
3354
|
+
* audit catches this at scan time so users add the enumerator OR
|
|
3355
|
+
* declare the route as runtime-only.
|
|
3356
|
+
*
|
|
3357
|
+
* - **`non-literal-revalidate-export`** (PR I limitation): a route
|
|
3358
|
+
* file exports `export const revalidate = TTL` (variable reference)
|
|
3359
|
+
* or `export const revalidate = ...` (expression). The literal-
|
|
3360
|
+
* capture path in `extractLiteralExport` skips non-literals — the
|
|
3361
|
+
* manifest's revalidate entry is omitted, platform-driven ISR is
|
|
3362
|
+
* silently unconfigured for that route. The audit catches this so
|
|
3363
|
+
* users inline the literal (`export const revalidate = 60`).
|
|
3364
|
+
*
|
|
3365
|
+
* Real-app coverage:
|
|
3366
|
+
* - Per-code synthetic-fixture tests in `tests/ssg-audit.test.ts`
|
|
3367
|
+
* (one fixture per finding type, bisect-verified by reverting the
|
|
3368
|
+
* detector's match condition)
|
|
3369
|
+
* - Doctor wiring at `packages/tools/cli/src/doctor.ts:checkSsg`,
|
|
3370
|
+
* CLI flag `pyreon doctor --check-ssg [--json]`
|
|
3371
|
+
*
|
|
3372
|
+
* Same syntactic-only style as `island-audit.ts` — no type-check pass,
|
|
3373
|
+
* no module resolution. False negatives acceptable; false positives
|
|
3374
|
+
* must be rare. Every finding ships with file path + line/column +
|
|
3375
|
+
* actionable fix suggestion.
|
|
3376
|
+
*/
|
|
3377
|
+
function findMonorepoRoot(startDir) {
|
|
3378
|
+
let dir = resolve(startDir);
|
|
3379
|
+
for (let i = 0; i < 30; i++) {
|
|
3380
|
+
try {
|
|
3381
|
+
if (statSync(join(dir, "packages")).isDirectory()) return dir;
|
|
3382
|
+
} catch {}
|
|
3383
|
+
const parent = dirname(dir);
|
|
3384
|
+
if (parent === dir) return null;
|
|
3385
|
+
dir = parent;
|
|
3386
|
+
}
|
|
3387
|
+
return null;
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Walk a directory looking for files under any `routes/` subdirectory.
|
|
3391
|
+
* fs-router treats files under `src/routes/` as routes; we mirror the
|
|
3392
|
+
* convention. Skips node_modules / lib / dist / test directories.
|
|
3393
|
+
*/
|
|
3394
|
+
function findRouteFiles(rootDir, out, depth = 0) {
|
|
3395
|
+
if (depth > 12) return;
|
|
3396
|
+
let entries;
|
|
3397
|
+
try {
|
|
3398
|
+
entries = readdirSync(rootDir);
|
|
3399
|
+
} catch {
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
for (const name of entries) {
|
|
3403
|
+
if (name.startsWith(".")) continue;
|
|
3404
|
+
if (name === "node_modules" || name === "lib" || name === "dist") continue;
|
|
3405
|
+
if (name === "__tests__" || name === "tests") continue;
|
|
3406
|
+
const full = join(rootDir, name);
|
|
3407
|
+
let isDir = false;
|
|
3408
|
+
try {
|
|
3409
|
+
isDir = statSync(full).isDirectory();
|
|
3410
|
+
} catch {
|
|
3411
|
+
continue;
|
|
3412
|
+
}
|
|
3413
|
+
if (isDir) {
|
|
3414
|
+
if (name === "routes") walkRoutesDir(full, out);
|
|
3415
|
+
else findRouteFiles(full, out, depth + 1);
|
|
3416
|
+
continue;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
function walkRoutesDir(dir, out) {
|
|
3421
|
+
let entries;
|
|
3422
|
+
try {
|
|
3423
|
+
entries = readdirSync(dir);
|
|
3424
|
+
} catch {
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
for (const name of entries) {
|
|
3428
|
+
if (name.startsWith(".")) continue;
|
|
3429
|
+
if (name === "node_modules") continue;
|
|
3430
|
+
const full = join(dir, name);
|
|
3431
|
+
let stat;
|
|
3432
|
+
try {
|
|
3433
|
+
stat = statSync(full);
|
|
3434
|
+
} catch {
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
if (stat.isDirectory()) {
|
|
3438
|
+
walkRoutesDir(full, out);
|
|
3439
|
+
continue;
|
|
3440
|
+
}
|
|
3441
|
+
if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) out.push(full);
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
function parseSourceFile(filePath) {
|
|
3445
|
+
let source;
|
|
3446
|
+
try {
|
|
3447
|
+
source = readFileSync(filePath, "utf8");
|
|
3448
|
+
} catch {
|
|
3449
|
+
return null;
|
|
3450
|
+
}
|
|
3451
|
+
return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
|
3452
|
+
}
|
|
3453
|
+
function locOf(source, node) {
|
|
3454
|
+
const pos = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
3455
|
+
return {
|
|
3456
|
+
line: pos.line + 1,
|
|
3457
|
+
column: pos.character + 1
|
|
3458
|
+
};
|
|
3459
|
+
}
|
|
3460
|
+
function makeLocation(absPath, source, node, rootForRel) {
|
|
3461
|
+
const { line, column } = locOf(source, node);
|
|
3462
|
+
return {
|
|
3463
|
+
path: absPath,
|
|
3464
|
+
relPath: relative(rootForRel, absPath),
|
|
3465
|
+
line,
|
|
3466
|
+
column
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* 1) `_404.tsx` / `_not-found.tsx` outside a `_layout.tsx` directory.
|
|
3471
|
+
*
|
|
3472
|
+
* fs-router scans `_404.tsx` / `_not-found.tsx` and attaches the default
|
|
3473
|
+
* export as `notFoundComponent` on its parent layout's RouteRecord. PR L5's
|
|
3474
|
+
* `findNotFoundFallback` filters to records with `Array.isArray(r.children)
|
|
3475
|
+
* && r.children.length > 0` — i.e. layouts only. A standalone `_404.tsx`
|
|
3476
|
+
* outside a layout directory:
|
|
3477
|
+
* - Becomes attached to a page record (no children)
|
|
3478
|
+
* - PR L5's walker skips it
|
|
3479
|
+
* - SSG entry falls back to the pre-L5 standalone render (no chrome)
|
|
3480
|
+
*
|
|
3481
|
+
* The audit catches this at filesystem-walk time, fast and structural.
|
|
3482
|
+
*/
|
|
3483
|
+
function detect404OutsideLayoutDir(routeFiles, rootForRel) {
|
|
3484
|
+
const findings = [];
|
|
3485
|
+
const layoutDirs = /* @__PURE__ */ new Set();
|
|
3486
|
+
for (const file of routeFiles) {
|
|
3487
|
+
const base = file.split("/").pop() ?? "";
|
|
3488
|
+
if (/^_layout\.(tsx?|jsx?)$/.test(base)) layoutDirs.add(dirname(file));
|
|
3489
|
+
}
|
|
3490
|
+
for (const file of routeFiles) {
|
|
3491
|
+
const base = file.split("/").pop() ?? "";
|
|
3492
|
+
if (!/^_(404|not-found)\.(tsx?|jsx?)$/.test(base)) continue;
|
|
3493
|
+
const dir = dirname(file);
|
|
3494
|
+
if (layoutDirs.has(dir)) continue;
|
|
3495
|
+
findings.push({
|
|
3496
|
+
code: "404-outside-layout-dir",
|
|
3497
|
+
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).`,
|
|
3498
|
+
location: {
|
|
3499
|
+
path: file,
|
|
3500
|
+
relPath: relative(rootForRel, file),
|
|
3501
|
+
line: 1,
|
|
3502
|
+
column: 1
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
return findings;
|
|
3507
|
+
}
|
|
3508
|
+
/**
|
|
3509
|
+
* 2) Dynamic route file missing `getStaticPaths` export.
|
|
3510
|
+
*
|
|
3511
|
+
* `[id].tsx`, `[...slug].tsx` — under SSG mode without a `getStaticPaths`,
|
|
3512
|
+
* the auto-detect step silently skips the route. User expects
|
|
3513
|
+
* `dist/posts/1/index.html` but never gets it.
|
|
3514
|
+
*
|
|
3515
|
+
* We syntactically scan for `export const getStaticPaths` or
|
|
3516
|
+
* `export function getStaticPaths`. Re-exports / async-function form
|
|
3517
|
+
* supported. Same literal-extraction shape used in fs-router's scanner.
|
|
3518
|
+
*/
|
|
3519
|
+
function detectDynamicRouteMissingGetStaticPaths(routeFiles, rootForRel) {
|
|
3520
|
+
const findings = [];
|
|
3521
|
+
for (const file of routeFiles) {
|
|
3522
|
+
const base = file.split("/").pop() ?? "";
|
|
3523
|
+
if (!/\[.+\]/.test(base)) continue;
|
|
3524
|
+
if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue;
|
|
3525
|
+
if (/[/\\]routes[/\\]api[/\\]/.test(file)) continue;
|
|
3526
|
+
const source = parseSourceFile(file);
|
|
3527
|
+
if (!source) continue;
|
|
3528
|
+
let hasGetStaticPaths = false;
|
|
3529
|
+
let hasDefaultExport = false;
|
|
3530
|
+
function visit(node) {
|
|
3531
|
+
if (hasGetStaticPaths && hasDefaultExport) return;
|
|
3532
|
+
if (ts.isVariableStatement(node)) {
|
|
3533
|
+
if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
3534
|
+
for (const decl of node.declarationList.declarations) if (ts.isIdentifier(decl.name) && decl.name.text === "getStaticPaths") hasGetStaticPaths = true;
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
3538
|
+
const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
3539
|
+
const isDefault = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
3540
|
+
if (hasExport && node.name?.text === "getStaticPaths") hasGetStaticPaths = true;
|
|
3541
|
+
if (hasExport && isDefault) hasDefaultExport = true;
|
|
3542
|
+
}
|
|
3543
|
+
if (ts.isExportAssignment(node) && !node.isExportEquals) hasDefaultExport = true;
|
|
3544
|
+
ts.forEachChild(node, visit);
|
|
3545
|
+
}
|
|
3546
|
+
visit(source);
|
|
3547
|
+
if (!hasDefaultExport) continue;
|
|
3548
|
+
if (!hasGetStaticPaths) findings.push({
|
|
3549
|
+
code: "dynamic-route-missing-get-static-paths",
|
|
3550
|
+
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'.`,
|
|
3551
|
+
location: {
|
|
3552
|
+
path: file,
|
|
3553
|
+
relPath: relative(rootForRel, file),
|
|
3554
|
+
line: 1,
|
|
3555
|
+
column: 1
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
return findings;
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* 3) `export const revalidate = X` where X is NOT a pure literal.
|
|
3563
|
+
*
|
|
3564
|
+
* PR I's `extractLiteralExport` skips re-export forms (`const x = 60;
|
|
3565
|
+
* export { x as revalidate }`) and non-literal expressions
|
|
3566
|
+
* (`export const revalidate = TTL` where TTL is a const elsewhere). The
|
|
3567
|
+
* manifest emission skips the entry silently — user thinks ISR is wired
|
|
3568
|
+
* but `_pyreon-revalidate.json` is missing the path. The audit catches
|
|
3569
|
+
* the syntactic shape and warns.
|
|
3570
|
+
*
|
|
3571
|
+
* Valid literals: NumericLiteral (`60`), FalseKeyword (`false`).
|
|
3572
|
+
* Anything else — Identifier reference, BinaryExpression, CallExpression,
|
|
3573
|
+
* TemplateLiteral — flagged.
|
|
3574
|
+
*/
|
|
3575
|
+
function detectNonLiteralRevalidateExport(routeFiles, rootForRel) {
|
|
3576
|
+
const findings = [];
|
|
3577
|
+
for (const file of routeFiles) {
|
|
3578
|
+
const parsed = parseSourceFile(file);
|
|
3579
|
+
if (!parsed) continue;
|
|
3580
|
+
const source = parsed;
|
|
3581
|
+
function visit(node) {
|
|
3582
|
+
if (ts.isVariableStatement(node)) {
|
|
3583
|
+
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
3584
|
+
ts.forEachChild(node, visit);
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
for (const decl of node.declarationList.declarations) {
|
|
3588
|
+
if (!ts.isIdentifier(decl.name) || decl.name.text !== "revalidate") continue;
|
|
3589
|
+
const init = decl.initializer;
|
|
3590
|
+
if (!init) continue;
|
|
3591
|
+
if (ts.isNumericLiteral(init)) continue;
|
|
3592
|
+
if (init.kind === ts.SyntaxKind.FalseKeyword) continue;
|
|
3593
|
+
findings.push({
|
|
3594
|
+
code: "non-literal-revalidate-export",
|
|
3595
|
+
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`.",
|
|
3596
|
+
location: makeLocation(file, source, init, rootForRel)
|
|
3597
|
+
});
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
ts.forEachChild(node, visit);
|
|
3601
|
+
}
|
|
3602
|
+
visit(source);
|
|
3603
|
+
}
|
|
3604
|
+
return findings;
|
|
3605
|
+
}
|
|
3606
|
+
function auditSsg(rootDir) {
|
|
3607
|
+
const root = findMonorepoRoot(rootDir) ?? rootDir;
|
|
3608
|
+
const routeFiles = [];
|
|
3609
|
+
findRouteFiles(rootDir, routeFiles);
|
|
3610
|
+
let dynamicRoutes = 0;
|
|
3611
|
+
let revalidateExports = 0;
|
|
3612
|
+
for (const file of routeFiles) {
|
|
3613
|
+
const base = file.split("/").pop() ?? "";
|
|
3614
|
+
if (/\[.+\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) dynamicRoutes++;
|
|
3615
|
+
const source = parseSourceFile(file);
|
|
3616
|
+
if (!source) continue;
|
|
3617
|
+
function visit(node) {
|
|
3618
|
+
if (ts.isVariableStatement(node)) {
|
|
3619
|
+
if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
3620
|
+
for (const decl of node.declarationList.declarations) if (ts.isIdentifier(decl.name) && decl.name.text === "revalidate") revalidateExports++;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
ts.forEachChild(node, visit);
|
|
3624
|
+
}
|
|
3625
|
+
visit(source);
|
|
3626
|
+
}
|
|
3627
|
+
const findings = [
|
|
3628
|
+
...detect404OutsideLayoutDir(routeFiles, root),
|
|
3629
|
+
...detectDynamicRouteMissingGetStaticPaths(routeFiles, root),
|
|
3630
|
+
...detectNonLiteralRevalidateExport(routeFiles, root)
|
|
3631
|
+
];
|
|
3632
|
+
const findingsByCode = {
|
|
3633
|
+
"404-outside-layout-dir": 0,
|
|
3634
|
+
"dynamic-route-missing-get-static-paths": 0,
|
|
3635
|
+
"non-literal-revalidate-export": 0
|
|
3636
|
+
};
|
|
3637
|
+
for (const f of findings) findingsByCode[f.code]++;
|
|
3638
|
+
return {
|
|
3639
|
+
root,
|
|
3640
|
+
findings,
|
|
3641
|
+
summary: {
|
|
3642
|
+
filesScanned: routeFiles.length,
|
|
3643
|
+
routesScanned: routeFiles.length,
|
|
3644
|
+
dynamicRoutes,
|
|
3645
|
+
revalidateExports,
|
|
3646
|
+
findingsByCode
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3649
|
+
}
|
|
3650
|
+
function formatSsgAudit(result, _options = {}) {
|
|
3651
|
+
const lines = [];
|
|
3652
|
+
lines.push("── SSG audit ─────────────────────────────────────────────────────");
|
|
3653
|
+
lines.push("");
|
|
3654
|
+
lines.push(`Scanned ${result.summary.routesScanned} route file(s), ${result.summary.dynamicRoutes} dynamic route(s), ${result.summary.revalidateExports} revalidate export(s).`);
|
|
3655
|
+
lines.push("");
|
|
3656
|
+
if (result.findings.length === 0) {
|
|
3657
|
+
lines.push("✓ No SSG / ISR issues found.");
|
|
3658
|
+
lines.push("");
|
|
3659
|
+
return lines.join("\n");
|
|
3660
|
+
}
|
|
3661
|
+
lines.push(`Found ${result.findings.length} issue(s):`);
|
|
3662
|
+
for (const f of result.findings) {
|
|
3663
|
+
lines.push("");
|
|
3664
|
+
lines.push(` [${f.code}] ${f.location.relPath}:${f.location.line}:${f.location.column}`);
|
|
3665
|
+
lines.push(` ${f.message}`);
|
|
3666
|
+
}
|
|
3667
|
+
lines.push("");
|
|
3668
|
+
lines.push("Run `pyreon doctor --check-ssg --json` for machine-readable output.");
|
|
3669
|
+
lines.push("");
|
|
3670
|
+
return lines.join("\n");
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
//#endregion
|
|
3674
|
+
export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
|
|
2516
3675
|
//# sourceMappingURL=index.js.map
|