@pyreon/compiler 0.22.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/index.js CHANGED
@@ -785,6 +785,23 @@ function scanCollapsibleSites(code, filename, collapsibleSources) {
785
785
  childrenText: site.childrenText,
786
786
  key: rocketstyleCollapseKey(tag, site.props, site.childrenText)
787
787
  });
788
+ else {
789
+ const dyn = detectDynamicCollapsibleShape(node, tag);
790
+ if (dyn) for (const value of [dyn.dynamicProp.valueTruthy, dyn.dynamicProp.valueFalsy]) {
791
+ const expandedProps = {
792
+ ...dyn.props,
793
+ [dyn.dynamicProp.name]: value
794
+ };
795
+ out.push({
796
+ componentName: tag,
797
+ source: imp.source,
798
+ importedName: imp.imported,
799
+ props: expandedProps,
800
+ childrenText: dyn.childrenText,
801
+ key: rocketstyleCollapseKey(tag, expandedProps, dyn.childrenText)
802
+ });
803
+ }
804
+ }
788
805
  }
789
806
  }
790
807
  for (const k in node) {
@@ -880,6 +897,104 @@ function detectPartialCollapsibleShape(node, _tag) {
880
897
  handlers
881
898
  };
882
899
  }
900
+ /**
901
+ * Dynamic-prop partial-collapse detector — PR 2 of the dynamic-prop
902
+ * partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1
903
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the next-bigger
904
+ * bite after the `on*`-handler partial-collapse).
905
+ *
906
+ * Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
907
+ * with ONE relaxation" pattern (see that detector's docstring + `PR 1`
908
+ * `_rsCollapseDyn` runtime helper, PR #765). The single relaxation: a
909
+ * `JSXExpressionContainer` wrapping a `ConditionalExpression` whose
910
+ * `consequent` AND `alternate` are BOTH `StringLiteral` is acceptable as
911
+ * a "ternary-of-two-literals" dynamic prop — captured as a {@link DynamicCollapsibleProp}
912
+ * with the cond source span + the two literal values.
913
+ *
914
+ * Constraint: **AT MOST ONE** such dynamic prop per site. Multiple
915
+ * ternaries would compound into a 2^N value-set per site at build time
916
+ * and an N-axis dispatcher at runtime — that's a separable scope
917
+ * (potential PR 5+), NOT this PR. Sites with 2+ ternaries bail (return
918
+ * null), keeping the normal mount; same conservative shape as the rest
919
+ * of the detector family.
920
+ *
921
+ * Constraint: the FULL `on*`-handler relaxation is also folded in — a
922
+ * site can have ONE ternary AND `on*` handlers in the same call. This
923
+ * matches the real-corpus shape (a Button with `state={cond ? 'a' : 'b'}`
924
+ * almost always also has an `onClick`). The two relaxations compose
925
+ * cleanly because they're orthogonal at the resolver layer (handlers
926
+ * don't change rendered CSS; the ternary picks among pre-resolved
927
+ * classes). PR 3's emit will use `_rsCollapseDyn` when handlers are
928
+ * absent and a future combined helper when both are present — for THIS
929
+ * PR (detector-only) the structure carries both so PR 3 can dispatch.
930
+ *
931
+ * Every OTHER non-literal shape still bails (spread, non-handler
932
+ * non-ternary `{expr}` prop, multi-literal ternary anywhere, computed-
933
+ * expression ternary, element/expression child, boolean attr) —
934
+ * conservative by construction, exactly like the rest of the family.
935
+ * Returns `null` when there are ZERO ternaries so the on*-only path
936
+ * (`detectPartialCollapsibleShape`) and the full-collapse path
937
+ * (`detectCollapsibleShape`) stay byte-unchanged and no detector both
938
+ * claims the same site.
939
+ *
940
+ * A consistency test (PR 3) will lock this catalogue against the
941
+ * plugin scan, mirroring the `detectCollapsibleShape` ↔ `scanCollapsibleSites`
942
+ * + `detectPartialCollapsibleShape` ↔ scan invariants — keys cannot drift.
943
+ */
944
+ function detectDynamicCollapsibleShape(node, _tag) {
945
+ const props = {};
946
+ const handlers = [];
947
+ const dynamicProps = [];
948
+ for (const attr of jsxAttrs(node)) {
949
+ if (attr.type !== "JSXAttribute") return null;
950
+ const nm = attr.name?.type === "JSXIdentifier" ? attr.name.name : null;
951
+ if (!nm) return null;
952
+ const v = attr.value;
953
+ if (!v) return null;
954
+ if (v.type === "StringLiteral" || v.type === "Literal" && typeof v.value === "string") {
955
+ props[nm] = String(v.value);
956
+ continue;
957
+ }
958
+ if (v.type === "JSXExpressionContainer" && v.expression && typeof v.expression.start === "number" && typeof v.expression.end === "number") {
959
+ if (/^on[A-Z]/.test(nm)) {
960
+ handlers.push({
961
+ name: nm,
962
+ exprStart: v.expression.start,
963
+ exprEnd: v.expression.end
964
+ });
965
+ continue;
966
+ }
967
+ const expr = v.expression;
968
+ if (expr.type === "ConditionalExpression" && expr.test && typeof expr.test.start === "number" && typeof expr.test.end === "number" && expr.consequent && expr.alternate) {
969
+ const isLitStr = (n) => {
970
+ const x = n;
971
+ return x?.type === "StringLiteral" || x?.type === "Literal" && typeof x.value === "string";
972
+ };
973
+ if (isLitStr(expr.consequent) && isLitStr(expr.alternate)) {
974
+ dynamicProps.push({
975
+ name: nm,
976
+ condStart: expr.test.start,
977
+ condEnd: expr.test.end,
978
+ valueTruthy: String(expr.consequent.value),
979
+ valueFalsy: String(expr.alternate.value)
980
+ });
981
+ continue;
982
+ }
983
+ }
984
+ }
985
+ return null;
986
+ }
987
+ let childrenText = "";
988
+ for (const c of jsxChildren(node)) if (c.type === "JSXText") childrenText += c.value ?? "";
989
+ else return null;
990
+ if (dynamicProps.length !== 1) return null;
991
+ return {
992
+ props,
993
+ childrenText: childrenText.trim(),
994
+ handlers,
995
+ dynamicProp: dynamicProps[0]
996
+ };
997
+ }
883
998
  function transformJSX(code, filename = "input.tsx", options = {}) {
884
999
  if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options);
