@barefootjs/jsx 0.15.2 → 0.17.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.
Files changed (61) hide show
  1. package/dist/adapters/env-signal.d.ts +38 -15
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer-context.d.ts +29 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/builtin-lowering-plugins.d.ts +34 -0
  10. package/dist/builtin-lowering-plugins.d.ts.map +1 -0
  11. package/dist/expression-parser.d.ts +219 -163
  12. package/dist/expression-parser.d.ts.map +1 -1
  13. package/dist/index.d.ts +9 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6892 -6118
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  20. package/dist/jsx-to-ir.d.ts.map +1 -1
  21. package/dist/lowering-registry.d.ts +122 -0
  22. package/dist/lowering-registry.d.ts.map +1 -0
  23. package/dist/profiler.d.ts +115 -0
  24. package/dist/profiler.d.ts.map +1 -1
  25. package/dist/query-href-lowering.d.ts +63 -0
  26. package/dist/query-href-lowering.d.ts.map +1 -0
  27. package/dist/ssr-defaults.d.ts.map +1 -1
  28. package/dist/types.d.ts +169 -11
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  32. package/src/__tests__/analyzer.test.ts +53 -0
  33. package/src/__tests__/expression-parser.test.ts +703 -391
  34. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  35. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  36. package/src/__tests__/lowering-registry.test.ts +141 -0
  37. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  38. package/src/__tests__/profiler.test.ts +149 -0
  39. package/src/__tests__/query-href-recognition.test.ts +58 -0
  40. package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
  41. package/src/__tests__/unsupported-expression.test.ts +98 -4
  42. package/src/adapters/env-signal.ts +60 -21
  43. package/src/adapters/jsx-adapter.ts +17 -0
  44. package/src/adapters/parsed-expr-emitter.ts +39 -41
  45. package/src/analyzer-context.ts +72 -27
  46. package/src/analyzer.ts +226 -9
  47. package/src/builtin-lowering-plugins.ts +54 -0
  48. package/src/expression-parser.ts +1183 -927
  49. package/src/index.ts +35 -3
  50. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  51. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  52. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  53. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  54. package/src/jsx-to-ir.ts +182 -43
  55. package/src/lowering-registry.ts +160 -0
  56. package/src/profiler.ts +328 -0
  57. package/src/query-href-lowering.ts +147 -0
  58. package/src/ssr-defaults.ts +5 -1
  59. package/src/types.ts +171 -12
  60. package/src/__tests__/flatmap-support.test.ts +0 -218
  61. package/src/__tests__/reduce-op.test.ts +0 -201
