@pyreon/compiler 0.24.5 → 0.24.6
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/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/island-audit.ts
DELETED
|
@@ -1,675 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project-wide islands audit for the `audit_islands` MCP tool +
|
|
3
|
-
* `pyreon doctor --check-islands` CLI flag (PR C of the islands DX
|
|
4
|
-
* roadmap).
|
|
5
|
-
*
|
|
6
|
-
* Companion gates that pre-date this module:
|
|
7
|
-
*
|
|
8
|
-
* - PR G's `island-never-with-registry-entry` detector (in
|
|
9
|
-
* `pyreon-intercept.ts`) catches the same shape per FILE — it only
|
|
10
|
-
* fires when the `island()` declaration AND `hydrateIslands({...})`
|
|
11
|
-
* call are in the same source.
|
|
12
|
-
* - PR B's auto-registry (`@pyreon/vite-plugin` `islands: true`)
|
|
13
|
-
* eliminates the manual sync entirely — the registry is generated
|
|
14
|
-
* from `island()` declarations, so it can't drift.
|
|
15
|
-
*
|
|
16
|
-
* What this audit adds: cross-file analysis. Five findings:
|
|
17
|
-
*
|
|
18
|
-
* 1. `never-with-registry-entry` — project-wide cross-file version of
|
|
19
|
-
* the per-file detector. Fires when ANY file's `island()` with
|
|
20
|
-
* `hydrate: 'never'` matches a key in ANY file's `hydrateIslands`
|
|
21
|
-
* call.
|
|
22
|
-
* 2. `duplicate-name` — two `island()` declarations with the same
|
|
23
|
-
* `name`. Runtime would only hydrate the first; the second fails
|
|
24
|
-
* silently.
|
|
25
|
-
* 3. `registry-mismatch` — a `hydrateIslands({ X })` entry with no
|
|
26
|
-
* matching `island()` declaration anywhere in the project. Catches
|
|
27
|
-
* the manual-form drift foot-gun (typo / removed island /
|
|
28
|
-
* forgotten import).
|
|
29
|
-
* 4. `nested-island` — an `island()` whose loader-imported file ALSO
|
|
30
|
-
* contains an `island()` call. Statically reachable nesting; the
|
|
31
|
-
* outer's `hydrateRoot` would replace the inner before its loader
|
|
32
|
-
* runs.
|
|
33
|
-
* 5. `dead-island` — an `island()` declared in a file that no other
|
|
34
|
-
* file imports (statically OR dynamically). Heuristic catches the
|
|
35
|
-
* common shape of "declared but never wired up." False negatives
|
|
36
|
-
* possible (file imported but the island binding within it isn't
|
|
37
|
-
* used) — that's the cost of staying syntactic + cheap.
|
|
38
|
-
*
|
|
39
|
-
* Architectural note. This is intentionally syntactic, not semantic.
|
|
40
|
-
* The audit reads source files as text + AST and never resolves through
|
|
41
|
-
* type-checking. False negatives are acceptable; false positives must
|
|
42
|
-
* be rare. Every finding includes file paths + line/column + actionable
|
|
43
|
-
* fix suggestion so the user can verify in seconds.
|
|
44
|
-
*/
|
|
45
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
46
|
-
import { dirname, join, relative, resolve } from 'node:path'
|
|
47
|
-
import ts from 'typescript'
|
|
48
|
-
|
|
49
|
-
export type IslandFindingCode =
|
|
50
|
-
| 'never-with-registry-entry'
|
|
51
|
-
| 'duplicate-name'
|
|
52
|
-
| 'registry-mismatch'
|
|
53
|
-
| 'nested-island'
|
|
54
|
-
| 'dead-island'
|
|
55
|
-
|
|
56
|
-
export interface IslandLocation {
|
|
57
|
-
/** Absolute path */
|
|
58
|
-
path: string
|
|
59
|
-
/** Path relative to the repo root for readable reporting */
|
|
60
|
-
relPath: string
|
|
61
|
-
/** 1-based line number */
|
|
62
|
-
line: number
|
|
63
|
-
/** 1-based column number */
|
|
64
|
-
column: number
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface IslandFinding {
|
|
68
|
-
code: IslandFindingCode
|
|
69
|
-
/** One-paragraph human-readable explanation, including the fix path. */
|
|
70
|
-
message: string
|
|
71
|
-
/** Where the finding surfaces. */
|
|
72
|
-
location: IslandLocation
|
|
73
|
-
/**
|
|
74
|
-
* Companion locations for cross-file findings (`duplicate-name` lists
|
|
75
|
-
* the OTHER occurrence; `nested-island` lists the inner island's
|
|
76
|
-
* declaration; `never-with-registry-entry` lists the matching island
|
|
77
|
-
* declaration).
|
|
78
|
-
*/
|
|
79
|
-
related?: IslandLocation[] | undefined
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface IslandAuditResult {
|
|
83
|
-
root: string | null
|
|
84
|
-
findings: IslandFinding[]
|
|
85
|
-
summary: {
|
|
86
|
-
filesScanned: number
|
|
87
|
-
islandsDeclared: number
|
|
88
|
-
registryEntries: number
|
|
89
|
-
findingsByCode: Record<IslandFindingCode, number>
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
94
|
-
// Discovery
|
|
95
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
96
|
-
|
|
97
|
-
function findMonorepoRoot(startDir: string): string | null {
|
|
98
|
-
let dir = resolve(startDir)
|
|
99
|
-
for (let i = 0; i < 30; i++) {
|
|
100
|
-
try {
|
|
101
|
-
if (statSync(join(dir, 'packages')).isDirectory()) return dir
|
|
102
|
-
} catch {
|
|
103
|
-
// fall through
|
|
104
|
-
}
|
|
105
|
-
const parent = dirname(dir)
|
|
106
|
-
if (parent === dir) return null
|
|
107
|
-
dir = parent
|
|
108
|
-
}
|
|
109
|
-
return null
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function walkSourceFiles(dir: string, out: string[], depth = 0): void {
|
|
113
|
-
if (depth > 12) return
|
|
114
|
-
let entries: string[]
|
|
115
|
-
try {
|
|
116
|
-
entries = readdirSync(dir)
|
|
117
|
-
} catch {
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
for (const name of entries) {
|
|
121
|
-
if (name.startsWith('.')) continue
|
|
122
|
-
if (name === 'node_modules' || name === 'lib' || name === 'dist') continue
|
|
123
|
-
if (name === '__tests__' || name === 'tests') continue
|
|
124
|
-
const full = join(dir, name)
|
|
125
|
-
let isDir = false
|
|
126
|
-
try {
|
|
127
|
-
isDir = statSync(full).isDirectory()
|
|
128
|
-
} catch {
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
if (isDir) {
|
|
132
|
-
walkSourceFiles(full, out, depth + 1)
|
|
133
|
-
continue
|
|
134
|
-
}
|
|
135
|
-
if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) {
|
|
136
|
-
out.push(full)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
142
|
-
// Per-file extraction
|
|
143
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
144
|
-
|
|
145
|
-
interface IslandDecl {
|
|
146
|
-
name: string
|
|
147
|
-
hydrate: string
|
|
148
|
-
/** Raw import path string from the loader fn body, e.g. './Counter' */
|
|
149
|
-
importPath: string | undefined
|
|
150
|
-
loc: IslandLocation
|
|
151
|
-
/** Containing file's directory — used to resolve `importPath` */
|
|
152
|
-
fileDir: string
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
interface RegistryEntry {
|
|
156
|
-
/** The key in the `hydrateIslands({...})` call (the island name being registered) */
|
|
157
|
-
key: string
|
|
158
|
-
loc: IslandLocation
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
interface FileExtraction {
|
|
162
|
-
islands: IslandDecl[]
|
|
163
|
-
registryEntries: RegistryEntry[]
|
|
164
|
-
/** Set of resolved (best-effort) absolute paths this file imports */
|
|
165
|
-
imports: Set<string>
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function lineColAt(sf: ts.SourceFile, pos: number): { line: number; column: number } {
|
|
169
|
-
const lc = sf.getLineAndCharacterOfPosition(pos)
|
|
170
|
-
return { line: lc.line + 1, column: lc.character + 1 }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Strip surrounding quotes from a string literal as parsed by TS. */
|
|
174
|
-
function stringLiteralValue(node: ts.Node): string | undefined {
|
|
175
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
176
|
-
return node.text
|
|
177
|
-
}
|
|
178
|
-
return undefined
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Extract `island()` declarations recognized in the file. Mirrors the
|
|
183
|
-
* shape recognized by `@pyreon/vite-plugin`'s `scanIslandDeclarations`
|
|
184
|
-
* and PR G's `collectNeverIslandNames` — only inline-arrow loaders +
|
|
185
|
-
* string-literal options are captured. Other shapes fall through (false
|
|
186
|
-
* negatives, by design).
|
|
187
|
-
*/
|
|
188
|
-
function extractIslandDecls(sf: ts.SourceFile, absPath: string, root: string): IslandDecl[] {
|
|
189
|
-
const decls: IslandDecl[] = []
|
|
190
|
-
const fileDir = dirname(absPath)
|
|
191
|
-
const relPath = relative(root, absPath)
|
|
192
|
-
|
|
193
|
-
function visit(node: ts.Node): void {
|
|
194
|
-
if (
|
|
195
|
-
ts.isCallExpression(node) &&
|
|
196
|
-
ts.isIdentifier(node.expression) &&
|
|
197
|
-
node.expression.text === 'island' &&
|
|
198
|
-
node.arguments.length >= 2
|
|
199
|
-
) {
|
|
200
|
-
const loaderArg = node.arguments[0]
|
|
201
|
-
const optsArg = node.arguments[1]
|
|
202
|
-
|
|
203
|
-
let importPath: string | undefined
|
|
204
|
-
// Recognize `() => import('PATH')` — single ImportExpression in body
|
|
205
|
-
if (loaderArg && ts.isArrowFunction(loaderArg)) {
|
|
206
|
-
const body = loaderArg.body
|
|
207
|
-
const callTarget = ts.isCallExpression(body) ? body : undefined
|
|
208
|
-
if (callTarget && callTarget.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
209
|
-
const arg0 = callTarget.arguments[0]
|
|
210
|
-
if (arg0) importPath = stringLiteralValue(arg0)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (optsArg && ts.isObjectLiteralExpression(optsArg)) {
|
|
215
|
-
let nameVal: string | undefined
|
|
216
|
-
let hydrateVal: string | undefined
|
|
217
|
-
for (const prop of optsArg.properties) {
|
|
218
|
-
if (!ts.isPropertyAssignment(prop)) continue
|
|
219
|
-
const keyText = ts.isIdentifier(prop.name)
|
|
220
|
-
? prop.name.text
|
|
221
|
-
: ts.isStringLiteral(prop.name)
|
|
222
|
-
? prop.name.text
|
|
223
|
-
: ''
|
|
224
|
-
if (keyText === 'name') {
|
|
225
|
-
nameVal = stringLiteralValue(prop.initializer)
|
|
226
|
-
} else if (keyText === 'hydrate') {
|
|
227
|
-
const v = stringLiteralValue(prop.initializer)
|
|
228
|
-
// Normalize `'interaction(focus)'` → `'interaction'` for grouping
|
|
229
|
-
hydrateVal = v?.startsWith('interaction') ? 'interaction' : v
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (nameVal) {
|
|
233
|
-
const lc = lineColAt(sf, node.getStart(sf))
|
|
234
|
-
decls.push({
|
|
235
|
-
name: nameVal,
|
|
236
|
-
hydrate: hydrateVal ?? 'load',
|
|
237
|
-
importPath,
|
|
238
|
-
loc: { path: absPath, relPath, line: lc.line, column: lc.column },
|
|
239
|
-
fileDir,
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
ts.forEachChild(node, visit)
|
|
245
|
-
}
|
|
246
|
-
visit(sf)
|
|
247
|
-
return decls
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Extract `hydrateIslands({...})` registry entries. Recognizes both
|
|
252
|
-
* shorthand (`{ Counter }`) and property-assignment (`{ Counter: () =>
|
|
253
|
-
* import('./Counter') }`) forms.
|
|
254
|
-
*/
|
|
255
|
-
function extractRegistryEntries(
|
|
256
|
-
sf: ts.SourceFile,
|
|
257
|
-
absPath: string,
|
|
258
|
-
root: string,
|
|
259
|
-
): RegistryEntry[] {
|
|
260
|
-
const entries: RegistryEntry[] = []
|
|
261
|
-
const relPath = relative(root, absPath)
|
|
262
|
-
|
|
263
|
-
function visit(node: ts.Node): void {
|
|
264
|
-
if (
|
|
265
|
-
ts.isCallExpression(node) &&
|
|
266
|
-
ts.isIdentifier(node.expression) &&
|
|
267
|
-
node.expression.text === 'hydrateIslands' &&
|
|
268
|
-
node.arguments.length >= 1
|
|
269
|
-
) {
|
|
270
|
-
const arg = node.arguments[0]
|
|
271
|
-
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
272
|
-
for (const prop of arg.properties) {
|
|
273
|
-
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue
|
|
274
|
-
const keyNode = prop.name
|
|
275
|
-
const key = ts.isIdentifier(keyNode)
|
|
276
|
-
? keyNode.text
|
|
277
|
-
: ts.isStringLiteral(keyNode)
|
|
278
|
-
? keyNode.text
|
|
279
|
-
: ''
|
|
280
|
-
if (!key) continue
|
|
281
|
-
const lc = lineColAt(sf, prop.getStart(sf))
|
|
282
|
-
entries.push({
|
|
283
|
-
key,
|
|
284
|
-
loc: { path: absPath, relPath, line: lc.line, column: lc.column },
|
|
285
|
-
})
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
ts.forEachChild(node, visit)
|
|
290
|
-
}
|
|
291
|
-
visit(sf)
|
|
292
|
-
return entries
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Extract every import target (static `import` declarations + dynamic
|
|
297
|
-
* `import()` expressions) and resolve relative paths to absolute paths
|
|
298
|
-
* for the imports map. Bare specifiers (`@pyreon/server`) are kept as-is
|
|
299
|
-
* — we only use this for the dead-island heuristic, which compares
|
|
300
|
-
* against absolute file paths of declared islands, so bare specs simply
|
|
301
|
-
* never match.
|
|
302
|
-
*/
|
|
303
|
-
function extractImports(sf: ts.SourceFile, absPath: string): Set<string> {
|
|
304
|
-
const out = new Set<string>()
|
|
305
|
-
const fileDir = dirname(absPath)
|
|
306
|
-
|
|
307
|
-
function record(spec: string): void {
|
|
308
|
-
if (spec.startsWith('.')) {
|
|
309
|
-
out.add(resolve(fileDir, spec))
|
|
310
|
-
} else {
|
|
311
|
-
out.add(spec)
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function visit(node: ts.Node): void {
|
|
316
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
317
|
-
record(node.moduleSpecifier.text)
|
|
318
|
-
} else if (
|
|
319
|
-
ts.isCallExpression(node) &&
|
|
320
|
-
node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
321
|
-
) {
|
|
322
|
-
const arg0 = node.arguments[0]
|
|
323
|
-
const v = arg0 ? stringLiteralValue(arg0) : undefined
|
|
324
|
-
if (v) record(v)
|
|
325
|
-
} else if (
|
|
326
|
-
ts.isExportDeclaration(node) &&
|
|
327
|
-
node.moduleSpecifier &&
|
|
328
|
-
ts.isStringLiteral(node.moduleSpecifier)
|
|
329
|
-
) {
|
|
330
|
-
record(node.moduleSpecifier.text)
|
|
331
|
-
}
|
|
332
|
-
ts.forEachChild(node, visit)
|
|
333
|
-
}
|
|
334
|
-
visit(sf)
|
|
335
|
-
return out
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function extractFromFile(absPath: string, root: string): FileExtraction {
|
|
339
|
-
let code = ''
|
|
340
|
-
try {
|
|
341
|
-
code = readFileSync(absPath, 'utf8')
|
|
342
|
-
} catch {
|
|
343
|
-
return { islands: [], registryEntries: [], imports: new Set() }
|
|
344
|
-
}
|
|
345
|
-
const sf = ts.createSourceFile(absPath, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
346
|
-
return {
|
|
347
|
-
islands: extractIslandDecls(sf, absPath, root),
|
|
348
|
-
registryEntries: extractRegistryEntries(sf, absPath, root),
|
|
349
|
-
imports: extractImports(sf, absPath),
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
354
|
-
// Import-path resolution helper
|
|
355
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
356
|
-
|
|
357
|
-
const TS_EXTS = ['.ts', '.tsx', '.js', '.jsx']
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Try common extensions + index files to land an absolute path on a
|
|
361
|
-
* concrete file. Used by both helpers below.
|
|
362
|
-
*/
|
|
363
|
-
function resolveAbsToFile(absBase: string): string | null {
|
|
364
|
-
try {
|
|
365
|
-
if (statSync(absBase).isFile()) return absBase
|
|
366
|
-
} catch {
|
|
367
|
-
// fall through
|
|
368
|
-
}
|
|
369
|
-
for (const ext of TS_EXTS) {
|
|
370
|
-
try {
|
|
371
|
-
const candidate = `${absBase}${ext}`
|
|
372
|
-
if (statSync(candidate).isFile()) return candidate
|
|
373
|
-
} catch {
|
|
374
|
-
// try next
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
for (const ext of TS_EXTS) {
|
|
378
|
-
try {
|
|
379
|
-
const candidate = join(absBase, `index${ext}`)
|
|
380
|
-
if (statSync(candidate).isFile()) return candidate
|
|
381
|
-
} catch {
|
|
382
|
-
// try next
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return null
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Resolve `import './Counter'` (or similar) to an absolute file path.
|
|
390
|
-
* Used by `nested-island` to follow an island's loader to its target.
|
|
391
|
-
*/
|
|
392
|
-
function resolveImport(fromDir: string, spec: string): string | null {
|
|
393
|
-
if (!spec.startsWith('.')) return null
|
|
394
|
-
return resolveAbsToFile(resolve(fromDir, spec))
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
398
|
-
// Detectors
|
|
399
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
400
|
-
|
|
401
|
-
function detectDuplicateName(
|
|
402
|
-
declsByFile: Map<string, IslandDecl[]>,
|
|
403
|
-
findings: IslandFinding[],
|
|
404
|
-
): void {
|
|
405
|
-
const byName = new Map<string, IslandDecl[]>()
|
|
406
|
-
for (const decls of declsByFile.values()) {
|
|
407
|
-
for (const d of decls) {
|
|
408
|
-
const list = byName.get(d.name) ?? []
|
|
409
|
-
list.push(d)
|
|
410
|
-
byName.set(d.name, list)
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
for (const [name, list] of byName) {
|
|
414
|
-
if (list.length < 2) continue
|
|
415
|
-
// Each duplicate gets its own finding pointing at one occurrence,
|
|
416
|
-
// with `related` listing the others. The user sees N findings for
|
|
417
|
-
// N duplicates so every IDE jumps highlight the conflict cleanly.
|
|
418
|
-
for (let i = 0; i < list.length; i++) {
|
|
419
|
-
const self = list[i]
|
|
420
|
-
if (!self) continue
|
|
421
|
-
const others = list
|
|
422
|
-
.filter((_, j) => j !== i)
|
|
423
|
-
.map((d) => d.loc)
|
|
424
|
-
findings.push({
|
|
425
|
-
code: 'duplicate-name',
|
|
426
|
-
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.`,
|
|
427
|
-
location: self.loc,
|
|
428
|
-
related: others,
|
|
429
|
-
})
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function detectNeverWithRegistry(
|
|
435
|
-
decls: IslandDecl[],
|
|
436
|
-
registry: RegistryEntry[],
|
|
437
|
-
findings: IslandFinding[],
|
|
438
|
-
): void {
|
|
439
|
-
const neverByName = new Map<string, IslandDecl>()
|
|
440
|
-
for (const d of decls) {
|
|
441
|
-
if (d.hydrate === 'never') neverByName.set(d.name, d)
|
|
442
|
-
}
|
|
443
|
-
for (const entry of registry) {
|
|
444
|
-
const decl = neverByName.get(entry.key)
|
|
445
|
-
if (!decl) continue
|
|
446
|
-
findings.push({
|
|
447
|
-
code: 'never-with-registry-entry',
|
|
448
|
-
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.`,
|
|
449
|
-
location: entry.loc,
|
|
450
|
-
related: [decl.loc],
|
|
451
|
-
})
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function detectRegistryMismatch(
|
|
456
|
-
decls: IslandDecl[],
|
|
457
|
-
registry: RegistryEntry[],
|
|
458
|
-
findings: IslandFinding[],
|
|
459
|
-
): void {
|
|
460
|
-
const declaredNames = new Set(decls.map((d) => d.name))
|
|
461
|
-
for (const entry of registry) {
|
|
462
|
-
if (declaredNames.has(entry.key)) continue
|
|
463
|
-
findings.push({
|
|
464
|
-
code: 'registry-mismatch',
|
|
465
|
-
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.`,
|
|
466
|
-
location: entry.loc,
|
|
467
|
-
})
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function detectNestedIsland(
|
|
472
|
-
decls: IslandDecl[],
|
|
473
|
-
declsByFile: Map<string, IslandDecl[]>,
|
|
474
|
-
findings: IslandFinding[],
|
|
475
|
-
): void {
|
|
476
|
-
for (const outer of decls) {
|
|
477
|
-
if (!outer.importPath) continue
|
|
478
|
-
const resolved = resolveImport(outer.fileDir, outer.importPath)
|
|
479
|
-
if (!resolved) continue
|
|
480
|
-
const innerDecls = declsByFile.get(resolved)
|
|
481
|
-
if (!innerDecls || innerDecls.length === 0) continue
|
|
482
|
-
for (const inner of innerDecls) {
|
|
483
|
-
findings.push({
|
|
484
|
-
code: 'nested-island',
|
|
485
|
-
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).`,
|
|
486
|
-
location: outer.loc,
|
|
487
|
-
related: [inner.loc],
|
|
488
|
-
})
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function detectDeadIslands(
|
|
494
|
-
decls: IslandDecl[],
|
|
495
|
-
importedFiles: Set<string>,
|
|
496
|
-
findings: IslandFinding[],
|
|
497
|
-
): void {
|
|
498
|
-
for (const d of decls) {
|
|
499
|
-
if (importedFiles.has(d.loc.path)) continue
|
|
500
|
-
findings.push({
|
|
501
|
-
code: 'dead-island',
|
|
502
|
-
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.`,
|
|
503
|
-
location: d.loc,
|
|
504
|
-
})
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
509
|
-
// Public API
|
|
510
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
511
|
-
|
|
512
|
-
export function auditIslands(rootDir: string): IslandAuditResult {
|
|
513
|
-
const root = findMonorepoRoot(rootDir)
|
|
514
|
-
const findings: IslandFinding[] = []
|
|
515
|
-
const summary = {
|
|
516
|
-
filesScanned: 0,
|
|
517
|
-
islandsDeclared: 0,
|
|
518
|
-
registryEntries: 0,
|
|
519
|
-
findingsByCode: {
|
|
520
|
-
'never-with-registry-entry': 0,
|
|
521
|
-
'duplicate-name': 0,
|
|
522
|
-
'registry-mismatch': 0,
|
|
523
|
-
'nested-island': 0,
|
|
524
|
-
'dead-island': 0,
|
|
525
|
-
} as Record<IslandFindingCode, number>,
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (!root) return { root: null, findings, summary }
|
|
529
|
-
|
|
530
|
-
const files: string[] = []
|
|
531
|
-
walkSourceFiles(join(root, 'packages'), files)
|
|
532
|
-
walkSourceFiles(join(root, 'examples'), files)
|
|
533
|
-
summary.filesScanned = files.length
|
|
534
|
-
|
|
535
|
-
const declsByFile = new Map<string, IslandDecl[]>()
|
|
536
|
-
const allDecls: IslandDecl[] = []
|
|
537
|
-
const allRegistry: RegistryEntry[] = []
|
|
538
|
-
// Set of canonical absolute file paths that some other file imports
|
|
539
|
-
// (statically OR dynamically). Used by the dead-island detector to
|
|
540
|
-
// distinguish declared-but-orphaned islands from declared-and-wired-up
|
|
541
|
-
// ones. extractImports records resolve()'d relative specs WITHOUT
|
|
542
|
-
// extension-completion, so we re-canonicalize each entry through the
|
|
543
|
-
// file-resolution helper to land on the actual file path.
|
|
544
|
-
const resolvedImports = new Set<string>()
|
|
545
|
-
|
|
546
|
-
for (const file of files) {
|
|
547
|
-
const ex = extractFromFile(file, root)
|
|
548
|
-
if (ex.islands.length > 0) {
|
|
549
|
-
declsByFile.set(file, ex.islands)
|
|
550
|
-
allDecls.push(...ex.islands)
|
|
551
|
-
}
|
|
552
|
-
allRegistry.push(...ex.registryEntries)
|
|
553
|
-
for (const spec of ex.imports) {
|
|
554
|
-
// extractImports stores absolute paths for relative specs (already
|
|
555
|
-
// resolve()'d) and bare package names as-is. Bare specs never
|
|
556
|
-
// match an island file path, so skip them. For absolute paths,
|
|
557
|
-
// try ext / index completion to land on the canonical file the
|
|
558
|
-
// dead-island detector compares against.
|
|
559
|
-
if (!spec.startsWith('/')) continue
|
|
560
|
-
const resolved = resolveAbsToFile(spec)
|
|
561
|
-
if (resolved) resolvedImports.add(resolved)
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
summary.islandsDeclared = allDecls.length
|
|
566
|
-
summary.registryEntries = allRegistry.length
|
|
567
|
-
|
|
568
|
-
// Run detectors. Order is informational only — findings are sorted
|
|
569
|
-
// by (file, line) at the end for stable display.
|
|
570
|
-
detectDuplicateName(declsByFile, findings)
|
|
571
|
-
detectNeverWithRegistry(allDecls, allRegistry, findings)
|
|
572
|
-
detectRegistryMismatch(allDecls, allRegistry, findings)
|
|
573
|
-
detectNestedIsland(allDecls, declsByFile, findings)
|
|
574
|
-
detectDeadIslands(allDecls, resolvedImports, findings)
|
|
575
|
-
|
|
576
|
-
for (const f of findings) {
|
|
577
|
-
summary.findingsByCode[f.code] = (summary.findingsByCode[f.code] ?? 0) + 1
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
findings.sort((a, b) => {
|
|
581
|
-
const pathCmp = a.location.relPath.localeCompare(b.location.relPath)
|
|
582
|
-
if (pathCmp !== 0) return pathCmp
|
|
583
|
-
return a.location.line - b.location.line || a.location.column - b.location.column
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
return { root, findings, summary }
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
590
|
-
// Formatter
|
|
591
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
592
|
-
|
|
593
|
-
export interface IslandAuditFormatOptions {
|
|
594
|
-
/** When true, emit JSON instead of markdown-ish text. */
|
|
595
|
-
json?: boolean | undefined
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const CODE_HEADERS: Record<IslandFindingCode, string> = {
|
|
599
|
-
'never-with-registry-entry':
|
|
600
|
-
'Never-strategy island in client registry — defeats zero-JS strategy',
|
|
601
|
-
'duplicate-name': 'Duplicate island names — only the first hydrates',
|
|
602
|
-
'registry-mismatch': 'Registry references unknown island — runtime warns + skips',
|
|
603
|
-
'nested-island': 'Nested island — outer hydrateRoot replaces inner before its loader runs',
|
|
604
|
-
'dead-island': 'Declared but unused island — no other file imports its module',
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export function formatIslandAudit(
|
|
608
|
-
result: IslandAuditResult,
|
|
609
|
-
options: IslandAuditFormatOptions = {},
|
|
610
|
-
): string {
|
|
611
|
-
if (options.json) return JSON.stringify(result, null, 2)
|
|
612
|
-
|
|
613
|
-
if (!result.root) {
|
|
614
|
-
return (
|
|
615
|
-
'No monorepo root found. The islands audit walks `packages/` and `examples/` ' +
|
|
616
|
-
'starting from the cwd. Run `pyreon doctor --check-islands` from the Pyreon repo root.'
|
|
617
|
-
)
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const parts: string[] = []
|
|
621
|
-
parts.push(
|
|
622
|
-
`# Islands audit — ${result.summary.filesScanned} files scanned, ` +
|
|
623
|
-
`${result.summary.islandsDeclared} \`island()\` declaration${result.summary.islandsDeclared === 1 ? '' : 's'}, ` +
|
|
624
|
-
`${result.summary.registryEntries} \`hydrateIslands\` registry ` +
|
|
625
|
-
`entr${result.summary.registryEntries === 1 ? 'y' : 'ies'}`,
|
|
626
|
-
)
|
|
627
|
-
parts.push('')
|
|
628
|
-
|
|
629
|
-
if (result.findings.length === 0) {
|
|
630
|
-
parts.push('✓ No island findings. Project-wide cross-file checks are clean:')
|
|
631
|
-
parts.push(' - No duplicate names')
|
|
632
|
-
parts.push(' - No `hydrate: "never"` islands in any client registry')
|
|
633
|
-
parts.push(' - No registry entries pointing at undeclared names')
|
|
634
|
-
parts.push(' - No nested islands')
|
|
635
|
-
parts.push(' - No declared-but-unimported islands')
|
|
636
|
-
return parts.join('\n')
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
parts.push(
|
|
640
|
-
`Findings: ${result.findings.length} (` +
|
|
641
|
-
Object.entries(result.summary.findingsByCode)
|
|
642
|
-
.filter(([, n]) => n > 0)
|
|
643
|
-
.map(([code, n]) => `${code}: ${n}`)
|
|
644
|
-
.join(', ') +
|
|
645
|
-
')',
|
|
646
|
-
)
|
|
647
|
-
parts.push('')
|
|
648
|
-
|
|
649
|
-
// Group by code so the output reads like a per-finding-type catalog.
|
|
650
|
-
const byCode = new Map<IslandFindingCode, IslandFinding[]>()
|
|
651
|
-
for (const f of result.findings) {
|
|
652
|
-
const list = byCode.get(f.code) ?? []
|
|
653
|
-
list.push(f)
|
|
654
|
-
byCode.set(f.code, list)
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
for (const [code, list] of byCode) {
|
|
658
|
-
parts.push(`## ${code} — ${list.length} finding${list.length === 1 ? '' : 's'}`)
|
|
659
|
-
parts.push('')
|
|
660
|
-
parts.push(`> ${CODE_HEADERS[code]}`)
|
|
661
|
-
parts.push('')
|
|
662
|
-
for (const f of list) {
|
|
663
|
-
parts.push(` ${f.location.relPath}:${f.location.line}:${f.location.column}`)
|
|
664
|
-
parts.push(` ${f.message}`)
|
|
665
|
-
if (f.related && f.related.length > 0) {
|
|
666
|
-
for (const r of f.related) {
|
|
667
|
-
parts.push(` related: ${r.relPath}:${r.line}:${r.column}`)
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
parts.push('')
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return parts.join('\n')
|
|
675
|
-
}
|