885
1000
  if (nativeTransformJsx) try {
@@ -975,6 +1090,8 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
975
1090
  let needsMountSlotImportGlobal = false;
976
1091
  let needsCollapse = false;
977
1092
  let needsCollapseH = false;
1093
+ let needsCollapseDyn = false;
1094
+ let needsCollapseDynH = false;
978
1095
  const collapseRuleKeys = /* @__PURE__ */ new Set();
979
1096
  const collapseRules = [];
980
1097
  /**
@@ -997,7 +1114,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
997
1114
  if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false;
998
1115
  if (!cfg.candidates.has(tag)) return false;
999
1116
  const shape = detectCollapsibleShape(node, tag);
1000
- if (!shape) return tryPartialCollapse(node, tag);
1117
+ if (!shape) return tryPartialCollapse(node, tag) || tryDynamicCollapse(node, tag);
1001
1118
  const { props, childrenText } = shape;
1002
1119
  const key = rocketstyleCollapseKey(tag, props, childrenText);
1003
1120
  const site = cfg.sites.get(key);
@@ -1069,6 +1186,103 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1069
1186
  }
1070
1187
  return true;
1071
1188
  }
1189
+ /**
1190
+ * PR 3 of the dynamic-prop partial-collapse build (open-work #1
1191
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the
1192
+ * next-bigger bite after the just-shipped `on*`-handler partial).
1193
+ * The dynamic-prop fallback `tryRocketstyleCollapse` defers to when
1194
+ * BOTH the full and the on*-handler-partial paths bail.
1195
+ *
1196
+ * Same site-resolution contract as the full path — the dynamic prop
1197
+ * is replaced with EACH literal value to compute TWO keys; the
1198
+ * resolver pre-renders both via the existing SSR pipeline; if both
1199
+ * lookups succeed AND the structural template is byte-identical
1200
+ * across values, emit `__rsCollapseDyn(html, [classes...], () =>
1201
+ * cond ? 0 : 1, () => __pyrMode() === "dark")` — the PR 1 runtime
1202
+ * helper (#765) dispatches across `(value × mode)` with a stride-2
1203
+ * value-major class layout.
1204
+ *
1205
+ * Conservative discipline:
1206
+ * - Either expanded key missing from sites map ⇒ bail (an
1207
+ * intermittent resolver failure on one value mustn't half-collapse)
1208
+ * - Divergent template HTML across values ⇒ bail (the dispatcher
1209
+ * assumes a shared template; deriveCollapseDyn cannot be done
1210
+ * across values that produce structurally different markup —
1211
+ * this is the cross-value parallel of `deriveCollapse`'s
1212
+ * light↔dark template-divergence bail)
1213
+ *
1214
+ * Handler-combined sites: when the detected dynamic site has `on*`
1215
+ * handlers (the most common real-corpus shape — bail-census measured
1216
+ * the no-handler subset at 0.2% of all sites; handler-combined is
1217
+ * the bulk of the 15.4% dynamic-prop bucket), emit
1218
+ * `__rsCollapseDynH(...)` (PR A: runtime helper) instead of
1219
+ * `__rsCollapseDyn(...)`. Handlers are orthogonal to the SSR-
1220
+ * resolved styler class (the resolver pre-renders both values
1221
+ * identically regardless of handlers); the union helper just
1222
+ * re-attaches them through the same canonical `_bindEvent` path
1223
+ * `tryPartialCollapse` uses.
1224
+ *
1225
+ * Rule injection unions the rule sets across both values (each value
1226
+ * may inject distinct CSS rules — e.g. `state="primary"` and
1227
+ * `state="secondary"` produce different background-color rules); the
1228
+ * union is the byte-set the dispatcher will need at runtime regardless
1229
+ * of which value the cond resolves to. Idempotent by per-value
1230
+ * `ruleKey` so a re-resolve / HMR is a no-op.
1231
+ */
1232
+ function tryDynamicCollapse(node, tag) {
1233
+ const cfg = options.collapseRocketstyle;
1234
+ if (!cfg) return false;
1235
+ const dyn = detectDynamicCollapsibleShape(node, tag);
1236
+ if (!dyn) return false;
1237
+ const { props, childrenText, dynamicProp, handlers } = dyn;
1238
+ const truthyProps = {
1239
+ ...props,
1240
+ [dynamicProp.name]: dynamicProp.valueTruthy
1241
+ };
1242
+ const falsyProps = {
1243
+ ...props,
1244
+ [dynamicProp.name]: dynamicProp.valueFalsy
1245
+ };
1246
+ const truthyKey = rocketstyleCollapseKey(tag, truthyProps, childrenText);
1247
+ const falsyKey = rocketstyleCollapseKey(tag, falsyProps, childrenText);
1248
+ const truthySite = cfg.sites.get(truthyKey);
1249
+ const falsySite = cfg.sites.get(falsyKey);
1250
+ if (!truthySite || !falsySite) return false;
1251
+ if (truthySite.templateHtml !== falsySite.templateHtml) return false;
1252
+ const classes = [
1253
+ truthySite.lightClass,
1254
+ truthySite.darkClass,
1255
+ falsySite.lightClass,
1256
+ falsySite.darkClass
1257
+ ];
1258
+ const condSrc = code.slice(dynamicProp.condStart, dynamicProp.condEnd);
1259
+ let call;
1260
+ if (handlers.length > 0) {
1261
+ const handlerObj = `{ ${handlers.map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`).join(", ")} }`;
1262
+ call = `__rsCollapseDynH(${JSON.stringify(truthySite.templateHtml)}, ${JSON.stringify(classes)}, () => (${condSrc}) ? 0 : 1, () => __pyrMode() === "dark", ${handlerObj})`;
1263
+ needsCollapseDynH = true;
1264
+ } else {
1265
+ call = `__rsCollapseDyn(${JSON.stringify(truthySite.templateHtml)}, ${JSON.stringify(classes)}, () => (${condSrc}) ? 0 : 1, () => __pyrMode() === "dark")`;
1266
+ needsCollapseDyn = true;
1267
+ }
1268
+ const start = node.start;
1269
+ const end = node.end;
1270
+ const parent = findParent(node);
1271
+ const needsBraces = parent && (parent.type === "JSXElement" || parent.type === "JSXFragment");
1272
+ replacements.push({
1273
+ start,
1274
+ end,
1275
+ text: needsBraces ? `{${call}}` : call
1276
+ });
1277
+ for (const site of [truthySite, falsySite]) if (!collapseRuleKeys.has(site.ruleKey)) {
1278
+ collapseRuleKeys.add(site.ruleKey);
1279
+ collapseRules.push({
1280
+ ruleKey: site.ruleKey,
1281
+ rules: site.rules
1282
+ });
1283
+ }
1284
+ return true;
1285
+ }
1072
1286
  function maybeHoist(node) {
1073
1287
  if ((node.type === "JSXElement" || node.type === "JSXFragment") && isStaticJSXNode(node)) {
1074
1288
  const name = `_$h${hoistIdx++}`;
@@ -1193,7 +1407,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1193
1407
  }
1194
1408
  } else hoistOrWrap(expr);
1195
1409
  }
1196
- function handleJsxExpression(node) {
1410
+ function handleJsxExpression(node, parentJsx) {
1197
1411
  const expr = node.expression;
1198
1412
  if (!expr || expr.type === "JSXEmptyExpression") return;
1199
1413
  const hoistName = maybeHoist(expr);
@@ -1206,11 +1420,64 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1206
1420
  return;
1207
1421
  }
1208
1422
  if (shouldWrap(expr)) {
1423
+ if (parentJsx && isComponentTag(jsxTagName(parentJsx)) && isStableReference(expr) && !referencesSignalVar(expr)) {
1424
+ const start = expr.start;
1425
+ const end = expr.end;
1426
+ const sliced = sliceExpr(unwrapTypeLayers(expr));
1427
+ replacements.push({
1428
+ start,
1429
+ end,
1430
+ text: sliced
1431
+ });
1432
+ return;
1433
+ }
1209
1434
  wrap(expr);
1210
1435
  return;
1211
1436
  }
1212
1437
  walkNode(expr);
1213
1438
  }
1439
+ /** Component tag — uppercase first letter. Lowercase = DOM element. */
1440
+ function isComponentTag(tag) {
1441
+ return tag.length > 0 && tag.charAt(0) !== tag.charAt(0).toLowerCase();
1442
+ }
1443
+ /**
1444
+ * Stable reference — an expression whose value is a bare property read.
1445
+ * Bare Identifier (`children`) or a non-computed MemberExpression chain
1446
+ * (`obj.x.y`) terminating in an Identifier or `this`. These are the
1447
+ * shapes that survive the no-wrap path without losing reactivity:
1448
+ * reading them once captures the same value as reading them N times,
1449
+ * because the underlying getter (if any) is the source of truth either
1450
+ * way. Excludes CallExpression / TaggedTemplateExpression / BinaryExpression
1451
+ * / LogicalExpression / ConditionalExpression / etc. — those keep the
1452
+ * wrap so consumers can re-evaluate inside reactive scopes.
1453
+ *
1454
+ * TS type-only layers (`as T` / `satisfies T` / non-null `!`) and
1455
+ * parentheses are transparent — they don't change runtime semantics
1456
+ * so we unwrap to look at the underlying expression. Reproducer:
1457
+ * `<Comp>{children as VNode[]}</Comp>` in `createKineticComponent.tsx`
1458
+ * — the TS cast wraps the Identifier as a `TSAsExpression`; without
1459
+ * unwrap the carve-out misses the very pattern it was written for.
1460
+ */
1461
+ function isStableReference(expr) {
1462
+ const u = unwrapTypeLayers(expr);
1463
+ if (u.type === "Identifier") return true;
1464
+ if (u.type === "MemberExpression") {
1465
+ let cur = u;
1466
+ while (cur.type === "MemberExpression") {
1467
+ if (cur.computed) return false;
1468
+ if (cur.property?.type !== "Identifier") return false;
1469
+ cur = cur.object;
1470
+ }
1471
+ return cur.type === "Identifier" || cur.type === "ThisExpression";
1472
+ }
1473
+ return false;
1474
+ }
1475
+ /** Strip TS type-only layers + parens that don't affect runtime value. */
1476
+ function unwrapTypeLayers(expr) {
1477
+ let cur = expr;
1478
+ while (cur.type === "TSAsExpression" || cur.type === "TSSatisfiesExpression" || cur.type === "TSNonNullExpression" || cur.type === "TSTypeAssertion" || cur.type === "ParenthesizedExpression") cur = cur.expression;
1479
+ return cur;
1480
+ }
1214
1481
  const propsNames = /* @__PURE__ */ new Set();
1215
1482
  const propDerivedVars = /* @__PURE__ */ new Map();
1216
1483
  const elementVars = /* @__PURE__ */ new Set();
@@ -1514,7 +1781,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1514
1781
  checkForWarnings(node);
1515
1782
  for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
1516
1783
  else if (attr.type === "JSXSpreadAttribute") handleJsxSpreadAttribute(attr, node);
1517
- for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
1784
+ for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child, node);
1518
1785
  else walkNode(child);
1519
1786
  return;
1520
1787
  }
@@ -1559,12 +1826,17 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1559
1826
  if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
1560
1827
  preamble = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + preamble;
1561
1828
  }
1562
- if (needsCollapse) {
1829
+ if (needsCollapse || needsCollapseDyn || needsCollapseDynH) {
1563
1830
  const cfg = options.collapseRocketstyle;
1564
1831
  const rd = cfg.runtimeDomSource ?? "@pyreon/runtime-dom";
1565
1832
  const st = cfg.stylerSource ?? "@pyreon/styler";
1566
1833
  const inj = collapseRules.map((r) => `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`).join("");
1567
- preamble = `import { _rsCollapse as __rsCollapse${needsCollapseH ? ", _rsCollapseH as __rsCollapseH" : ""} } from "${rd}";\nimport { sheet as __rsSheet } from "${st}";\nimport { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n${inj}\n` + preamble;
1834
+ const rdImports = [];
1835
+ if (needsCollapse) rdImports.push("_rsCollapse as __rsCollapse");
1836
+ if (needsCollapseH) rdImports.push("_rsCollapseH as __rsCollapseH");
1837
+ if (needsCollapseDyn) rdImports.push("_rsCollapseDyn as __rsCollapseDyn");
1838
+ if (needsCollapseDynH) rdImports.push("_rsCollapseDynH as __rsCollapseDynH");
1839
+ preamble = `import { ${rdImports.join(", ")} } from "${rd}";\nimport { sheet as __rsSheet } from "${st}";\nimport { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n${inj}\n` + preamble;
1568
1840
  }
1569
1841
  if (preamble) s.prepend(preamble);
1570
1842
  const output = s.toString();
@@ -2754,7 +3026,7 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
2754
3026
  }
2755
3027
  /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
2756
3028
  function hasPyreonPatterns(code) {
2757
- return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:const|let|var)\s+\{[^}]*\}\s*=\s*[A-Za-z_$]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) || /\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(code) || /\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code);
3029
+ return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w{0,60}\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]{1,500}\}\s*[:)]/.test(code) || /\b(?:const|let|var)\s+\{[^}]{0,500}\}\s*=\s*[A-Za-z_$]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]{1,500}\)[\s{]{0,20}return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) || /\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(code) || /\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code);
2758
3030
  }
2759
3031
 
2760
3032
  //#endregion
@@ -2887,6 +3159,139 @@ function formatReactivityLens(code, result) {
2887
3159
  return out.join("\n");
2888
3160
  }
2889
3161
 
3162
+ //#endregion
3163
+ //#region src/lpih.ts
3164
+ /**
3165
+ * Threshold below which the rate suffix is omitted. A long-dormant node
3166
+ * decays toward 0; showing "0/s" or "0.001/s" is noise. The 0.5 cutoff
3167
+ * means "less than once every 2 seconds at steady state" — at that
3168
+ * rate, the cumulative count is the more useful signal.
3169
+ *
3170
+ * @internal — exported for tests + tunability.
3171
+ */
3172
+ const _LPIH_RATE_VISIBLE_THRESHOLD = .5;
3173
+ function _formatRate(rate1s) {
3174
+ if (rate1s < .5) return "";
3175
+ return rate1s < 10 ? ` (${rate1s.toFixed(1)}/s)` : ` (${Math.round(rate1s)}/s)`;
3176
+ }
3177
+ const DEFAULT_FORMAT = (detail, fire) => {
3178
+ const kindLabel = fire.kind ? `${fire.kind} ` : "";
3179
+ const rate = typeof fire.rate1s === "number" ? _formatRate(fire.rate1s) : "";
3180
+ return `${detail} — ${kindLabel}fired ${fire.count}×${rate}`;
3181
+ };
3182
+ /**
3183
+ * Merge runtime fire data onto static reactivity findings. Pure function,
3184
+ * deterministic, input not mutated.
3185
+ *
3186
+ * Matching rules:
3187
+ * - Only fires whose normalized `file` matches the analyzed source file
3188
+ * are considered (cross-file fires are silently skipped).
3189
+ * - Line-level matching only (column is ignored). V8 stack columns
3190
+ * differ from compiler-emitted span columns by 1+ chars in practice,
3191
+ * and the user-visible affordance is "this signal at this line is
3192
+ * firing" — line precision is sufficient.
3193
+ * - Multiple fires at the same `line` are summed; latest `lastFire`
3194
+ * and corresponding `kind` win.
3195
+ * - Findings of kind `footgun`, `hoisted-static`, or `static-text` are
3196
+ * passed through unchanged — they're not runtime-active reactive
3197
+ * reads, so a fire count at their location is unrelated to them.
3198
+ */
3199
+ function mergeFireDataIntoFindings(findings, fires, sourceFile, options = {}) {
3200
+ if (fires.length === 0) return findings;
3201
+ const norm = options.normalizeFile ?? ((p) => p);
3202
+ const format = options.formatDetail ?? DEFAULT_FORMAT;
3203
+ const targetFile = norm(sourceFile);
3204
+ const byLine = /* @__PURE__ */ new Map();
3205
+ for (const f of fires) {
3206
+ if (norm(f.file) !== targetFile) continue;
3207
+ const existing = byLine.get(f.line);
3208
+ if (existing) {
3209
+ existing.count += f.count;
3210
+ if (typeof f.rate1s === "number") existing.rate1s = (existing.rate1s ?? 0) + f.rate1s;
3211
+ if ((f.lastFire ?? -Infinity) > (existing.lastFire ?? -Infinity)) {
3212
+ existing.lastFire = f.lastFire;
3213
+ existing.kind = f.kind ?? existing.kind;
3214
+ }
3215
+ } else byLine.set(f.line, { ...f });
3216
+ }
3217
+ if (byLine.size === 0) return findings;
3218
+ return findings.map((finding) => {
3219
+ if (finding.kind === "footgun" || finding.kind === "hoisted-static" || finding.kind === "static-text") return finding;
3220
+ const fire = byLine.get(finding.line);
3221
+ if (!fire) return finding;
3222
+ return {
3223
+ ...finding,
3224
+ detail: format(finding.detail, fire)
3225
+ };
3226
+ });
3227
+ }
3228
+ /**
3229
+ * Synthesize "creation-site" inlay-hint findings directly from fire data.
3230
+ *
3231
+ * `analyzeReactivity()` produces findings at REACTIVE READ sites (JSX
3232
+ * expressions). But the runtime captures fires at CREATION sites
3233
+ * (`signal(0)`, `computed(...)`, `effect(...)`). These are usually
3234
+ * different source lines — so the merge function above only helps when
3235
+ * they happen to coincide.
3236
+ *
3237
+ * The simpler, more useful editor surface is: show fire counts AT THE
3238
+ * CREATION LINE. The user writes `const count = signal(0)` and sees
3239
+ * `(signal fired 129×)` as ghost text on that line, the same way
3240
+ * TypeScript shows the inferred type.
3241
+ *
3242
+ * This function turns each fire datum into a synthetic finding the LSP
3243
+ * can serve as an inlay hint. No static analysis required — pure runtime
3244
+ * data → editor hint.
3245
+ *
3246
+ * Returns findings sorted by (line, column). Files that don't match
3247
+ * `sourceFile` (after normalization) are skipped.
3248
+ *
3249
+ * @example
3250
+ * import { firesToCreationSiteFindings } from '@pyreon/compiler'
3251
+ * import { getFireSummaries } from '@pyreon/reactivity'
3252
+ *
3253
+ * const fires = getFireSummaries().map(s => ({
3254
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
3255
+ * }))
3256
+ * const findings = firesToCreationSiteFindings(fires, 'app.tsx')
3257
+ * // [{ kind: 'live-fire', line: 5, detail: 'signal fired 129×', ... }]
3258
+ */
3259
+ function firesToCreationSiteFindings(fires, sourceFile, options = {}) {
3260
+ if (fires.length === 0) return [];
3261
+ const norm = options.normalizeFile ?? ((p) => p);
3262
+ const targetFile = norm(sourceFile);
3263
+ const byLine = /* @__PURE__ */ new Map();
3264
+ for (const f of fires) {
3265
+ if (norm(f.file) !== targetFile) continue;
3266
+ const existing = byLine.get(f.line);
3267
+ if (existing) {
3268
+ existing.count += f.count;
3269
+ if (typeof f.rate1s === "number") existing.rate1s = (existing.rate1s ?? 0) + f.rate1s;
3270
+ if ((f.lastFire ?? -Infinity) > (existing.lastFire ?? -Infinity)) {
3271
+ existing.lastFire = f.lastFire;
3272
+ existing.kind = f.kind ?? existing.kind;
3273
+ }
3274
+ } else byLine.set(f.line, { ...f });
3275
+ }
3276
+ const format = options.formatDetail ?? ((_, fire) => {
3277
+ const kindLabel = fire.kind ?? "node";
3278
+ const rate = typeof fire.rate1s === "number" ? _formatRate(fire.rate1s) : "";
3279
+ return `${kindLabel} fired ${fire.count}×${rate}`;
3280
+ });
3281
+ const LIVE_KIND = "live-fire";
3282
+ const out = [];
3283
+ for (const [line, fire] of byLine) out.push({
3284
+ kind: LIVE_KIND,
3285
+ line,
3286
+ column: 0,
3287
+ endLine: line,
3288
+ endColumn: 9999,
3289
+ detail: format("", fire)
3290
+ });
3291
+ out.sort((a, b) => a.line - b.line || a.column - b.column);
3292
+ return out;
3293
+ }
3294
+
2890
3295
  //#endregion
2891
3296
  //#region src/project-scanner.ts
2892
3297
  /**
@@ -4687,7 +5092,7 @@ function detectDynamicRouteMissingGetStaticPaths(routeFiles, rootForRel) {
4687
5092
  const findings = [];
4688
5093
  for (const file of routeFiles) {
4689
5094
  const base = file.split("/").pop() ?? "";
4690
- if (!/\[.+\]/.test(base)) continue;
5095
+ if (!/\[[^\]]{1,200}\]/.test(base)) continue;
4691
5096
  if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue;
4692
5097
  if (/[/\\]routes[/\\]api[/\\]/.test(file)) continue;
4693
5098
  const source = parseSourceFile(file);
@@ -4778,7 +5183,7 @@ function auditSsg(rootDir) {
4778
5183
  let revalidateExports = 0;
4779
5184
  for (const file of routeFiles) {
4780
5185
  const base = file.split("/").pop() ?? "";
4781
- if (/\[.+\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) dynamicRoutes++;
5186
+ if (/\[[^\]]{1,200}\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) dynamicRoutes++;
4782
5187
  const source = parseSourceFile(file);
4783
5188
  if (!source) continue;
4784
5189
  function visit(node) {
@@ -4838,5 +5243,5 @@ function formatSsgAudit(result, _options = {}) {
4838
5243
  }
4839
5244
 
4840
5245
  //#endregion
4841
- export { analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, rocketstyleCollapseKey, scanCollapsibleSites, transformDeferInline, transformJSX, transformJSX_JS };
5246
+ export { analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, firesToCreationSiteFindings, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, mergeFireDataIntoFindings, migrateReactCode, rocketstyleCollapseKey, scanCollapsibleSites, transformDeferInline, transformJSX, transformJSX_JS };
4842
5247
  //# sourceMappingURL=index.js.map
@@ -419,6 +419,99 @@ declare function analyzeReactivity(code: string, filename?: string, options?: {
419
419
  */
420
420
  declare function formatReactivityLens(code: string, result: AnalyzeReactivityResult): string;
421
421
  //#endregion
422
+ //#region src/lpih.d.ts
423
+ /**
424
+ * Runtime fire data carried into the merge function. Shape mirrors
425
+ * `@pyreon/reactivity`'s `FireSummary` but is duplicated here to keep
426
+ * `@pyreon/compiler` free of a runtime-package import. The consumer
427
+ * adapts the shape at the call site.
428
+ */
429
+ interface LPIHFireDatum {
430
+ /** Source file path captured from `new Error().stack`. */
431
+ file: string;
432
+ /** 1-based line number (V8 stack format). */
433
+ line: number;
434
+ /** Total fires recorded at this location. */
435
+ count: number;
436
+ /** `performance.now()` of most recent fire, or null. */
437
+ lastFire?: number | null | undefined;
438
+ /** Node kind that fired (signal / derived / effect). */
439
+ kind?: 'signal' | 'derived' | 'effect' | undefined;
440
+ /**
441
+ * Exponentially-decayed fire rate, fires/sec (1s time constant). 0
442
+ * when the node has been idle longer than several time constants.
443
+ * Used by the default formatter to add a "12/s" suffix when active.
444
+ * See `@pyreon/reactivity`'s `FireSummary.rate1s` for the math.
445
+ */
446
+ rate1s?: number | undefined;
447
+ }
448
+ /** Options for `mergeFireDataIntoFindings`. */
449
+ interface LPIHMergeOptions {
450
+ /**
451
+ * Optional file-path normalizer. Used for both the analyzed source
452
+ * file and each fire's `file` field. Useful when fires come from
453
+ * runtime stacks (absolute paths) but the source file is identified
454
+ * relative (e.g. workspace-rooted). Defaults to identity.
455
+ */
456
+ normalizeFile?: (path: string) => string;
457
+ /**
458
+ * Optional formatter for the enriched detail. Receives the original
459
+ * detail + the matched fire datum. Defaults to:
460
+ * `${detail} — ${kind ? kind + ' ' : ''}fired ${count}×`
461
+ */
462
+ formatDetail?: (detail: string, fire: LPIHFireDatum) => string;
463
+ }
464
+ /**
465
+ * Merge runtime fire data onto static reactivity findings. Pure function,
466
+ * deterministic, input not mutated.
467
+ *
468
+ * Matching rules:
469
+ * - Only fires whose normalized `file` matches the analyzed source file
470
+ * are considered (cross-file fires are silently skipped).
471
+ * - Line-level matching only (column is ignored). V8 stack columns
472
+ * differ from compiler-emitted span columns by 1+ chars in practice,
473
+ * and the user-visible affordance is "this signal at this line is
474
+ * firing" — line precision is sufficient.
475
+ * - Multiple fires at the same `line` are summed; latest `lastFire`
476
+ * and corresponding `kind` win.
477
+ * - Findings of kind `footgun`, `hoisted-static`, or `static-text` are
478
+ * passed through unchanged — they're not runtime-active reactive
479
+ * reads, so a fire count at their location is unrelated to them.
480
+ */
481
+ declare function mergeFireDataIntoFindings(findings: ReactivityFinding[], fires: readonly LPIHFireDatum[], sourceFile: string, options?: LPIHMergeOptions): ReactivityFinding[];
482
+ /**
483
+ * Synthesize "creation-site" inlay-hint findings directly from fire data.
484
+ *
485
+ * `analyzeReactivity()` produces findings at REACTIVE READ sites (JSX
486
+ * expressions). But the runtime captures fires at CREATION sites
487
+ * (`signal(0)`, `computed(...)`, `effect(...)`). These are usually
488
+ * different source lines — so the merge function above only helps when
489
+ * they happen to coincide.
490
+ *
491
+ * The simpler, more useful editor surface is: show fire counts AT THE
492
+ * CREATION LINE. The user writes `const count = signal(0)` and sees
493
+ * `(signal fired 129×)` as ghost text on that line, the same way
494
+ * TypeScript shows the inferred type.
495
+ *
496
+ * This function turns each fire datum into a synthetic finding the LSP
497
+ * can serve as an inlay hint. No static analysis required — pure runtime
498
+ * data → editor hint.
499
+ *
500
+ * Returns findings sorted by (line, column). Files that don't match
501
+ * `sourceFile` (after normalization) are skipped.
502
+ *
503
+ * @example
504
+ * import { firesToCreationSiteFindings } from '@pyreon/compiler'
505
+ * import { getFireSummaries } from '@pyreon/reactivity'
506
+ *
507
+ * const fires = getFireSummaries().map(s => ({
508
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
509
+ * }))
510
+ * const findings = firesToCreationSiteFindings(fires, 'app.tsx')
511
+ * // [{ kind: 'live-fire', line: 5, detail: 'signal fired 129×', ... }]
512
+ */
513
+ declare function firesToCreationSiteFindings(fires: readonly LPIHFireDatum[], sourceFile: string, options?: LPIHMergeOptions): ReactivityFinding[];
514
+ //#endregion
422
515
  //#region src/project-scanner.d.ts
423
516
  /**
424
517
  * Project scanner — extracts route, component, and island information from source files.
@@ -642,5 +735,5 @@ interface SsgAuditFormatOptions {
642
735
  }
643
736
  declare function formatSsgAudit(result: SsgAuditResult, _options?: SsgAuditFormatOptions): string;
644
737
  //#endregion
645
- export { type AnalyzeReactivityResult, type AuditFormatOptions, type AuditRisk, type CollapsibleSite, type CompilerWarning, type ComponentInfo, type DeferInlineResult, type DeferInlineWarning, type ErrorDiagnosis, type IslandAuditFormatOptions, type IslandAuditResult, type IslandFinding, type IslandFindingCode, type IslandInfo, type IslandLocation, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type ReactivityFinding, type ReactivityFindingKind, type ReactivityKind, type ReactivitySpan, type RouteInfo, type SsgAuditFormatOptions, type SsgAuditResult, type SsgFinding, type SsgFindingCode, type SsgLocation, type TestAuditEntry, type TestAuditResult, type TransformResult, analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, rocketstyleCollapseKey, scanCollapsibleSites, transformDeferInline, transformJSX, transformJSX_JS };
738
+ export { type AnalyzeReactivityResult, type AuditFormatOptions, type AuditRisk, type CollapsibleSite, type CompilerWarning, type ComponentInfo, type DeferInlineResult, type DeferInlineWarning, type ErrorDiagnosis, type IslandAuditFormatOptions, type IslandAuditResult, type IslandFinding, type IslandFindingCode, type IslandInfo, type IslandLocation, type LPIHFireDatum, type LPIHMergeOptions, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type ReactivityFinding, type ReactivityFindingKind, type ReactivityKind, type ReactivitySpan, type RouteInfo, type SsgAuditFormatOptions, type SsgAuditResult, type SsgFinding, type SsgFindingCode, type SsgLocation, type TestAuditEntry, type TestAuditResult, type TransformResult, analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, firesToCreationSiteFindings, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, mergeFireDataIntoFindings, migrateReactCode, rocketstyleCollapseKey, scanCollapsibleSites, transformDeferInline, transformJSX, transformJSX_JS };
646
739
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Template and JSX compiler for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
6
6
  "bugs": {
@@ -48,20 +48,20 @@
48
48
  "oxc-parser": "^0.129.0"
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pyreon/compiler-darwin-arm64": "^0.22.0",
52
- "@pyreon/compiler-darwin-x64": "^0.22.0",
53
- "@pyreon/compiler-linux-arm64-gnu": "^0.22.0",
54
- "@pyreon/compiler-linux-arm64-musl": "^0.22.0",
55
- "@pyreon/compiler-linux-x64-gnu": "^0.22.0",
56
- "@pyreon/compiler-linux-x64-musl": "^0.22.0",
57
- "@pyreon/compiler-win32-x64-msvc": "^0.22.0"
51
+ "@pyreon/compiler-darwin-arm64": "^0.24.0",
52
+ "@pyreon/compiler-darwin-x64": "^0.24.0",
53
+ "@pyreon/compiler-linux-arm64-gnu": "^0.24.0",
54
+ "@pyreon/compiler-linux-arm64-musl": "^0.24.0",
55
+ "@pyreon/compiler-linux-x64-gnu": "^0.24.0",
56
+ "@pyreon/compiler-linux-x64-musl": "^0.24.0",
57
+ "@pyreon/compiler-win32-x64-msvc": "^0.24.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@pyreon/core": "^0.22.0",
60
+ "@pyreon/core": "^0.24.0",
61
61
  "@pyreon/manifest": "0.13.1",
62
- "@pyreon/reactivity": "^0.22.0",
63
- "@pyreon/runtime-dom": "^0.22.0",
64
- "@pyreon/test-utils": "^0.13.9",
62
+ "@pyreon/reactivity": "^0.24.0",
63
+ "@pyreon/runtime-dom": "^0.24.0",
64
+ "@pyreon/test-utils": "^0.13.11",
65
65
  "happy-dom": "^20.8.3"
66
66
  },
67
67
  "peerDependencies": {