package/src/profiler.ts CHANGED
@@ -1360,6 +1360,13 @@ export interface ProfileCoverage {
1360
1360
  handlersFired: number
1361
1361
  /** Handlers the IR knows about (`buildEventSummary`). */
1362
1362
  handlersTotal: number
1363
+ /**
1364
+ * `handlersFired / handlersTotal` in `[0,1]` — the fraction of known handlers
1365
+ * this run exercised, so an agent can gate on it (`--min-coverage`) without
1366
+ * dividing the two counts itself (#1841). `1` when the component has no
1367
+ * handlers (nothing to cover ⇒ trivially complete, not a gap).
1368
+ */
1369
+ ratio: number
1363
1370
  /** SR4 ids the IR could not resolve — the honest, actionable gap. */
1364
1371
  unattributed: UnattributedId[]
1365
1372
  /**
@@ -1386,6 +1393,79 @@ export interface ProfileReport {
1386
1393
  wastedReReruns: WastedReRunsResult
1387
1394
  batchAdvisor: BatchAdvisorResult
1388
1395
  coverage: ProfileCoverage
1396
+ /**
1397
+ * Normalized run status an agent can branch on without parsing prose (#1841):
1398
+ * `ok` when nothing is flagged, `warning` when any finding is severity
1399
+ * `warning` or higher — regardless of its `actionable` flag, since an
1400
+ * unresolved-but-real cost (e.g. a hot subscriber with no source loc) is still
1401
+ * worth surfacing. A failed *gate* escalates this to `error` — but gates are
1402
+ * policy applied by the CLI from flags, so the builder only ever sets
1403
+ * `ok`/`warning` here.
1404
+ */
1405
+ status: ProfileStatus
1406
+ /**
1407
+ * Flattened, normalized findings (#1841): the hot/wasted/batch/coverage tables
1408
+ * re-expressed with a single `severity` scale, an explicit `actionable` flag,
1409
+ * and `nextCommands` — the exact follow-up `bf debug …` invocations to run.
1410
+ * The structured tables above stay the source of truth; this is the agent view.
1411
+ */
1412
+ findings: AgentFinding[]
1413
+ /**
1414
+ * Coverage guidance for an under-exercised run (#1841): present only when the
1415
+ * scenario fired no or only some handlers, which usually means a story file is
1416
+ * needed (handlers live in composed children).
1417
+ */
1418
+ guidance?: ScenarioGuidance
1419
+ }
1420
+
1421
+ // -- Agent contract (#1841): normalized severity, actionable findings, guidance
1422
+
1423
+ /** Normalized finding severity an agent can branch on without parsing prose. */
1424
+ export type ProfileSeverity = 'info' | 'warning' | 'error'
1425
+
1426
+ /** Top-level run status: `ok` (clean), `warning` (findings), `error` (gate failed). */
1427
+ export type ProfileStatus = 'ok' | 'warning' | 'error'
1428
+
1429
+ /**
1430
+ * One normalized, machine-actionable finding flattened from the per-analysis
1431
+ * tables (hot subscribers / wasted re-runs / batch advisor / coverage gaps).
1432
+ * Carries the agent contract fields the issue asks for: a normalized `severity`,
1433
+ * an explicit `actionable` flag (`false` ⇒ no safe direct fix — e.g. an
1434
+ * unverified advisory), and `nextCommands` — valid, ready-to-run `bf debug …`
1435
+ * follow-ups.
1436
+ */
1437
+ export interface AgentFinding {
1438
+ kind: 'hot-subscriber' | 'wasted-re-run' | 'batch-candidate' | 'coverage-gap'
1439
+ severity: ProfileSeverity
1440
+ /**
1441
+ * Whether this finding has a concrete fix worth acting on directly. `false`
1442
+ * marks a finding an agent should *not* apply blindly — an unverified `batch()`
1443
+ * advisory (the wrap could change behavior) or a finding whose source location
1444
+ * the id index couldn't resolve. A coverage gap is `actionable: true`: the next
1445
+ * step (widen the scenario, inspect the graph) is clear even without a `loc`.
1446
+ */
1447
+ actionable: boolean
1448
+ /** The compiler subscriber/turn id this finding is about, when it has one. */
1449
+ subscriber?: string
1450
+ /** Source location, when the id resolved to an IR node. */
1451
+ loc?: { file: string; line: number }
1452
+ /** One-line human summary — the same sentence the text report would print. */
1453
+ message: string
1454
+ /** Follow-up commands to run next — valid, ready-to-run `bf debug …` lines. */
1455
+ nextCommands: string[]
1456
+ }
1457
+
1458
+ /**
1459
+ * Coverage guidance for an under-exercised run (#1841). `--scenario auto` can
1460
+ * only fire handlers the IR exposes on *this* component; for a compound/context
1461
+ * component whose handlers live in composed children it fires `0/N`, and the
1462
+ * honest next step is a story/scenario file rather than trusting a thin run.
1463
+ */
1464
+ export interface ScenarioGuidance {
1465
+ /** Why coverage was incomplete — drives the suggested next step. */
1466
+ reason: 'no-handlers' | 'no-interactions' | 'partial-coverage'
1467
+ message: string
1468
+ nextCommands: string[]
1389
1469
  }
1390
1470
 
