@pyreon/compiler 0.23.0 → 0.24.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +357 -5
- package/lib/types/index.d.ts +94 -1
- package/package.json +12 -12
- package/src/index.ts +2 -0
- package/src/jsx.ts +320 -3
- package/src/lpih.ts +270 -0
- package/src/pyreon-intercept.ts +9 -1
- package/src/tests/collapse-bail-census.test.ts +101 -16
- package/src/tests/dynamic-collapse-detector.test.ts +164 -0
- package/src/tests/dynamic-collapse-emit.test.ts +192 -0
- package/src/tests/dynamic-collapse-scan.test.ts +111 -0
- package/src/tests/lpih.test.ts +404 -0
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
|
-
|
|
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 {
|
|
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
|
+
}
|
package/src/pyreon-intercept.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|