@pyreon/compiler 0.15.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 +966 -21
- package/lib/types/index.d.ts +97 -2
- package/package.json +15 -5
- package/src/index.ts +17 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +22 -32
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +127 -1
- package/src/ssg-audit.ts +513 -0
- package/src/tests/detector-tag-consistency.test.ts +28 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/pyreon-intercept.test.ts +141 -0
- package/src/tests/ssg-audit.test.ts +402 -0
package/src/jsx.ts
CHANGED
|
@@ -29,31 +29,21 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { parseSync } from 'oxc-parser'
|
|
32
|
-
import { createRequire } from 'node:module'
|
|
33
|
-
import { fileURLToPath } from 'node:url'
|
|
34
|
-
import { dirname, join } from 'node:path'
|
|
35
32
|
import { REACT_EVENT_REMAP } from './event-names'
|
|
33
|
+
import { loadNativeBinding } from './load-native'
|
|
36
34
|
|
|
37
35
|
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
36
|
+
// Two-path resolution: in-tree binary first (dev mode), then per-platform
|
|
37
|
+
// npm package (production install via optionalDependencies). Falls through
|
|
38
|
+
// to the JS implementation below when both paths fail (wrong platform, CI
|
|
39
|
+
// environment, WASM runtime like StackBlitz, missing per-platform package).
|
|
41
40
|
//
|
|
42
|
-
//
|
|
43
|
-
// exist in ESM modules.
|
|
41
|
+
// See `load-native.ts` for the resolution logic.
|
|
44
42
|
type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const __dirname = dirname(__filename)
|
|
50
|
-
const nativeRequire = createRequire(import.meta.url)
|
|
51
|
-
const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
|
|
52
|
-
const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
|
|
53
|
-
nativeTransformJsx = native.transformJsx
|
|
54
|
-
} catch {
|
|
55
|
-
// Native binary not available — JS fallback will be used
|
|
56
|
-
}
|
|
43
|
+
const nativeBinding = loadNativeBinding(import.meta.url)
|
|
44
|
+
const nativeTransformJsx: NativeTransformFn | null = nativeBinding
|
|
45
|
+
? (nativeBinding.transformJsx as NativeTransformFn)
|
|
46
|
+
: null
|
|
57
47
|
|
|
58
48
|
export interface CompilerWarning {
|
|
59
49
|
/** Warning message */
|
|
@@ -774,19 +764,19 @@ export function transformJSX_JS(
|
|
|
774
764
|
if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
|
|
775
765
|
|
|
776
766
|
replacements.sort((a, b) => a.start - b.start)
|
|
777
|
-
const
|
|
778
|
-
let
|
|
767
|
+
const outParts: string[] = []
|
|
768
|
+
let outPos = 0
|
|
779
769
|
for (const r of replacements) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
770
|
+
outParts.push(code.slice(outPos, r.start))
|
|
771
|
+
outParts.push(r.text)
|
|
772
|
+
outPos = r.end
|
|
783
773
|
}
|
|
784
|
-
|
|
785
|
-
let
|
|
774
|
+
outParts.push(code.slice(outPos))
|
|
775
|
+
let output = outParts.join('')
|
|
786
776
|
|
|
787
777
|
if (hoists.length > 0) {
|
|
788
778
|
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
|
|
789
|
-
|
|
779
|
+
output = preamble + output
|
|
790
780
|
}
|
|
791
781
|
|
|
792
782
|
if (needsTplImport) {
|
|
@@ -798,16 +788,16 @@ export function transformJSX_JS(
|
|
|
798
788
|
const reactivityImports = needsBindImportGlobal
|
|
799
789
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
800
790
|
: ''
|
|
801
|
-
|
|
791
|
+
output =
|
|
802
792
|
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
803
|
-
|
|
793
|
+
output
|
|
804
794
|
}
|
|
805
795
|
|
|
806
796
|
if (needsRpImport) {
|
|
807
|
-
|
|
797
|
+
output = `import { _rp } from "@pyreon/core";\n` + output
|
|
808
798
|
}
|
|
809
799
|
|
|
810
|
-
return { code:
|
|
800
|
+
return { code: output, usesTemplates: needsTplImport, warnings }
|
|
811
801
|
|
|
812
802
|
// ── Template emission helpers ─────────────────────────────────────────────
|
|
813
803
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native binding loader — resolves the @pyreon/compiler napi-rs binary
|
|
3
|
+
* via two paths in priority order:
|
|
4
|
+
*
|
|
5
|
+
* 1. **In-tree binary** at `<package>/native/pyreon-compiler.node`.
|
|
6
|
+
* Populated by `scripts/build-native.ts` during local development
|
|
7
|
+
* (Phase 2). Faster path because it skips npm-package resolution.
|
|
8
|
+
*
|
|
9
|
+
* 2. **Per-platform npm package** (Phase 5b — not active until per-
|
|
10
|
+
* platform packages are published). Resolves `@pyreon/compiler-
|
|
11
|
+
* <platform>-<arch>[-<libc>]` via the standard Node module
|
|
12
|
+
* resolution algorithm. End users on machines without a local
|
|
13
|
+
* `cargo` install will hit this path: `bun install` resolves
|
|
14
|
+
* `optionalDependencies` to the matching per-platform package and
|
|
15
|
+
* this loader picks it up.
|
|
16
|
+
*
|
|
17
|
+
* 3. **JS fallback** (caller's responsibility) — if both paths fail,
|
|
18
|
+
* `loadNativeBinding()` returns `null` and the caller uses the
|
|
19
|
+
* pure-JS implementation. Slower but correctness-equivalent.
|
|
20
|
+
*
|
|
21
|
+
* Platform detection follows the napi-rs convention. Linux variants
|
|
22
|
+
* include a `libc` suffix (`gnu` for glibc, `musl` for musl) per
|
|
23
|
+
* https://napi.rs/docs/cli/build#deployment.
|
|
24
|
+
*
|
|
25
|
+
* The two-path resolution lets dev-mode (where `cargo build` produced
|
|
26
|
+
* an in-tree binary) and production-mode (where the user has only the
|
|
27
|
+
* published per-platform package) coexist with no flag flipping.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createRequire } from 'node:module'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { dirname, join } from 'node:path'
|
|
33
|
+
|
|
34
|
+
export interface NativeBinding {
|
|
35
|
+
transformJsx: (
|
|
36
|
+
code: string,
|
|
37
|
+
filename: string,
|
|
38
|
+
ssr: boolean,
|
|
39
|
+
knownSignals: string[] | null,
|
|
40
|
+
) => unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Local Node-process surface. `@pyreon/runtime-dom` ships an ambient
|
|
44
|
+
// `declare var process: { env: { NODE_ENV?: string } }` to enforce the
|
|
45
|
+
// bundler-agnostic dev-gate pattern, which narrows `process` for ANY
|
|
46
|
+
// file pulled in by runtime-dom's typecheck — including this one when
|
|
47
|
+
// imported via the `bun` condition. Casting through a local interface
|
|
48
|
+
// restores access to the platform/arch/report fields we genuinely need.
|
|
49
|
+
interface NodeProcess {
|
|
50
|
+
platform: string
|
|
51
|
+
arch: string
|
|
52
|
+
report?: {
|
|
53
|
+
getReport(): unknown
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const nodeProcess = process as unknown as NodeProcess
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the per-platform package name following the napi-rs naming
|
|
60
|
+
* convention: `@pyreon/compiler-<platform>-<arch>[-<libc>]`.
|
|
61
|
+
*
|
|
62
|
+
* Examples:
|
|
63
|
+
* darwin + arm64 → @pyreon/compiler-darwin-arm64
|
|
64
|
+
* darwin + x64 → @pyreon/compiler-darwin-x64
|
|
65
|
+
* linux + x64 + gnu → @pyreon/compiler-linux-x64-gnu
|
|
66
|
+
* linux + arm64 + gnu → @pyreon/compiler-linux-arm64-gnu
|
|
67
|
+
* win32 + x64 + msvc → @pyreon/compiler-win32-x64-msvc
|
|
68
|
+
*
|
|
69
|
+
* Returns `null` for unsupported (platform, arch) combinations — caller
|
|
70
|
+
* skips per-platform resolution entirely and falls through to JS.
|
|
71
|
+
*/
|
|
72
|
+
export function getPlatformPackageName(
|
|
73
|
+
platform: string = nodeProcess.platform,
|
|
74
|
+
arch: string = nodeProcess.arch,
|
|
75
|
+
libc: string | null = detectLibc(platform),
|
|
76
|
+
): string | null {
|
|
77
|
+
// Build the suffix for libc-bearing platforms (Linux glibc/musl,
|
|
78
|
+
// Windows MSVC). Single source of truth — no per-platform branching.
|
|
79
|
+
const suffix = libc ? `-${libc}` : ''
|
|
80
|
+
// Allowlist of (platform, arch) combos that the cross-platform CI
|
|
81
|
+
// workflow actually builds. Keep in sync with
|
|
82
|
+
// `.github/workflows/release-native.yml` matrix.
|
|
83
|
+
const supported: Record<string, string[]> = {
|
|
84
|
+
darwin: ['arm64', 'x64'],
|
|
85
|
+
linux: ['x64', 'arm64'],
|
|
86
|
+
win32: ['x64'],
|
|
87
|
+
}
|
|
88
|
+
if (!supported[platform]?.includes(arch)) return null
|
|
89
|
+
return `@pyreon/compiler-${platform}-${arch}${suffix}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect the libc family for the current Linux runtime. Returns:
|
|
94
|
+
* - `'gnu'` on glibc-based distros (Debian, Ubuntu, RHEL, …)
|
|
95
|
+
* - `'musl'` on musl-based distros (Alpine, …)
|
|
96
|
+
* - `null` on macOS / Windows (no libc differentiation)
|
|
97
|
+
* - `'msvc'` on Windows (we only ship MSVC binaries)
|
|
98
|
+
*
|
|
99
|
+
* `process.report.getReport().header.glibcVersionRuntime` is the
|
|
100
|
+
* Node-canonical detection: present on glibc, absent on musl. Falls
|
|
101
|
+
* back to `gnu` on read failure since glibc is the more common case.
|
|
102
|
+
*/
|
|
103
|
+
function detectLibc(platform: string): string | null {
|
|
104
|
+
if (platform === 'win32') return 'msvc'
|
|
105
|
+
if (platform !== 'linux') return null
|
|
106
|
+
try {
|
|
107
|
+
const report = nodeProcess.report?.getReport()
|
|
108
|
+
if (typeof report === 'object' && report !== null) {
|
|
109
|
+
const header = (report as { header?: { glibcVersionRuntime?: string } }).header
|
|
110
|
+
return header?.glibcVersionRuntime ? 'gnu' : 'musl'
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort detection — fall through to glibc default.
|
|
114
|
+
}
|
|
115
|
+
return 'gnu'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load the native binding by trying paths in order:
|
|
120
|
+
* 1. In-tree binary (`<package>/native/pyreon-compiler.node`)
|
|
121
|
+
* 2. Per-platform npm package (`@pyreon/compiler-<triple>`)
|
|
122
|
+
*
|
|
123
|
+
* Returns `null` if both paths fail — caller falls back to the
|
|
124
|
+
* pure-JS implementation. NEVER throws — every error path swallows
|
|
125
|
+
* silently because a missing native binary is a perf optimization
|
|
126
|
+
* miss, not a correctness failure.
|
|
127
|
+
*/
|
|
128
|
+
export function loadNativeBinding(metaUrl: string): NativeBinding | null {
|
|
129
|
+
const nativeRequire = createRequire(metaUrl)
|
|
130
|
+
|
|
131
|
+
// Path 1: in-tree binary (dev mode + Phase 2 local-build path).
|
|
132
|
+
try {
|
|
133
|
+
const __filename = fileURLToPath(metaUrl)
|
|
134
|
+
const __dirname = dirname(__filename)
|
|
135
|
+
const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
|
|
136
|
+
return nativeRequire(nativePath) as NativeBinding
|
|
137
|
+
} catch {
|
|
138
|
+
// In-tree binary not present — fall through to per-platform package.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Path 2: per-platform npm package (production install path).
|
|
142
|
+
// Will start working once Phase 5b publishes the per-platform
|
|
143
|
+
// packages and `optionalDependencies` resolves them at install time.
|
|
144
|
+
const pkgName = getPlatformPackageName()
|
|
145
|
+
if (pkgName !== null) {
|
|
146
|
+
try {
|
|
147
|
+
return nativeRequire(pkgName) as NativeBinding
|
|
148
|
+
} catch {
|
|
149
|
+
// Per-platform package not installed (typical pre-Phase-5b
|
|
150
|
+
// state, or a platform we don't yet ship binaries for).
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null
|
|
155
|
+
}
|
package/src/pyreon-intercept.ts
CHANGED
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
42
42
|
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
43
43
|
* is already assignable to `VNodeChild`).
|
|
44
|
+
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
45
|
+
* `hydrate: 'never'` is also registered in the same
|
|
46
|
+
* file's `hydrateIslands({ ... })` call. The whole
|
|
47
|
+
* point of `'never'` is shipping zero client JS;
|
|
48
|
+
* registering pulls the component module into the
|
|
49
|
+
* client bundle graph (the runtime short-circuits
|
|
50
|
+
* and never calls the loader, but the bundler still
|
|
51
|
+
* includes the import). Drop the registry entry.
|
|
44
52
|
*
|
|
45
53
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
46
54
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -81,6 +89,7 @@ export type PyreonDiagnosticCode =
|
|
|
81
89
|
| 'signal-write-as-call'
|
|
82
90
|
| 'static-return-null-conditional'
|
|
83
91
|
| 'as-unknown-as-vnodechild'
|
|
92
|
+
| 'island-never-with-registry-entry'
|
|
84
93
|
|
|
85
94
|
export interface PyreonDiagnostic {
|
|
86
95
|
/** Machine-readable code for filtering + programmatic handling */
|
|
@@ -114,6 +123,21 @@ interface DetectContext {
|
|
|
114
123
|
* patterns that should be `sig.set(value)`.
|
|
115
124
|
*/
|
|
116
125
|
signalBindings: Set<string>
|
|
126
|
+
/**
|
|
127
|
+
* Names of `island()` declarations carrying `hydrate: 'never'`. Populated
|
|
128
|
+
* by `collectNeverIslandNames()` before the main detection walk. Used by
|
|
129
|
+
* `detectIslandNeverWithRegistry` to flag entries in
|
|
130
|
+
* `hydrateIslands({ ... })` whose key matches a never-strategy island.
|
|
131
|
+
*
|
|
132
|
+
* Cross-call detection: the never-vs-registry mismatch is only catchable
|
|
133
|
+
* when both sides live in the same source. In real apps the `island()`
|
|
134
|
+
* declarations sit in `src/islands.ts` and the `hydrateIslands()` call
|
|
135
|
+
* sits in `src/entry-client.ts`. The static detector covers the common
|
|
136
|
+
* "all in one file" case (which catches the bug while users are first
|
|
137
|
+
* learning the API); the cross-file case is the territory of `pyreon
|
|
138
|
+
* doctor --check-islands` (separate PR / future scope).
|
|
139
|
+
*/
|
|
140
|
+
neverIslandNames: Set<string>
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
function getNodeText(ctx: DetectContext, node: ts.Node): string {
|
|
@@ -647,6 +671,101 @@ function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression):
|
|
|
647
671
|
)
|
|
648
672
|
}
|
|
649
673
|
|
|
674
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
675
|
+
// Island never-with-registry detection
|
|
676
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
|
|
680
|
+
* call expressions and collect the `name` field of each never-strategy island.
|
|
681
|
+
*
|
|
682
|
+
* Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
|
|
683
|
+
*
|
|
684
|
+
* island(() => import('./X'), { name: 'X', hydrate: 'never' })
|
|
685
|
+
*
|
|
686
|
+
* Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
|
|
687
|
+
* fall through and don't populate the set — false-negatives, not false
|
|
688
|
+
* positives):
|
|
689
|
+
*
|
|
690
|
+
* - Loader is a variable, not an inline arrow
|
|
691
|
+
* - Name is a variable / template / spread, not a string literal
|
|
692
|
+
* - Options come from a spread (`island(loader, opts)`)
|
|
693
|
+
*
|
|
694
|
+
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
695
|
+
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
696
|
+
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
697
|
+
*/
|
|
698
|
+
function collectNeverIslandNames(sf: ts.SourceFile): Set<string> {
|
|
699
|
+
const names = new Set<string>()
|
|
700
|
+
function walk(node: ts.Node): void {
|
|
701
|
+
if (
|
|
702
|
+
ts.isCallExpression(node) &&
|
|
703
|
+
ts.isIdentifier(node.expression) &&
|
|
704
|
+
node.expression.text === 'island' &&
|
|
705
|
+
node.arguments.length >= 2
|
|
706
|
+
) {
|
|
707
|
+
const opts = node.arguments[1]
|
|
708
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
709
|
+
let nameVal: string | undefined
|
|
710
|
+
let hydrateVal: string | undefined
|
|
711
|
+
for (const prop of opts.properties) {
|
|
712
|
+
if (!ts.isPropertyAssignment(prop)) continue
|
|
713
|
+
const key = prop.name
|
|
714
|
+
const keyText = ts.isIdentifier(key)
|
|
715
|
+
? key.text
|
|
716
|
+
: ts.isStringLiteral(key)
|
|
717
|
+
? key.text
|
|
718
|
+
: ''
|
|
719
|
+
if (keyText === 'name' && ts.isStringLiteral(prop.initializer)) {
|
|
720
|
+
nameVal = prop.initializer.text
|
|
721
|
+
} else if (keyText === 'hydrate' && ts.isStringLiteral(prop.initializer)) {
|
|
722
|
+
hydrateVal = prop.initializer.text
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (nameVal && hydrateVal === 'never') {
|
|
726
|
+
names.add(nameVal)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
ts.forEachChild(node, walk)
|
|
731
|
+
}
|
|
732
|
+
walk(sf)
|
|
733
|
+
return names
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
|
|
738
|
+
* key matches an `island()` name declared with `hydrate: 'never'` in the
|
|
739
|
+
* same file. Each matching entry produces one diagnostic at the property's
|
|
740
|
+
* location so the IDE highlights exactly which key needs to go.
|
|
741
|
+
*/
|
|
742
|
+
function detectIslandNeverWithRegistry(ctx: DetectContext, node: ts.CallExpression): void {
|
|
743
|
+
if (ctx.neverIslandNames.size === 0) return
|
|
744
|
+
const callee = node.expression
|
|
745
|
+
if (!ts.isIdentifier(callee) || callee.text !== 'hydrateIslands') return
|
|
746
|
+
const arg = node.arguments[0]
|
|
747
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return
|
|
748
|
+
for (const prop of arg.properties) {
|
|
749
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue
|
|
750
|
+
const key = prop.name
|
|
751
|
+
const keyText = ts.isIdentifier(key)
|
|
752
|
+
? key.text
|
|
753
|
+
: ts.isStringLiteral(key)
|
|
754
|
+
? key.text
|
|
755
|
+
: ''
|
|
756
|
+
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue
|
|
757
|
+
pushDiag(
|
|
758
|
+
ctx,
|
|
759
|
+
prop,
|
|
760
|
+
'island-never-with-registry-entry',
|
|
761
|
+
`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.`,
|
|
762
|
+
getNodeText(ctx, prop),
|
|
763
|
+
`// remove the "${keyText}" entry — never-strategy islands need no registry entry`,
|
|
764
|
+
false,
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
650
769
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
651
770
|
// Visitor
|
|
652
771
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -674,6 +793,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
674
793
|
detectEmptyTheme(ctx, node)
|
|
675
794
|
detectRawEventListener(ctx, node)
|
|
676
795
|
detectSignalWriteAsCall(ctx, node)
|
|
796
|
+
detectIslandNeverWithRegistry(ctx, node)
|
|
677
797
|
}
|
|
678
798
|
if (ts.isJsxAttribute(node)) {
|
|
679
799
|
detectOnClickUndefined(ctx, node)
|
|
@@ -701,6 +821,7 @@ export function detectPyreonPatterns(code: string, filename = 'input.tsx'): Pyre
|
|
|
701
821
|
code,
|
|
702
822
|
diagnostics: [],
|
|
703
823
|
signalBindings: collectSignalBindings(sf),
|
|
824
|
+
neverIslandNames: collectNeverIslandNames(sf),
|
|
704
825
|
}
|
|
705
826
|
visit(ctx, sf)
|
|
706
827
|
// Sort by (line, column) for stable ordering when multiple patterns fire.
|
|
@@ -723,6 +844,11 @@ export function hasPyreonPatterns(code: string): boolean {
|
|
|
723
844
|
// static-return-null-conditional: `if (...) return null` anywhere
|
|
724
845
|
/\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
|
|
725
846
|
// as-unknown-as-vnodechild
|
|
726
|
-
/\bas\s+unknown\s+as\s+VNodeChild\b/.test(code)
|
|
847
|
+
/\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
|
|
848
|
+
// island-never-with-registry-entry: a never-strategy declaration AND a
|
|
849
|
+
// hydrateIslands call must both appear in the same source for the bug
|
|
850
|
+
// shape to trigger. Pre-filter on EITHER half — the AST walker fast-
|
|
851
|
+
// exits when the never-island set is empty.
|
|
852
|
+
(/\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code))
|
|
727
853
|
)
|
|
728
854
|
}
|