1391
1471
  export interface ProfileReportInput {
@@ -1412,6 +1492,104 @@ export interface ProfileReportInput {
1412
1492
  wastedRatio?: number
1413
1493
  }
1414
1494
 
1495
+ /**
1496
+ * The follow-up `bf debug …` commands an agent should run to investigate a
1497
+ * subscriber finding (#1841). Every command is valid and ready to run: a
1498
+ * memo/signal id resolves to a name `bf debug trace` accepts; a DOM-binding id
1499
+ * resolves to a slotId `bf debug why-update` accepts; `bf debug graph` is always
1500
+ * applicable, so it is the universal fallback.
1501
+ *
1502
+ * Commands target the component parsed *from the id* (`<Component>#…`), not the
1503
+ * primary component — a scenario-file run resolves subscribers from composed
1504
+ * children (`extraSources`), so a child's finding must point at the child. The
1505
+ * passed `fallbackComponent` is used only for ids that don't parse (e.g. an
1506
+ * anonymous `e1`).
1507
+ */
1508
+ function nextCommandsForSubscriber(fallbackComponent: string, subscriber: string): string[] {
1509
+ const cmds: string[] = []
1510
+ const parsed = parseProfilerId(subscriber)
1511
+ const component = parsed?.component ?? fallbackComponent
1512
+ if (parsed) {
1513
+ if (parsed.kind === 'memo' || parsed.kind === 'signal') {
1514
+ cmds.push(`bf debug trace ${component} ${parsed.rest} --json`)
1515
+ } else if (parsed.kind === 'binding') {
1516
+ cmds.push(`bf debug why-update ${component} ${parsed.rest} --json`)
1517
+ }
1518
+ }
1519
+ cmds.push(`bf debug graph ${component} --json`)
1520
+ return cmds
1521
+ }
1522
+
1523
+ /**
1524
+ * Flatten the per-analysis tables into the normalized agent findings (#1841).
1525
+ * Only *flagged* rows become findings — a hot subscriber that isn't `hot`, or a
1526
+ * subscriber below the wasted threshold, is data in the tables but not something
1527
+ * the agent must act on. Coverage gaps (unresolved ids) are actionable findings;
1528
+ * the non-actionable bookkeeping ids stay in `coverage.diagnostics`.
1529
+ */
1530
+ function buildAgentFindings(
1531
+ component: string,
1532
+ hotSubscribers: HotSubscribersResult,
1533
+ wastedReReruns: WastedReRunsResult,
1534
+ batchAdvisor: BatchAdvisorResult,
1535
+ unattributed: readonly UnattributedId[],
1536
+ ): AgentFinding[] {
1537
+ const findings: AgentFinding[] = []
1538
+ for (const s of hotSubscribers.subscribers) {
1539
+ if (!s.hot) continue
1540
+ findings.push({
1541
+ kind: 'hot-subscriber',
1542
+ severity: 'warning',
1543
+ actionable: s.loc !== undefined,
1544
+ subscriber: s.subscriber,
1545
+ loc: s.loc,
1546
+ message: `${s.name ?? s.subscriber} ran ${s.runsPerTurn.toFixed(1)}×/turn — re-run pressure (split or batch).`,
1547
+ nextCommands: nextCommandsForSubscriber(component, s.subscriber),
1548
+ })
1549
+ }
1550
+ for (const s of wastedReReruns.subscribers) {
1551
+ if (!s.wasted) continue
1552
+ findings.push({
1553
+ kind: 'wasted-re-run',
1554
+ severity: 'warning',
1555
+ actionable: s.loc !== undefined,
1556
+ subscriber: s.subscriber,
1557
+ loc: s.loc,
1558
+ message: `${s.name ?? s.subscriber} produced identical output in ${Math.round(s.wastedRatio * 100)}% of runs — finer split.`,
1559
+ nextCommands: nextCommandsForSubscriber(component, s.subscriber),
1560
+ })
1561
+ }
1562
+ for (const c of batchAdvisor.candidates) {
1563
+ findings.push({
1564
+ kind: 'batch-candidate',
1565
+ // Only a *proven-safe* batch is advised as actionable warning; an
1566
+ // unverified one is surfaced as info so an agent doesn't apply a wrap that
1567
+ // could change behavior (mirrors the batch advisor's own safety gate).
1568
+ severity: c.safety === 'safe' ? 'warning' : 'info',
1569
+ actionable: c.safety === 'safe' && c.loc !== undefined,
1570
+ subscriber: c.turn,
1571
+ loc: c.loc,
1572
+ message: `${c.handler ?? c.turn} re-ran shared effects ${c.savings}× extra across ${c.writes} writes — batch() candidate (${c.safety}).`,
1573
+ // The turn id is `<Component>#handler:…` — for a scenario-file run it can be
1574
+ // a composed child, so route through the helper to target the right one.
1575
+ nextCommands: nextCommandsForSubscriber(component, c.turn),
1576
+ })
1577
+ }
1578
+ for (const u of unattributed) {
1579
+ findings.push({
1580
+ kind: 'coverage-gap',
1581
+ severity: 'warning',
1582
+ actionable: true,
1583
+ subscriber: u.id,
1584
+ message: `Unresolved subscriber id "${u.id}" — could not map to source (scope caveat).`,
1585
+ // `u.id` is shaped `<Component>#…` and may name a composed child — route
1586
+ // through the helper so the command targets that component, not the root.
1587
+ nextCommands: nextCommandsForSubscriber(component, u.id),
1588
+ })
1589
+ }
1590
+ return findings
1591
+ }
1592
+
1415
1593
  /**
1416
1594
  * Assemble a dynamic profile (SR1–SR4 + analyses, SR7) from a recorded event
1417
1595
  * stream. Pure: the DOM run that *produces* `events` lives in the driver (the
@@ -1524,6 +1702,41 @@ export function buildProfileReport(input: ProfileReportInput): ProfileReport {
1524
1702
  }
1525
1703
  }
1526
1704
 
1705
+ const findings = buildAgentFindings(primary.componentName, hotSubscribers, wastedReReruns, batchAdvisor, unattributed)
1706
+ // Status is measurement-only here: any warning/error finding ⇒ `warning`. A
1707
+ // failed gate escalates to `error`, but gates are CLI policy (flags), so the
1708
+ // builder never sets `error` itself.
1709
+ const status: ProfileStatus = findings.some(f => f.severity === 'warning' || f.severity === 'error') ? 'warning' : 'ok'
1710
+
1711
+ // Coverage ratio: 1 when there is nothing to cover (no handlers), else the
1712
+ // exercised fraction. Clamped to `[0,1]` — a malformed stream (more distinct
1713
+ // turn ids than `buildEventSummary` knows about, e.g. missing `extraSources`)
1714
+ // must not push the ratio past 1 and break a gate's assumptions. Drives
1715
+ // `--min-coverage` and the guidance below.
1716
+ const ratio = handlersTotal > 0 ? Math.min(1, handlerIds.size / handlersTotal) : 1
1717
+ let guidance: ScenarioGuidance | undefined
1718
+ if (turnSeqs.size === 0) {
1719
+ guidance =
1720
+ handlersTotal === 0
1721
+ ? {
1722
+ reason: 'no-handlers',
1723
+ message: 'No event handlers — use the static budget instead of a dynamic run.',
1724
+ nextCommands: [`bf debug profile ${primary.componentName} --json`],
1725
+ }
1726
+ : {
1727
+ reason: 'no-interactions',
1728
+ message:
1729
+ 'Handlers exist but none fired — they likely live in composed children. Drive the component with a story/scenario file.',
1730
+ nextCommands: [`bf debug profile ${primary.componentName} --scenario <story.tsx> --json`],
1731
+ }
1732
+ } else if (ratio < 1) {
1733
+ guidance = {
1734
+ reason: 'partial-coverage',
1735
+ message: `Only ${handlerIds.size}/${handlersTotal} handlers exercised — a story/scenario file can cover the rest.`,
1736
+ nextCommands: [`bf debug profile ${primary.componentName} --scenario <story.tsx> --json`],
1737
+ }
1738
+ }
1739
+
1527
1740
  return {
1528
1741
  kind: 'profile',
1529
1742
  schemaVersion: PROFILE_SCHEMA_VERSION,
@@ -1538,12 +1751,16 @@ export function buildProfileReport(input: ProfileReportInput): ProfileReport {
1538
1751
  coverage: {
1539
1752
  handlersFired: handlerIds.size,
1540
1753
  handlersTotal,
1754
+ ratio,
1541
1755
  unattributed,
1542
1756
  // Roll the (potentially hundreds of) bookkeeping ids up to a count + a
1543
1757
  // small sample so JSON consumers aren't flooded (#1849 B7). `diagnostics`
1544
1758
  // is already sorted hottest-first by `joinProfilerEvents`.
1545
1759
  diagnostics: { count: diagnostics.length, sample: diagnostics.slice(0, 3).map(d => d.id) },
1546
1760
  },
1761
+ status,
1762
+ findings,
1763
+ ...(guidance ? { guidance } : {}),
1547
1764
  }
1548
1765
  }
1549
1766
 
@@ -1579,5 +1796,116 @@ export function formatProfileReport(r: ProfileReport): string {
1579
1796
  if (c.diagnostics.count > 0) {
1580
1797
  lines.push(` · ${c.diagnostics.count} anonymous runtime id(s) (non-actionable bookkeeping)`)
1581
1798
  }
1799
+ // Agent contract footer (#1841): a normalized status and the single most
1800
+ // useful next command, so a human reading the text output sees the same
1801
+ // signal `--json` consumers branch on.
1802
+ lines.push('')
1803
+ lines.push(`status: ${r.status} (${r.findings.length} finding(s))`)
1804
+ if (r.guidance) {
1805
+ lines.push(` guidance: ${r.guidance.message}`)
1806
+ lines.push(` next: ${r.guidance.nextCommands[0]}`)
1807
+ } else if (r.findings.length > 0) {
1808
+ lines.push(` next: ${r.findings[0].nextCommands[0]}`)
1809
+ }
1582
1810
  return lines.join('\n')
1583
1811
  }
1812
+
1813
+ // -- Gates (#1841): turn a measured report into a pass/fail CI decision --------
1814
+
1815
+ /** The gate names `--fail-on` accepts. `regression` applies to `--diff` mode. */
1816
+ export type GateName = 'unresolved' | 'hot' | 'coverage' | 'regression'
1817
+
1818
+ /**
1819
+ * Gate thresholds an agent/CI supplies via flags. A gate is *active* when it is
1820
+ * named in `failOn` or its numeric threshold is set — so `--max-unresolved 0`
1821
+ * enforces the unresolved gate without also passing `--fail-on unresolved`.
1822
+ */
1823
+ export interface GateConfig {
1824
+ failOn?: readonly GateName[]
1825
+ /** `--min-coverage`: minimum `coverage.ratio` in `[0,1]`. */
1826
+ minCoverage?: number
1827
+ /** `--max-runs-per-turn`: budget for the hottest subscriber's runs/turn. */
1828
+ maxRunsPerTurn?: number
1829
+ /** `--max-unresolved`: maximum allowed unresolved (actionable) coverage gaps. */
1830
+ maxUnresolved?: number
1831
+ }
1832
+
1833
+ export interface GateCheck {
1834
+ gate: GateName
1835
+ passed: boolean
1836
+ /** The measured value the gate compared (ratio, count, or runs/turn). */
1837
+ observed: number
1838
+ /** The threshold it was compared against; `null` for a boolean gate. */
1839
+ threshold: number | null
1840
+ message: string
1841
+ }
1842
+
1843
+ export interface GateResult {
1844
+ passed: boolean
1845
+ /** Names of the gates that failed — the `gates.failed` an agent branches on. */
1846
+ failed: GateName[]
1847
+ checks: GateCheck[]
1848
+ }
1849
+
1850
+ /**
1851
+ * Evaluate the dynamic-run gates (#1841) against a measured report. Pure: maps a
1852
+ * `ProfileReport` + thresholds to a pass/fail decision the CLI turns into an
1853
+ * exit code. The `regression` gate is *not* evaluated here — it belongs to
1854
+ * `--diff` mode, which the CLI gates directly off the `BudgetDiff`.
1855
+ */
1856
+ export function evaluateProfileGates(report: ProfileReport, config: GateConfig): GateResult {
1857
+ const failOn = new Set(config.failOn ?? [])
1858
+ const checks: GateCheck[] = []
1859
+
1860
+ if (failOn.has('coverage') || config.minCoverage !== undefined) {
1861
+ const threshold = config.minCoverage ?? 1
1862
+ const observed = report.coverage.ratio
1863
+ checks.push({
1864
+ gate: 'coverage',
1865
+ passed: observed >= threshold,
1866
+ observed,
1867
+ threshold,
1868
+ message: `coverage ${(observed * 100).toFixed(0)}% ${observed >= threshold ? '≥' : '<'} required ${(threshold * 100).toFixed(0)}%`,
1869
+ })
1870
+ }
1871
+
1872
+ if (failOn.has('unresolved') || config.maxUnresolved !== undefined) {
1873
+ const threshold = config.maxUnresolved ?? 0
1874
+ const observed = report.coverage.unattributed.length
1875
+ checks.push({
1876
+ gate: 'unresolved',
1877
+ passed: observed <= threshold,
1878
+ observed,
1879
+ threshold,
1880
+ message: `${observed} unresolved id(s) ${observed <= threshold ? '≤' : '>'} allowed ${threshold}`,
1881
+ })
1882
+ }
1883
+
1884
+ if (failOn.has('hot') || config.maxRunsPerTurn !== undefined) {
1885
+ const observed = report.hotSubscribers.subscribers.reduce((m, s) => Math.max(m, s.runsPerTurn), 0)
1886
+ if (config.maxRunsPerTurn !== undefined) {
1887
+ // Numeric budget: the hottest subscriber must not exceed it.
1888
+ const threshold = config.maxRunsPerTurn
1889
+ checks.push({
1890
+ gate: 'hot',
1891
+ passed: observed <= threshold,
1892
+ observed,
1893
+ threshold,
1894
+ message: `max ${observed.toFixed(1)} runs/turn ${observed <= threshold ? '≤' : '>'} budget ${threshold}`,
1895
+ })
1896
+ } else {
1897
+ // Bare `--fail-on hot`: any subscriber the analysis flagged `hot` fails it.
1898
+ const anyHot = report.hotSubscribers.subscribers.some(s => s.hot)
1899
+ checks.push({
1900
+ gate: 'hot',
1901
+ passed: !anyHot,
1902
+ observed,
1903
+ threshold: null,
1904
+ message: anyHot ? `hot subscriber(s) present (max ${observed.toFixed(1)} runs/turn)` : 'no hot subscribers',
1905
+ })
1906
+ }
1907
+ }
1908
+
1909
+ const failed = checks.filter(c => !c.passed).map(c => c.gate)
1910
+ return { passed: failed.length === 0, failed, checks }
1911
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Backend-neutral destructuring of a recognised `queryHref(base, { … })` call
3
+ * (#2042) into a base expression plus include triples, shared by the SSR
4
+ * adapters' query lowering.
5
+ *
6
+ * `queryHref` is the pure functional URL-query builder (the counterpart to
7
+ * `searchParams()`); its call + object literal are already structured IR, so an
8
+ * adapter lowers it to its query helper without any block-body recognition or
9
+ * re-parse. This module only does the structural match — turning the object
10
+ * literal's properties into `{ guard, key, value }` triples — leaving each
11
+ * adapter to format the include condition and the helper call in its own
12
+ * template language.
13
+ *
14
+ * Inclusion is truthy-omit over string values (matching the client `queryHref`'s
15
+ * `if (value)`): a plain `key: v` is included iff `v` is a non-empty string
16
+ * (`guard: null`); a conditional `key: cond ? a : <undefined|null|''>` is
17
+ * included iff `cond` AND `a` is non-empty (`guard: cond`, `value: a`).
18
+ */
19
+
20
+ import type { ParsedExpr } from './expression-parser.ts'
21
+
22
+ export interface QueryHrefTriple {
23
+ /**
24
+ * The conditional test of a `key: cond ? a : <omit>` include, or null for a
25
+ * plain `key: v` (which is included purely on value-truthiness). An adapter
26
+ * combines this with the value's non-emptiness to form the include condition.
27
+ */
28
+ guard: ParsedExpr | null
29
+ /** The literal search-param key. */
30
+ key: string
31
+ /** The value expression (the consequent for a conditional include). */
32
+ value: ParsedExpr
33
+ }
34
+
35
+ export interface QueryHrefCall {
36
+ base: ParsedExpr
37
+ triples: QueryHrefTriple[]
38
+ }
39
+
40
+ /**
41
+ * Match a `queryHref(base, { … })` call from its callee + args, returning the
42
+ * base and include triples, or null when it isn't a `queryHref` call with a
43
+ * plain object-literal second argument (→ the adapter falls back to its generic
44
+ * lowering). `localNames` are the bindings `queryHref` is imported under (from
45
+ * `queryHrefLocalNames`).
46
+ */
47
+ export function matchQueryHrefCall(
48
+ callee: ParsedExpr,
49
+ args: readonly ParsedExpr[],
50
+ localNames: ReadonlySet<string>,
51
+ ): QueryHrefCall | null {
52
+ if (callee.kind !== 'identifier' || !localNames.has(callee.name)) return null
53
+ if (args.length !== 2) return null
54
+ const [base, obj] = args
55
+ // A dynamic (non-literal) params object can't be lowered to static include
56
+ // triples — fall back to the generic lowering.
57
+ if (obj.kind !== 'object-literal') return null
58
+
59
+ const triples: QueryHrefTriple[] = []
60
+ for (const p of obj.properties) {
61
+ const v = p.value
62
+ if (v.kind === 'conditional' && isOmitBranch(v.alternate)) {
63
+ triples.push({ guard: v.test, key: p.key, value: v.consequent })
64
+ } else {
65
+ triples.push({ guard: null, key: p.key, value: v })
66
+ }
67
+ }
68
+ return { base, triples }
69
+ }
70
+
71
+ /**
72
+ * Format a {@link QueryHrefCall} as the flat argument list for a guard-list
73
+ * query helper (`bf->query(base, guard, key, value, …)` in Mojo / `$bf.query(…)`
74
+ * in Xslate — the two adapters whose helper does the non-empty check itself).
75
+ * Each triple contributes a guard (`'1'` for a plain include, or the lowered
76
+ * condition for a conditional one), the key as a string literal, and the value —
77
+ * all lowered through the adapter's `emit`. The caller wraps the result in its
78
+ * own `<helper>(…)` call. (The go-template adapter folds the non-empty check
79
+ * into the include condition itself, so it formats its own form instead.)
80
+ *
81
+ * A conditional guard that is NOT already boolean-shaped (a bare value, a member
82
+ * access, `&&`/`||`) is JS *string* truthiness — `'0'` is a truthy string in JS
83
+ * but false under Perl's `unless`. To keep SSR byte-identical to the client (and
84
+ * to the go adapter, whose `lowerUrlGuard` does the same), such a guard is
85
+ * normalised to a `guard !== ''` test, emitted against a string literal so each
86
+ * adapter renders string `ne`, not numeric `!=`. Comparisons / `!x` / boolean
87
+ * literals already yield a real boolean and pass through unchanged.
88
+ */
89
+ export function queryHrefArgs(q: QueryHrefCall, emit: (e: ParsedExpr) => string): string[] {
90
+ const out = [emit(q.base)]
91
+ for (const t of q.triples) {
92
+ if (t.guard === null) {
93
+ out.push('1')
94
+ } else if (isBoolShapeGuard(t.guard)) {
95
+ out.push(`(${emit(t.guard)})`)
96
+ } else {
97
+ const test: ParsedExpr = {
98
+ kind: 'binary',
99
+ op: '!==',
100
+ left: t.guard,
101
+ right: { kind: 'literal', value: '', literalType: 'string' },
102
+ }
103
+ out.push(`(${emit(test)})`)
104
+ }
105
+ out.push(emit({ kind: 'literal', value: t.key, literalType: 'string' }))
106
+ out.push(emit(t.value))
107
+ }
108
+ return out
109
+ }
110
+
111
+ const GUARD_BOOL_OPS: ReadonlySet<string> = new Set([
112
+ '==',
113
+ '===',
114
+ '!=',
115
+ '!==',
116
+ '<',
117
+ '>',
118
+ '<=',
119
+ '>=',
120
+ ])
121
+
122
+ /**
123
+ * Whether a conditional-include guard already evaluates to a real boolean, so it
124
+ * can be emitted as-is rather than wrapped in a `!== ''` string-truthiness test.
125
+ * A comparison, a `!negation`, or a boolean literal qualifies; a bare value /
126
+ * member / `&&` / `||` does not. Mirrors the go adapter's `lowerUrlGuard`
127
+ * `isBoolShape` so the four backends agree on which guards need normalising.
128
+ */
129
+ function isBoolShapeGuard(g: ParsedExpr): boolean {
130
+ return (
131
+ (g.kind === 'binary' && GUARD_BOOL_OPS.has(g.op)) ||
132
+ (g.kind === 'unary' && g.op === '!') ||
133
+ (g.kind === 'literal' && g.literalType === 'boolean')
134
+ )
135
+ }
136
+
137
+ /**
138
+ * The falsy "omit" branch of a conditional include — `undefined` (an identifier),
139
+ * `null`, or `''` — which makes `cond ? v : <omit>` a conditional include.
140
+ */
141
+ function isOmitBranch(node: ParsedExpr): boolean {
142
+ if (node.kind === 'identifier') return node.name === 'undefined'
143
+ if (node.kind === 'literal') {
144
+ return node.literalType === 'null' || (node.literalType === 'string' && node.value === '')
145
+ }
146
+ return false
147
+ }
@@ -143,6 +143,10 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
143
143
  }
144
144
  for (const sig of metadata.signals) {
145
145
  if (!sig.getter || sig.isModule) continue
146
+ // Env signals (#2057) have no static SSR default — their value is the
147
+ // request-scoped reader, seeded by the adapter's env-signal binding, not a
148
+ // baked initial value.
149
+ if (sig.envReader) continue
146
150
  const value = tryStaticEval(sig.initialValue, { bindings, propsLike })
147
151
  out[sig.getter] = { value: resultToJsonable(value) }
148
152
  bindings[sig.getter] = value
@@ -169,7 +173,7 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
169
173
  if (metadata.propsObjectName !== null) {
170
174
  const referenced = new Set<string>()
171
175
  for (const sig of metadata.signals) {
172
- if (!sig.getter || sig.isModule) continue
176
+ if (!sig.getter || sig.isModule || sig.envReader) continue
173
177
  collectPropRefs(sig.initialValue, metadata.propsObjectName, referenced)
174
178
  }
175
179
  for (const memo of metadata.memos) {