@pyreon/compiler 0.23.0 → 0.24.1

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/src/jsx.ts CHANGED
@@ -404,6 +404,35 @@ export function scanCollapsibleSites(
404
404
  childrenText: site.childrenText,
405
405
  key: rocketstyleCollapseKey(tag, site.props, site.childrenText),
406
406
  })
407
+ } else {
408
+ // Dynamic-prop fallthrough: if the full detector bailed but
409
+ // the site matches the ternary-of-two-literals shape, expand
410
+ // into TWO CollapsibleSite entries — one per literal value.
411
+ // Each expanded site is byte-identical to a static-collapse
412
+ // site for that value, so the resolver pre-renders both via
413
+ // the existing SSR pipeline and the compiler emit looks up
414
+ // both by their respective keys to build the dispatcher.
415
+ //
416
+ // No-handler sites route to `__rsCollapseDyn`; handler-bearing
417
+ // sites route to `__rsCollapseDynH` (handlers are orthogonal
418
+ // to the SSR-resolved styler class — see `tryDynamicCollapse`
419
+ // in this file). The scan does NOT distinguish here because
420
+ // the resolver only cares about (componentName, props, text);
421
+ // handlers don't affect the resolution.
422
+ const dyn = detectDynamicCollapsibleShape(node, tag)
423
+ if (dyn) {
424
+ for (const value of [dyn.dynamicProp.valueTruthy, dyn.dynamicProp.valueFalsy]) {
425
+ const expandedProps = { ...dyn.props, [dyn.dynamicProp.name]: value }
426
+ out.push({
427
+ componentName: tag,
428
+ source: imp.source,
429
+ importedName: imp.imported,
430
+ props: expandedProps,
431
+ childrenText: dyn.childrenText,
432
+ key: rocketstyleCollapseKey(tag, expandedProps, dyn.childrenText),
433
+ })
434
+ }
435
+ }
407
436
  }
408
437
  }
409
438
  }
@@ -527,6 +556,158 @@ export function detectPartialCollapsibleShape(
527
556
  return { props, childrenText: childrenText.trim(), handlers }
528
557
  }
529
558
 
