@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.
@@ -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 = { sf, code, diagnostics: [] }
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
  }