@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.
- package/dist/adapters/env-signal.d.ts +38 -15
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +29 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts +34 -0
- package/dist/builtin-lowering-plugins.d.ts.map +1 -0
- package/dist/expression-parser.d.ts +219 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6892 -6118
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +122 -0
- package/dist/lowering-registry.d.ts.map +1 -0
- package/dist/profiler.d.ts +115 -0
- package/dist/profiler.d.ts.map +1 -1
- package/dist/query-href-lowering.d.ts +63 -0
- package/dist/query-href-lowering.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +169 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
- package/src/__tests__/analyzer.test.ts +53 -0
- package/src/__tests__/expression-parser.test.ts +703 -391
- package/src/__tests__/ir-reduce-op.test.ts +18 -21
- package/src/__tests__/ir-sort-comparator.test.ts +19 -20
- package/src/__tests__/lowering-registry.test.ts +141 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/profiler.test.ts +149 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +60 -21
- package/src/adapters/jsx-adapter.ts +17 -0
- package/src/adapters/parsed-expr-emitter.ts +39 -41
- package/src/analyzer-context.ts +72 -27
- package/src/analyzer.ts +226 -9
- package/src/builtin-lowering-plugins.ts +54 -0
- package/src/expression-parser.ts +1183 -927
- package/src/index.ts +35 -3
- package/src/ir-to-client-js/csr-substitute.ts +5 -0
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
- package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
- package/src/jsx-to-ir.ts +182 -43
- package/src/lowering-registry.ts +160 -0
- package/src/profiler.ts +328 -0
- package/src/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/types.ts +171 -12
- package/src/__tests__/flatmap-support.test.ts +0 -218
- 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
|
+
}
|
package/src/ssr-defaults.ts
CHANGED
|
@@ -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) {
|