559
+ /**
560
+ * A dynamic dimension prop on a collapsible call site. A ConditionalExpression
561
+ * (ternary) where both branches are string literals — `state={cond ? 'a' : 'b'}`
562
+ * is the canonical shape. Pre-resolution: the prop's value belongs to the
563
+ * enumerable set `[valueA, valueB]`. The compiler emits one collapsed
564
+ * variant per literal value + a dispatcher on the original `cond`.
565
+ */
566
+ export interface DynamicCollapsibleProp {
567
+ /** JSX attribute name, e.g. `state`. */
568
+ name: string
569
+ /** Source span of the ternary condition (the `cond` part), re-emitted into the runtime dispatcher. */
570
+ condStart: number
571
+ condEnd: number
572
+ /** Literal value for the `cond === truthy` branch (consequent). */
573
+ valueTruthy: string
574
+ /** Literal value for the `cond === falsy` branch (alternate). */
575
+ valueFalsy: string
576
+ }
577
+
578
+ /**
579
+ * Dynamic-prop partial-collapse detector — PR 2 of the dynamic-prop
580
+ * partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1
581
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the next-bigger
582
+ * bite after the `on*`-handler partial-collapse).
583
+ *
584
+ * Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
585
+ * with ONE relaxation" pattern (see that detector's docstring + `PR 1`
586
+ * `_rsCollapseDyn` runtime helper, PR #765). The single relaxation: a
587
+ * `JSXExpressionContainer` wrapping a `ConditionalExpression` whose
588
+ * `consequent` AND `alternate` are BOTH `StringLiteral` is acceptable as
589
+ * a "ternary-of-two-literals" dynamic prop — captured as a {@link DynamicCollapsibleProp}
590
+ * with the cond source span + the two literal values.
591
+ *
592
+ * Constraint: **AT MOST ONE** such dynamic prop per site. Multiple
593
+ * ternaries would compound into a 2^N value-set per site at build time
594
+ * and an N-axis dispatcher at runtime — that's a separable scope
595
+ * (potential PR 5+), NOT this PR. Sites with 2+ ternaries bail (return
596
+ * null), keeping the normal mount; same conservative shape as the rest
597
+ * of the detector family.
598
+ *
599
+ * Constraint: the FULL `on*`-handler relaxation is also folded in — a
600
+ * site can have ONE ternary AND `on*` handlers in the same call. This
601
+ * matches the real-corpus shape (a Button with `state={cond ? 'a' : 'b'}`
602
+ * almost always also has an `onClick`). The two relaxations compose
603
+ * cleanly because they're orthogonal at the resolver layer (handlers
604
+ * don't change rendered CSS; the ternary picks among pre-resolved
605
+ * classes). PR 3's emit will use `_rsCollapseDyn` when handlers are
606
+ * absent and a future combined helper when both are present — for THIS
607
+ * PR (detector-only) the structure carries both so PR 3 can dispatch.
608
+ *
609
+ * Every OTHER non-literal shape still bails (spread, non-handler
610
+ * non-ternary `{expr}` prop, multi-literal ternary anywhere, computed-
611
+ * expression ternary, element/expression child, boolean attr) —
612
+ * conservative by construction, exactly like the rest of the family.
613
+ * Returns `null` when there are ZERO ternaries so the on*-only path
614
+ * (`detectPartialCollapsibleShape`) and the full-collapse path
615
+ * (`detectCollapsibleShape`) stay byte-unchanged and no detector both
616
+ * claims the same site.
617
+ *
618
+ * A consistency test (PR 3) will lock this catalogue against the
619
+ * plugin scan, mirroring the `detectCollapsibleShape` ↔ `scanCollapsibleSites`
620
+ * + `detectPartialCollapsibleShape` ↔ scan invariants — keys cannot drift.
621
+ */
622
+ export function detectDynamicCollapsibleShape(
623
+ node: N,
624
+ _tag: string,
625
+ ): {
626
+ props: Record<string, string>
627
+ childrenText: string
628
+ handlers: CollapsibleHandler[]
629
+ dynamicProp: DynamicCollapsibleProp
630
+ } | null {
631
+ const props: Record<string, string> = {}
632
+ const handlers: CollapsibleHandler[] = []
633
+ const dynamicProps: DynamicCollapsibleProp[] = []
634
+ for (const attr of jsxAttrs(node)) {
635
+ if (attr.type !== 'JSXAttribute') return null // spread → bail
636
+ const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
637
+ if (!nm) return null
638
+ const v = attr.value
639
+ if (!v) return null // boolean attr → bail
640
+ const isStr =
641
+ v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
642
+ if (isStr) {
643
+ props[nm] = String(v.value)
644
+ continue
645
+ }
646
+ // Non-literal in a `{expr}` container — three possible relaxations:
647
+ // (a) `on[A-Z]…` handler with any expression → peeled
648
+ // (b) any other prop whose expression is a ternary of two string
649
+ // literals → peeled as a DynamicCollapsibleProp
650
+ // (c) anything else → bail
651
+ if (
652
+ v.type === 'JSXExpressionContainer' &&
653
+ v.expression &&
654
+ typeof v.expression.start === 'number' &&
655
+ typeof v.expression.end === 'number'
656
+ ) {
657
+ if (/^on[A-Z]/.test(nm)) {
658
+ handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
659
+ continue
660
+ }
661
+ const expr = v.expression
662
+ if (
663
+ expr.type === 'ConditionalExpression' &&
664
+ expr.test &&
665
+ typeof expr.test.start === 'number' &&
666
+ typeof expr.test.end === 'number' &&
667
+ expr.consequent &&
668
+ expr.alternate
669
+ ) {
670
+ // Both branches must be StringLiteral. We deliberately do NOT
671
+ // accept TemplateLiteral / `as`-casted literals / any other
672
+ // shape — keep the static-resolvable set narrow + provable.
673
+ const isLitStr = (n: unknown): n is { type: 'StringLiteral'; value: string } => {
674
+ const x = n as { type?: string; value?: unknown }
675
+ return (
676
+ x?.type === 'StringLiteral' ||
677
+ (x?.type === 'Literal' && typeof x.value === 'string')
678
+ )
679
+ }
680
+ if (isLitStr(expr.consequent) && isLitStr(expr.alternate)) {
681
+ dynamicProps.push({
682
+ name: nm,
683
+ condStart: expr.test.start,
684
+ condEnd: expr.test.end,
685
+ valueTruthy: String((expr.consequent as { value: string }).value),
686
+ valueFalsy: String((expr.alternate as { value: string }).value),
687
+ })
688
+ continue
689
+ }
690
+ }
691
+ }
692
+ return null // `{expr}` non-handler non-ternary, or non-literal ternary branch → bail
693
+ }
694
+ let childrenText = ''
695
+ for (const c of jsxChildren(node)) {
696
+ if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
697
+ else return null // element / expression child → bail
698
+ }
699
+ // Exactly ONE dynamic prop is the scope of this PR. Zero ⇒ defer to
700
+ // the existing detectors (full / on*-handler partial); 2+ ⇒ bail
701
+ // (multi-axis combinatorics is a separable scope).
702
+ if (dynamicProps.length !== 1) return null
703
+ return {
704
+ props,
705
+ childrenText: childrenText.trim(),
706
+ handlers,
707
+ dynamicProp: dynamicProps[0]!,
708
+ }
709
+ }
710
+
530
711
  // ─── Main transform ─────────────────────────────────────────────────────────
