@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/src/pyreon-intercept.ts
CHANGED
|
@@ -29,6 +29,26 @@
|
|
|
29
29
|
* monotonic counter.
|
|
30
30
|
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
31
31
|
* used to crash on this pattern. Omit the prop.
|
|
32
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
33
|
+
* its argument; the runtime warns in dev. Static
|
|
34
|
+
* detector spots it pre-runtime when `sig` was
|
|
35
|
+
* declared as `const sig = signal(...)` /
|
|
36
|
+
* `computed(...)` and called with ≥1 argument.
|
|
37
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
38
|
+
* top of a component body runs ONCE; signal changes
|
|
39
|
+
* in `cond` never re-evaluate the early-return.
|
|
40
|
+
* Wrap in a returned reactive accessor.
|
|
41
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
42
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
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.
|
|
32
52
|
*
|
|
33
53
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
34
54
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -66,6 +86,10 @@ export type PyreonDiagnosticCode =
|
|
|
66
86
|
| 'raw-remove-event-listener'
|
|
67
87
|
| 'date-math-random-id'
|
|
68
88
|
| 'on-click-undefined'
|
|
89
|
+
| 'signal-write-as-call'
|
|
90
|
+
| 'static-return-null-conditional'
|
|
91
|
+
| 'as-unknown-as-vnodechild'
|
|
92
|
+
| 'island-never-with-registry-entry'
|
|
69
93
|
|
|
70
94
|
export interface PyreonDiagnostic {
|
|
71
95
|
/** Machine-readable code for filtering + programmatic handling */
|
|
@@ -92,6 +116,28 @@ interface DetectContext {
|
|
|
92
116
|
sf: ts.SourceFile
|
|
93
117
|
code: string
|
|
94
118
|
diagnostics: PyreonDiagnostic[]
|
|
119
|
+
/**
|
|
120
|
+
* Identifiers bound to `signal(...)` or `computed(...)` calls anywhere in
|
|
121
|
+
* the file. Populated by `collectSignalBindings()` before the main
|
|
122
|
+
* detection walk. Used by `detectSignalWriteAsCall` to flag `sig(value)`
|
|
123
|
+
* patterns that should be `sig.set(value)`.
|
|
124
|
+
*/
|
|
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>
|
|
95
141
|
}
|
|
96
142
|
|
|
97
143
|
function getNodeText(ctx: DetectContext, node: ts.Node): string {
|
|
@@ -439,6 +485,287 @@ function detectOnClickUndefined(ctx: DetectContext, node: ts.JsxAttribute): void
|
|
|
439
485
|
)
|
|
440
486
|
}
|
|
441
487
|
|
|
488
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
489
|
+
// Pattern: signal-write-as-call (sig(value) instead of sig.set(value))
|
|
490
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
494
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
495
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
496
|
+
* reliable signal-write.
|
|
497
|
+
*
|
|
498
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
499
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
500
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
501
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
502
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
503
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
504
|
+
*/
|
|
505
|
+
function collectSignalBindings(sf: ts.SourceFile): Set<string> {
|
|
506
|
+
const names = new Set<string>()
|
|
507
|
+
function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
|
|
508
|
+
if (!init || !ts.isCallExpression(init)) return false
|
|
509
|
+
const callee = init.expression
|
|
510
|
+
if (!ts.isIdentifier(callee)) return false
|
|
511
|
+
return callee.text === 'signal' || callee.text === 'computed'
|
|
512
|
+
}
|
|
513
|
+
function walk(node: ts.Node): void {
|
|
514
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
515
|
+
// Only `const` — find the parent VariableDeclarationList to check.
|
|
516
|
+
const list = node.parent
|
|
517
|
+
if (
|
|
518
|
+
ts.isVariableDeclarationList(list) &&
|
|
519
|
+
(list.flags & ts.NodeFlags.Const) !== 0 &&
|
|
520
|
+
isSignalFactoryCall(node.initializer)
|
|
521
|
+
) {
|
|
522
|
+
names.add(node.name.text)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
ts.forEachChild(node, walk)
|
|
526
|
+
}
|
|
527
|
+
walk(sf)
|
|
528
|
+
return names
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function detectSignalWriteAsCall(ctx: DetectContext, node: ts.CallExpression): void {
|
|
532
|
+
if (ctx.signalBindings.size === 0) return
|
|
533
|
+
const callee = node.expression
|
|
534
|
+
if (!ts.isIdentifier(callee)) return
|
|
535
|
+
if (!ctx.signalBindings.has(callee.text)) return
|
|
536
|
+
// `sig()` (zero args) is a READ — that's the intended Pyreon API.
|
|
537
|
+
if (node.arguments.length === 0) return
|
|
538
|
+
// `sig.set(x)` / `sig.update(fn)` / `sig.peek()` — the proper write/read
|
|
539
|
+
// surface — go through PropertyAccess, not direct CallExpression on the
|
|
540
|
+
// identifier. So if we got here, the call is `sig(value)` or
|
|
541
|
+
// `sig(value, ..)` which is the buggy shape.
|
|
542
|
+
pushDiag(
|
|
543
|
+
ctx,
|
|
544
|
+
node,
|
|
545
|
+
'signal-write-as-call',
|
|
546
|
+
`\`${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.`,
|
|
547
|
+
getNodeText(ctx, node),
|
|
548
|
+
`${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(', ')})`,
|
|
549
|
+
false,
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
554
|
+
// Pattern: static-return-null-conditional in component bodies
|
|
555
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
559
|
+
* components mount and never re-execute their function bodies. A signal
|
|
560
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
561
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
562
|
+
*
|
|
563
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
564
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
565
|
+
*
|
|
566
|
+
* Detection:
|
|
567
|
+
* - The function contains JSX (i.e. it's a component)
|
|
568
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
569
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
570
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
571
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
572
|
+
* false positive)
|
|
573
|
+
*/
|
|
574
|
+
function returnsNullStatement(stmt: ts.Statement): boolean {
|
|
575
|
+
if (ts.isReturnStatement(stmt)) {
|
|
576
|
+
const expr = stmt.expression
|
|
577
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword
|
|
578
|
+
}
|
|
579
|
+
if (ts.isBlock(stmt)) {
|
|
580
|
+
return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]!)
|
|
581
|
+
}
|
|
582
|
+
return false
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Returns true if the function looks like a top-level component:
|
|
587
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
588
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
589
|
+
*
|
|
590
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
591
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
592
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
593
|
+
* this filter the detector would fire on the very pattern the
|
|
594
|
+
* diagnostic recommends as the fix.
|
|
595
|
+
*/
|
|
596
|
+
function isComponentShapedFunction(
|
|
597
|
+
node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
|
|
598
|
+
): boolean {
|
|
599
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
600
|
+
return !!node.name && /^[A-Z]/.test(node.name.text)
|
|
601
|
+
}
|
|
602
|
+
// Arrow / FunctionExpression: check VariableDeclaration parent.
|
|
603
|
+
const parent = node.parent
|
|
604
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
605
|
+
return /^[A-Z]/.test(parent.name.text)
|
|
606
|
+
}
|
|
607
|
+
return false
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function detectStaticReturnNullConditional(
|
|
611
|
+
ctx: DetectContext,
|
|
612
|
+
node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
|
|
613
|
+
): void {
|
|
614
|
+
// Only component-shaped functions (must render JSX AND be named with
|
|
615
|
+
// PascalCase) — see isComponentShapedFunction for why the name check
|
|
616
|
+
// matters: it filters out the reactive-accessor-as-fix pattern.
|
|
617
|
+
if (!isComponentShapedFunction(node)) return
|
|
618
|
+
if (!containsJsx(node)) return
|
|
619
|
+
const body = node.body
|
|
620
|
+
if (!body || !ts.isBlock(body)) return
|
|
621
|
+
|
|
622
|
+
for (const stmt of body.statements) {
|
|
623
|
+
if (!ts.isIfStatement(stmt)) continue
|
|
624
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue
|
|
625
|
+
// Found `if (cond) return null` at top-level component body scope.
|
|
626
|
+
pushDiag(
|
|
627
|
+
ctx,
|
|
628
|
+
stmt,
|
|
629
|
+
'static-return-null-conditional',
|
|
630
|
+
'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.',
|
|
631
|
+
getNodeText(ctx, stmt),
|
|
632
|
+
'return (() => { if (!cond()) return null; return <JSX /> })',
|
|
633
|
+
false,
|
|
634
|
+
)
|
|
635
|
+
// Only flag the FIRST occurrence per component to avoid noise on
|
|
636
|
+
// chained early-returns (often a single mistake, not three).
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
642
|
+
// Pattern: `expr as unknown as VNodeChild`
|
|
643
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
647
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
648
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
649
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
650
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
651
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
652
|
+
*/
|
|
653
|
+
function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression): void {
|
|
654
|
+
// Outer cast: `... as VNodeChild`
|
|
655
|
+
const outerType = node.type
|
|
656
|
+
if (!ts.isTypeReferenceNode(outerType)) return
|
|
657
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== 'VNodeChild') return
|
|
658
|
+
// Inner: `<expr> as unknown`
|
|
659
|
+
const inner = node.expression
|
|
660
|
+
if (!ts.isAsExpression(inner)) return
|
|
661
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return
|
|
662
|
+
|
|
663
|
+
pushDiag(
|
|
664
|
+
ctx,
|
|
665
|
+
node,
|
|
666
|
+
'as-unknown-as-vnodechild',
|
|
667
|
+
'`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.',
|
|
668
|
+
getNodeText(ctx, node),
|
|
669
|
+
getNodeText(ctx, inner.expression),
|
|
670
|
+
false,
|
|
671
|
+
)
|
|
672
|
+
}
|
|
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
|
+
|
|
442
769
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
443
770
|
// Visitor
|
|
444
771
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -453,6 +780,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
453
780
|
ts.isFunctionExpression(node)
|
|
454
781
|
) {
|
|
455
782
|
detectPropsDestructured(ctx, node)
|
|
783
|
+
detectStaticReturnNullConditional(ctx, node)
|
|
456
784
|
}
|
|
457
785
|
if (ts.isBinaryExpression(node)) {
|
|
458
786
|
detectProcessDevGate(ctx, node)
|
|
@@ -464,10 +792,15 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
464
792
|
if (ts.isCallExpression(node)) {
|
|
465
793
|
detectEmptyTheme(ctx, node)
|
|
466
794
|
detectRawEventListener(ctx, node)
|
|
795
|
+
detectSignalWriteAsCall(ctx, node)
|
|
796
|
+
detectIslandNeverWithRegistry(ctx, node)
|
|
467
797
|
}
|
|
468
798
|
if (ts.isJsxAttribute(node)) {
|
|
469
799
|
detectOnClickUndefined(ctx, node)
|
|
470
800
|
}
|
|
801
|
+
if (ts.isAsExpression(node)) {
|
|
802
|
+
detectAsUnknownAsVNodeChild(ctx, node)
|
|
803
|
+
}
|
|
471
804
|
}
|
|
472
805
|
|
|
473
806
|
function visit(ctx: DetectContext, node: ts.Node): void {
|
|
@@ -483,7 +816,13 @@ function visit(ctx: DetectContext, node: ts.Node): void {
|
|
|
483
816
|
|
|
484
817
|
export function detectPyreonPatterns(code: string, filename = 'input.tsx'): PyreonDiagnostic[] {
|
|
485
818
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
486
|
-
const ctx: DetectContext = {
|
|
819
|
+
const ctx: DetectContext = {
|
|
820
|
+
sf,
|
|
821
|
+
code,
|
|
822
|
+
diagnostics: [],
|
|
823
|
+
signalBindings: collectSignalBindings(sf),
|
|
824
|
+
neverIslandNames: collectNeverIslandNames(sf),
|
|
825
|
+
}
|
|
487
826
|
visit(ctx, sf)
|
|
488
827
|
// Sort by (line, column) for stable ordering when multiple patterns fire.
|
|
489
828
|
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
@@ -499,6 +838,17 @@ export function hasPyreonPatterns(code: string): boolean {
|
|
|
499
838
|
/\b(?:add|remove)EventListener\s*\(/.test(code) ||
|
|
500
839
|
(/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
|
|
501
840
|
/on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
|
|
502
|
-
/=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code)
|
|
841
|
+
/=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
|
|
842
|
+
// signal-write-as-call: `const X = signal(` declaration anywhere
|
|
843
|
+
/\b(?:signal|computed)\s*[<(]/.test(code) ||
|
|
844
|
+
// static-return-null-conditional: `if (...) return null` anywhere
|
|
845
|
+
/\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
|
|
846
|
+
// as-unknown-as-vnodechild
|
|
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))
|
|
503
853
|
)
|
|
504
854
|
}
|