531
712
 
532
713
  export function transformJSX(
@@ -665,6 +846,8 @@ export function transformJSX_JS(
665
846
  // ── P0 rocketstyle-collapse state ─────────────────────────────────────────
666
847
  let needsCollapse = false
667
848
  let needsCollapseH = false
849
+ let needsCollapseDyn = false
850
+ let needsCollapseDynH = false
668
851
  const collapseRuleKeys = new Set<string>()
669
852
  const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
670
853
 
@@ -691,7 +874,11 @@ export function transformJSX_JS(
691
874
  // plugin scans with the same predicate, so its resolved `sites`
692
875
  // keys match these lookups exactly; no drift possible).
693
876
  const shape = detectCollapsibleShape(node, tag)
694
- if (!shape) return tryPartialCollapse(node, tag) // PR 3: on*-handler-only fallback
877
+ // Fallthrough chain same conservative discipline at each layer:
878
+ // 1. on*-handler-only partial (literal dim props + handlers)
879
+ // 2. dynamic-prop partial (ternary-of-two-literals on ≤1 dim prop,
880
+ // no handlers — handler-combined dynamic is a future PR's scope)
881
+ if (!shape) return tryPartialCollapse(node, tag) || tryDynamicCollapse(node, tag)
695
882
  const { props, childrenText } = shape
696
883
  const key = rocketstyleCollapseKey(tag, props, childrenText)
697
884
  const site = cfg.sites.get(key)
@@ -765,6 +952,127 @@ export function transformJSX_JS(
765
952
  return true
766
953
  }
767
954
 
955
+ /**
956
+ * PR 3 of the dynamic-prop partial-collapse build (open-work #1
957
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the
958
+ * next-bigger bite after the just-shipped `on*`-handler partial).
959
+ * The dynamic-prop fallback `tryRocketstyleCollapse` defers to when
960
+ * BOTH the full and the on*-handler-partial paths bail.
961
+ *
962
+ * Same site-resolution contract as the full path — the dynamic prop
963
+ * is replaced with EACH literal value to compute TWO keys; the
964
+ * resolver pre-renders both via the existing SSR pipeline; if both
965
+ * lookups succeed AND the structural template is byte-identical
966
+ * across values, emit `__rsCollapseDyn(html, [classes...], () =>
967
+ * cond ? 0 : 1, () => __pyrMode() === "dark")` — the PR 1 runtime
968
+ * helper (#765) dispatches across `(value × mode)` with a stride-2
969
+ * value-major class layout.
970
+ *
971
+ * Conservative discipline:
972
+ * - Either expanded key missing from sites map ⇒ bail (an
973
+ * intermittent resolver failure on one value mustn't half-collapse)
974
+ * - Divergent template HTML across values ⇒ bail (the dispatcher
975
+ * assumes a shared template; deriveCollapseDyn cannot be done
976
+ * across values that produce structurally different markup —
977
+ * this is the cross-value parallel of `deriveCollapse`'s
978
+ * light↔dark template-divergence bail)
979
+ *
980
+ * Handler-combined sites: when the detected dynamic site has `on*`
981
+ * handlers (the most common real-corpus shape — bail-census measured
982
+ * the no-handler subset at 0.2% of all sites; handler-combined is
983
+ * the bulk of the 15.4% dynamic-prop bucket), emit
984
+ * `__rsCollapseDynH(...)` (PR A: runtime helper) instead of
985
+ * `__rsCollapseDyn(...)`. Handlers are orthogonal to the SSR-
986
+ * resolved styler class (the resolver pre-renders both values
987
+ * identically regardless of handlers); the union helper just
988
+ * re-attaches them through the same canonical `_bindEvent` path
989
+ * `tryPartialCollapse` uses.
990
+ *
991
+ * Rule injection unions the rule sets across both values (each value
992
+ * may inject distinct CSS rules — e.g. `state="primary"` and
993
+ * `state="secondary"` produce different background-color rules); the
994
+ * union is the byte-set the dispatcher will need at runtime regardless
995
+ * of which value the cond resolves to. Idempotent by per-value
996
+ * `ruleKey` so a re-resolve / HMR is a no-op.
997
+ */
998
+ function tryDynamicCollapse(node: N, tag: string): boolean {
999
+ const cfg = options.collapseRocketstyle
1000
+ if (!cfg) return false
1001
+ const dyn = detectDynamicCollapsibleShape(node, tag)
1002
+ if (!dyn) return false
1003
+ const { props, childrenText, dynamicProp, handlers } = dyn
1004
+ // Look up BOTH expanded sites (one per literal value). The scan's
1005
+ // dynamic-prop fallthrough (above in this file) emits a CollapsibleSite
1006
+ // for each value with identical key construction, so these lookups
1007
+ // must succeed iff both resolved.
1008
+ const truthyProps = { ...props, [dynamicProp.name]: dynamicProp.valueTruthy }
1009
+ const falsyProps = { ...props, [dynamicProp.name]: dynamicProp.valueFalsy }
1010
+ const truthyKey = rocketstyleCollapseKey(tag, truthyProps, childrenText)
1011
+ const falsyKey = rocketstyleCollapseKey(tag, falsyProps, childrenText)
1012
+ const truthySite = cfg.sites.get(truthyKey)
1013
+ const falsySite = cfg.sites.get(falsyKey)
1014
+ if (!truthySite || !falsySite) return false // half-resolved ⇒ keep normal mount
1015
+ // Cross-value template parity — the dispatcher reuses ONE `_tpl`
1016
+ // across both values; divergent markup means we'd silently pick
1017
+ // the truthy variant's HTML for falsy too. Bail conservatively.
1018
+ if (truthySite.templateHtml !== falsySite.templateHtml) return false
1019
+
1020
+ // Build the stride-2 value-major class array (consumed by
1021
+ // `_rsCollapseDyn`): `[v0_light, v0_dark, v1_light, v1_dark]` where
1022
+ // v0 = truthy (cond → 0), v1 = falsy (cond → 1).
1023
+ const classes = [
1024
+ truthySite.lightClass,
1025
+ truthySite.darkClass,
1026
+ falsySite.lightClass,
1027
+ falsySite.darkClass,
1028
+ ]
1029
+ const condSrc = code.slice(dynamicProp.condStart, dynamicProp.condEnd)
1030
+
1031
+ // Handler-combined sites route to `__rsCollapseDynH(...)` (PR A
1032
+ // runtime helper) — handlers re-attached after the class dispatcher
1033
+ // via the canonical `_bindEvent` path, byte-identical to how
1034
+ // `tryPartialCollapse` re-emits handlers via `__rsCollapseH`.
1035
+ // No-handler sites stay on `__rsCollapseDyn(...)` (lighter — no
1036
+ // handlers parameter, no loop allocation).
1037
+ let call: string
1038
+ if (handlers.length > 0) {
1039
+ const handlerObj =
1040
+ `{ ${handlers
1041
+ .map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
1042
+ .join(', ')} }`
1043
+ call =
1044
+ `__rsCollapseDynH(${JSON.stringify(truthySite.templateHtml)}, ` +
1045
+ `${JSON.stringify(classes)}, ` +
1046
+ `() => (${condSrc}) ? 0 : 1, ` +
1047
+ `() => __pyrMode() === "dark", ` +
1048
+ `${handlerObj})`
1049
+ needsCollapseDynH = true
1050
+ } else {
1051
+ call =
1052
+ `__rsCollapseDyn(${JSON.stringify(truthySite.templateHtml)}, ` +
1053
+ `${JSON.stringify(classes)}, ` +
1054
+ `() => (${condSrc}) ? 0 : 1, ` +
1055
+ `() => __pyrMode() === "dark")`
1056
+ needsCollapseDyn = true
1057
+ }
1058
+ const start = node.start as number
1059
+ const end = node.end as number
1060
+ const parent = findParent(node)
1061
+ const needsBraces =
1062
+ parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
1063
+ replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
1064
+ // Union BOTH value's rule bundles into the per-module injection.
1065
+ // De-dupe by ruleKey (the FNV-1a hash from the resolver) so two
1066
+ // dynamic sites sharing a value pay one injection.
1067
+ for (const site of [truthySite, falsySite]) {
1068
+ if (!collapseRuleKeys.has(site.ruleKey)) {
1069
+ collapseRuleKeys.add(site.ruleKey)
1070
+ collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
1071
+ }
1072
+ }
1073
+ return true
1074
+ }
1075
+
768
1076
  function maybeHoist(node: N): string | null {
769
1077
  if (
770
1078
  (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
@@ -1568,7 +1876,7 @@ export function transformJSX_JS(
1568
1876
  preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
1569
1877
  }
1570
1878
 
1571
- if (needsCollapse) {
1879
+ if (needsCollapse || needsCollapseDyn || needsCollapseDynH) {
1572
1880
  const cfg = options.collapseRocketstyle!
1573
1881
  const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
1574
1882
  const st = cfg.stylerSource ?? '@pyreon/styler'
@@ -1583,8 +1891,17 @@ export function transformJSX_JS(
1583
1891
  `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
1584
1892
  )
1585
1893
  .join('')
1894
+ // Only import the helpers actually emitted into this module — keeps
1895
+ // the bundle bytes per-feature and tree-shakable. needsCollapse
1896
+ // (full) gates `_rsCollapse`; the partial / dynamic flags gate
1897
+ // their respective helpers independently.
1898
+ const rdImports: string[] = []
1899
+ if (needsCollapse) rdImports.push('_rsCollapse as __rsCollapse')
1900
+ if (needsCollapseH) rdImports.push('_rsCollapseH as __rsCollapseH')
1901
+ if (needsCollapseDyn) rdImports.push('_rsCollapseDyn as __rsCollapseDyn')
1902
+ if (needsCollapseDynH) rdImports.push('_rsCollapseDynH as __rsCollapseDynH')
1586
1903
  preamble =
1587
- `import { _rsCollapse as __rsCollapse${needsCollapseH ? ', _rsCollapseH as __rsCollapseH' : ''} } from "${rd}";\n` +
1904
+ `import { ${rdImports.join(', ')} } from "${rd}";\n` +
1588
1905
  `import { sheet as __rsSheet } from "${st}";\n` +
1589
1906
  `import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
1590
1907
  `${inj}\n` +
package/src/lpih.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Live Program Inlay Hints (LPIH) — merge runtime fire data onto static
3
+ * Reactivity-Lens findings.
4
+ *
5
+ * This is a PURE function. The runtime side (`@pyreon/reactivity`)
6
+ * captures source locations at signal/computed/effect creation and emits
7
+ * fire counts via `getFireSummaries()`. The editor/LSP side calls
8
+ * `analyzeReactivity()` to get static findings. This module bridges them:
9
+ * given findings + fires, produces enriched findings whose `detail` field
10
+ * carries the live fire count.
11
+ *
12
+ * No I/O, no devtools dependency. The LSP transport is the consumer's
13
+ * responsibility (read fires from a cache file, an IPC bridge, or
14
+ * the editor's devtools panel — whatever the editor extension wants).
15
+ *
16
+ * ## The category
17
+ *
18
+ * Editors today show STATIC errors, types, and lint warnings at the
19
+ * cursor. They do NOT show LIVE program data — "this signal fires 240×
20
+ * per second", "this effect re-runs 3× per render", "this computed has
21
+ * 12 downstream subscribers". That data lives in a separate devtools
22
+ * panel that the developer has to context-switch to.
23
+ *
24
+ * LPIH closes that gap: live runtime data appears AT THE SOURCE LINE
25
+ * via LSP inlay hints, like a type annotation or a TypeScript error.
26
+ * No category like this exists for any reactive framework today.
27
+ *
28
+ * @example
29
+ * import { analyzeReactivity, mergeFireDataIntoFindings } from '@pyreon/compiler'
30
+ * import { getFireSummaries } from '@pyreon/reactivity'
31
+ *
32
+ * const code = `const count = signal(0)\nreturn <div>{count()}</div>`
33
+ * const { findings } = analyzeReactivity(code, 'app.tsx')
34
+ * const fires = getFireSummaries().map(s => ({
35
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
36
+ * }))
37
+ * const enriched = mergeFireDataIntoFindings(findings, fires, 'app.tsx')
38
+ * // enriched[0].detail might now be "live — signal fired 240×"
39
+ */
40
+
41
+ import type { ReactivityFinding, ReactivityFindingKind } from './reactivity-lens'
42
+
43
+ /**
44
+ * Runtime fire data carried into the merge function. Shape mirrors
45
+ * `@pyreon/reactivity`'s `FireSummary` but is duplicated here to keep
46
+ * `@pyreon/compiler` free of a runtime-package import. The consumer
47
+ * adapts the shape at the call site.
48
+ */
49
+ export interface LPIHFireDatum {
50
+ /** Source file path captured from `new Error().stack`. */
51
+ file: string
52
+ /** 1-based line number (V8 stack format). */
53
+ line: number
54
+ /** Total fires recorded at this location. */
55
+ count: number
56
+ /** `performance.now()` of most recent fire, or null. */
57
+ lastFire?: number | null | undefined
58
+ /** Node kind that fired (signal / derived / effect). */
59
+ kind?: 'signal' | 'derived' | 'effect' | undefined
60
+ /**
61
+ * Exponentially-decayed fire rate, fires/sec (1s time constant). 0
62
+ * when the node has been idle longer than several time constants.
63
+ * Used by the default formatter to add a "12/s" suffix when active.
64
+ * See `@pyreon/reactivity`'s `FireSummary.rate1s` for the math.
65
+ */
66
+ rate1s?: number | undefined
67
+ }
68
+
69
+ /** Options for `mergeFireDataIntoFindings`. */
70
+ export interface LPIHMergeOptions {
71
+ /**
72
+ * Optional file-path normalizer. Used for both the analyzed source
73
+ * file and each fire's `file` field. Useful when fires come from
74
+ * runtime stacks (absolute paths) but the source file is identified
75
+ * relative (e.g. workspace-rooted). Defaults to identity.
76
+ */
77
+ normalizeFile?: (path: string) => string
78
+ /**
79
+ * Optional formatter for the enriched detail. Receives the original
80
+ * detail + the matched fire datum. Defaults to:
81
+ * `${detail} — ${kind ? kind + ' ' : ''}fired ${count}×`
82
+ */
83
+ formatDetail?: (detail: string, fire: LPIHFireDatum) => string
84
+ }
85
+
86
+ /**
87
+ * Threshold below which the rate suffix is omitted. A long-dormant node
88
+ * decays toward 0; showing "0/s" or "0.001/s" is noise. The 0.5 cutoff
89
+ * means "less than once every 2 seconds at steady state" — at that
90
+ * rate, the cumulative count is the more useful signal.
91
+ *
92
+ * @internal — exported for tests + tunability.
93
+ */
94
+ export const _LPIH_RATE_VISIBLE_THRESHOLD = 0.5
95
+
96
+ function _formatRate(rate1s: number): string {
97
+ if (rate1s < _LPIH_RATE_VISIBLE_THRESHOLD) return ''
98
+ // < 10/s: 1 decimal place. ≥ 10/s: rounded integer.
99
+ return rate1s < 10
100
+ ? ` (${rate1s.toFixed(1)}/s)`
101
+ : ` (${Math.round(rate1s)}/s)`
102
+ }
103
+
104
+ const DEFAULT_FORMAT = (detail: string, fire: LPIHFireDatum): string => {
105
+ const kindLabel = fire.kind ? `${fire.kind} ` : ''
106
+ const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
107
+ return `${detail} — ${kindLabel}fired ${fire.count}×${rate}`
108
+ }
109
+
110
+ /**
111
+ * Merge runtime fire data onto static reactivity findings. Pure function,
112
+ * deterministic, input not mutated.
113
+ *
114
+ * Matching rules:
115
+ * - Only fires whose normalized `file` matches the analyzed source file
116
+ * are considered (cross-file fires are silently skipped).
117
+ * - Line-level matching only (column is ignored). V8 stack columns
118
+ * differ from compiler-emitted span columns by 1+ chars in practice,
119
+ * and the user-visible affordance is "this signal at this line is
120
+ * firing" — line precision is sufficient.
121
+ * - Multiple fires at the same `line` are summed; latest `lastFire`
122
+ * and corresponding `kind` win.
123
+ * - Findings of kind `footgun`, `hoisted-static`, or `static-text` are
124
+ * passed through unchanged — they're not runtime-active reactive
125
+ * reads, so a fire count at their location is unrelated to them.
126
+ */
127
+ export function mergeFireDataIntoFindings(
128
+ findings: ReactivityFinding[],
129
+ fires: readonly LPIHFireDatum[],
130
+ sourceFile: string,
131
+ options: LPIHMergeOptions = {},
132
+ ): ReactivityFinding[] {
133
+ if (fires.length === 0) return findings
134
+ const norm = options.normalizeFile ?? ((p) => p)
135
+ const format = options.formatDetail ?? DEFAULT_FORMAT
136
+ const targetFile = norm(sourceFile)
137
+
138
+ // Build line-keyed index. Sum counts at the same line; latest wins for
139
+ // lastFire + kind.
140
+ const byLine = new Map<number, LPIHFireDatum>()
141
+ for (const f of fires) {
142
+ if (norm(f.file) !== targetFile) continue
143
+ const existing = byLine.get(f.line)
144
+ if (existing) {
145
+ existing.count += f.count
146
+ if (typeof f.rate1s === 'number') {
147
+ existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
148
+ }
149
+ const incomingLast = f.lastFire ?? -Infinity
150
+ const existingLast = existing.lastFire ?? -Infinity
151
+ if (incomingLast > existingLast) {
152
+ existing.lastFire = f.lastFire
153
+ existing.kind = f.kind ?? existing.kind
154
+ }
155
+ } else {
156
+ byLine.set(f.line, { ...f })
157
+ }
158
+ }
159
+
160
+ if (byLine.size === 0) return findings
161
+
162
+ return findings.map((finding) => {
163
+ // Footguns + static spans are NOT enriched — fire data at those lines
164
+ // belongs to a SEPARATE reactive expression on the same line, and
165
+ // attributing it to the footgun would be misleading.
166
+ if (
167
+ finding.kind === 'footgun' ||
168
+ finding.kind === 'hoisted-static' ||
169
+ finding.kind === 'static-text'
170
+ ) {
171
+ return finding
172
+ }
173
+ const fire = byLine.get(finding.line)
174
+ if (!fire) return finding
175
+ return {
176
+ ...finding,
177
+ detail: format(finding.detail, fire),
178
+ }
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Synthesize "creation-site" inlay-hint findings directly from fire data.
184
+ *
185
+ * `analyzeReactivity()` produces findings at REACTIVE READ sites (JSX
186
+ * expressions). But the runtime captures fires at CREATION sites
187
+ * (`signal(0)`, `computed(...)`, `effect(...)`). These are usually
188
+ * different source lines — so the merge function above only helps when
189
+ * they happen to coincide.
190
+ *
191
+ * The simpler, more useful editor surface is: show fire counts AT THE
192
+ * CREATION LINE. The user writes `const count = signal(0)` and sees
193
+ * `(signal fired 129×)` as ghost text on that line, the same way
194
+ * TypeScript shows the inferred type.
195
+ *
196
+ * This function turns each fire datum into a synthetic finding the LSP
197
+ * can serve as an inlay hint. No static analysis required — pure runtime
198
+ * data → editor hint.
199
+ *
200
+ * Returns findings sorted by (line, column). Files that don't match
201
+ * `sourceFile` (after normalization) are skipped.
202
+ *
203
+ * @example
204
+ * import { firesToCreationSiteFindings } from '@pyreon/compiler'
205
+ * import { getFireSummaries } from '@pyreon/reactivity'
206
+ *
207
+ * const fires = getFireSummaries().map(s => ({
208
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
209
+ * }))
210
+ * const findings = firesToCreationSiteFindings(fires, 'app.tsx')
211
+ * // [{ kind: 'live-fire', line: 5, detail: 'signal fired 129×', ... }]
212
+ */
213
+ export function firesToCreationSiteFindings(
214
+ fires: readonly LPIHFireDatum[],
215
+ sourceFile: string,
216
+ options: LPIHMergeOptions = {},
217
+ ): ReactivityFinding[] {
218
+ if (fires.length === 0) return []
219
+ const norm = options.normalizeFile ?? ((p) => p)
220
+ const targetFile = norm(sourceFile)
221
+
222
+ // Per-line aggregation (multiple nodes on the same line — rare but
223
+ // possible: `const [a, b] = [signal(0), signal(0)]`).
224
+ const byLine = new Map<number, LPIHFireDatum>()
225
+ for (const f of fires) {
226
+ if (norm(f.file) !== targetFile) continue
227
+ const existing = byLine.get(f.line)
228
+ if (existing) {
229
+ existing.count += f.count
230
+ // Sum rates at the same line (e.g. destructured signal pair).
231
+ if (typeof f.rate1s === 'number') {
232
+ existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
233
+ }
234
+ const incomingLast = f.lastFire ?? -Infinity
235
+ const existingLast = existing.lastFire ?? -Infinity
236
+ if (incomingLast > existingLast) {
237
+ existing.lastFire = f.lastFire
238
+ existing.kind = f.kind ?? existing.kind
239
+ }
240
+ } else {
241
+ byLine.set(f.line, { ...f })
242
+ }
243
+ }
244
+
245
+ const format = options.formatDetail ?? ((_: string, fire: LPIHFireDatum) => {
246
+ const kindLabel = fire.kind ?? 'node'
247
+ const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
248
+ return `${kindLabel} fired ${fire.count}×${rate}`
249
+ })
250
+
251
+ // 'live-fire' is a new finding kind — synthetic, not produced by
252
+ // `analyzeReactivity()`. The LSP renders it as an inlay hint the same
253
+ // way as the structural kinds (reactive/static-text/etc).
254
+ const LIVE_KIND = 'live-fire' as ReactivityFindingKind
255
+
256
+ const out: ReactivityFinding[] = []
257
+ for (const [line, fire] of byLine) {
258
+ out.push({
259
+ kind: LIVE_KIND,
260
+ line,
261
+ column: 0,
262
+ endLine: line,
263
+ // 9999 = "end of line" sentinel; the LSP can clamp to actual line length.
264
+ endColumn: 9999,
265
+ detail: format('', fire),
266
+ })
267
+ }
268
+ out.sort((a, b) => a.line - b.line || a.column - b.column)
269
+ return out
270
+ }
@@ -993,7 +993,15 @@ export function hasPyreonPatterns(code: string): boolean {
993
993
  /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
994
994
  /\b(?:add|remove)EventListener\s*\(/.test(code) ||
995
995
  (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
996
- /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
996
+ // Bounded `\w{0,60}` cap on the handler identifier — real `on*`
997
+ // names are at most ~25 chars (`onPointerLeaveCapture`); 60 leaves
998
+ // headroom. The unbounded `\w*` form was flagged by CodeQL
999
+ // `js/polynomial-redos` (alert #65) as polynomial-time on inputs
1000
+ // like `onAAAA…` (long runs of `[A-Z]`): per starting position
1001
+ // the greedy `\w*` consumes O(N) chars before the trailing `=`
1002
+ // fails to match, giving O(N²) overall on N starting positions.
1003
+ // The cap keeps the regex linear regardless of input shape.
1004
+ /on[A-Z]\w{0,60}\s*=\s*\{\s*undefined\s*\}/.test(code) ||
997
1005
  // Bounded `{0,500}` / `{1,500}` quantifiers — this is a pre-filter
998
1006
  // scan before the precise AST walker, so losing detector recall on
999
1007
  // a pathologically long single-line input is acceptable.