@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
@@ -1,21 +1,21 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import { analyzeComponent } from '../analyzer'
3
3
  import { jsxToIR } from '../jsx-to-ir'
4
- import { parseExpression } from '../expression-parser'
4
+ import { parseExpression, asCallbackMethodCall } from '../expression-parser'
5
5
 
6
6
  /**
7
- * IR-level verification for `.reduce(fn, init)` (#1448 Tier C). The
8
- * parser intercepts the arithmetic-fold catalogue into a structured
9
- * `array-method` + `ReduceOp` node this test pins the source-string
10
- * that survives through `jsxToIR` to an `IRExpression`, then re-parses
11
- * it to confirm the structured fold shape adapters consume.
7
+ * IR-level verification for `.reduce(fn, init)` (#2018 P5). The parser no
8
+ * longer folds the reducer into a structured `ReduceOp`; `.reduce` arrives as a
9
+ * generic `call` whose callee is `<recv>.reduce` and whose args are the
10
+ * comparator `arrow` + the init literal. The adapter serializes the arrow body
11
+ * to the runtime evaluator. This test pins that the generic callback shape
12
+ * survives the analyzer → IR boundary.
12
13
  *
13
- * Hydration and template-emit correctness are pinned at the adapter
14
- * conformance layer (`reduce-*` fixtures in `packages/adapter-tests/
15
- * fixtures/methods/`).
14
+ * Hydration / template-emit / Go==Perl==JS fold correctness are pinned at the
15
+ * adapter conformance + eval-vectors layers.
16
16
  */
17
17
  describe('reduce(fn, init) IR shape', () => {
18
- test('sum over a struct field re-parses to a numeric ReduceOp', () => {
18
+ test('sum over a struct field parses to a generic reduce callback', () => {
19
19
  const source = `
20
20
  'use client'
21
21
  import { createSignal } from '@barefootjs/client'
@@ -35,17 +35,14 @@ describe('reduce(fn, init) IR shape', () => {
35
35
  expect(exprNode?.type).toBe('expression')
36
36
  if (exprNode?.type !== 'expression') return
37
37
 
38
- // The IR carries the expression as a source string (the same shape
39
- // adapters re-parse at emit time). Round-trip it to confirm the
40
- // ReduceOp catalogue match survives the analyzer → IR boundary.
41
38
  const parsed = parseExpression(exprNode.expr)
42
- expect(parsed.kind).toBe('array-method')
43
- if (parsed.kind !== 'array-method') return
44
- expect(parsed.method).toBe('reduce')
45
- if (parsed.method !== 'reduce') return
46
- expect(parsed.reduceOp.op).toBe('+')
47
- expect(parsed.reduceOp.key).toEqual({ kind: 'field', field: 'duration' })
48
- expect(parsed.reduceOp.type).toBe('numeric')
49
- expect(parsed.reduceOp.init).toBe('0')
39
+ const cb = asCallbackMethodCall(parsed)
40
+ expect(cb).not.toBeNull()
41
+ expect(cb!.method).toBe('reduce')
42
+ expect(cb!.arrow.params).toEqual(['sum', 't'])
43
+ // Reducer body: `sum + t.duration`.
44
+ expect(cb!.arrow.body.kind).toBe('binary')
45
+ // The init literal travels as the trailing argument.
46
+ expect(cb!.args).toEqual([{ kind: 'literal', value: 0, literalType: 'number', raw: '0' }])
50
47
  })
51
48
  })
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import { analyzeComponent } from '../analyzer'
3
3
  import { jsxToIR } from '../jsx-to-ir'
4
+ import { sortComparatorFromArrow } from '../expression-parser'
4
5
 
5
6
  describe('sort().map() / toSorted().map()', () => {
6
7
  test('sort((a, b) => a.price - b.price).map() produces sortComparator (asc)', () => {
@@ -31,11 +32,10 @@ describe('sort().map() / toSorted().map()', () => {
31
32
  expect(loop).toBeDefined()
32
33
  if (loop?.type === 'loop') {
33
34
  expect(loop.sortComparator).toBeDefined()
34
- expect(loop.sortComparator!.keys).toHaveLength(1)
35
- expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
36
- expect(loop.sortComparator!.keys[0].type).toBe('numeric')
37
- expect(loop.sortComparator!.keys[0].direction).toBe('asc')
38
- expect(loop.sortComparator!.method).toBe('sort')
35
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
36
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
37
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
38
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('asc')
39
39
  expect(loop.sortComparator!.paramA).toBe('a')
40
40
  expect(loop.sortComparator!.paramB).toBe('b')
41
41
  expect(loop.array).toBe('products()')
@@ -70,11 +70,10 @@ describe('sort().map() / toSorted().map()', () => {
70
70
  expect(loop).toBeDefined()
71
71
  if (loop?.type === 'loop') {
72
72
  expect(loop.sortComparator).toBeDefined()
73
- expect(loop.sortComparator!.keys).toHaveLength(1)
74
- expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
75
- expect(loop.sortComparator!.keys[0].type).toBe('numeric')
76
- expect(loop.sortComparator!.keys[0].direction).toBe('desc')
77
- expect(loop.sortComparator!.method).toBe('toSorted')
73
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
74
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
75
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
76
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('desc')
78
77
  }
79
78
  }
80
79
  })
@@ -107,10 +106,10 @@ describe('sort().map() / toSorted().map()', () => {
107
106
  expect(loop.filterPredicate).toBeDefined()
108
107
  expect(loop.filterPredicate!.param).toBe('t')
109
108
  expect(loop.sortComparator).toBeDefined()
110
- expect(loop.sortComparator!.keys).toHaveLength(1)
111
- expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'priority' })
112
- expect(loop.sortComparator!.keys[0].type).toBe('numeric')
113
- expect(loop.sortComparator!.keys[0].direction).toBe('asc')
109
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
110
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'priority' })
111
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
112
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('asc')
114
113
  expect(loop.chainOrder).toBe('filter-sort')
115
114
  expect(loop.array).toBe('todos()')
116
115
  }
@@ -176,7 +175,7 @@ describe('sort().map() / toSorted().map()', () => {
176
175
  expect(loop?.type).toBe('loop')
177
176
  if (loop?.type === 'loop') {
178
177
  expect(loop.sortComparator).toBeDefined()
179
- expect(loop.sortComparator!.keys).toEqual([
178
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
180
179
  { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'desc' },
181
180
  { key: { kind: 'field', field: 'name' }, type: 'string', direction: 'asc' },
182
181
  ])
@@ -209,7 +208,7 @@ describe('sort().map() / toSorted().map()', () => {
209
208
  const loop = ir!.children.find(c => c.type === 'loop')
210
209
  if (loop?.type === 'loop') {
211
210
  expect(loop.sortComparator).toBeDefined()
212
- expect(loop.sortComparator!.keys).toEqual([
211
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
213
212
  { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
214
213
  ])
215
214
  }
@@ -241,7 +240,7 @@ describe('sort().map() / toSorted().map()', () => {
241
240
  const loop = ir!.children.find(c => c.type === 'loop')
242
241
  if (loop?.type === 'loop') {
243
242
  expect(loop.sortComparator).toBeDefined()
244
- expect(loop.sortComparator!.keys).toEqual([
243
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
245
244
  { key: { kind: 'self' }, type: 'auto', direction: 'asc' },
246
245
  ])
247
246
  }
@@ -273,7 +272,7 @@ describe('sort().map() / toSorted().map()', () => {
273
272
  const loop = ir!.children.find(c => c.type === 'loop')
274
273
  if (loop?.type === 'loop') {
275
274
  expect(loop.sortComparator).toBeDefined()
276
- expect(loop.sortComparator!.keys).toEqual([
275
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
277
276
  { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
278
277
  ])
279
278
  // block body unwraps to the returned expression, keeping the
@@ -310,7 +309,7 @@ describe('sort().map() / toSorted().map()', () => {
310
309
  expect(loop.sortComparator).toBeDefined()
311
310
  // The `=== ? 0` arm is a tie; direction comes from the inner
312
311
  // relational ternary (a.rank > b.rank ? 1 : -1 → ascending).
313
- expect(loop.sortComparator!.keys).toEqual([
312
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
314
313
  { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
315
314
  ])
316
315
  }
@@ -342,7 +341,7 @@ describe('sort().map() / toSorted().map()', () => {
342
341
  const loop = ir!.children.find(c => c.type === 'loop')
343
342
  if (loop?.type === 'loop') {
344
343
  expect(loop.sortComparator).toBeDefined()
345
- expect(loop.sortComparator!.keys).toEqual([
344
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
346
345
  { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
347
346
  { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
348
347
  ])
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Lowering-plugin registry (#2057) — the one seam every lowering flows through.
3
+ * These tests guarantee the mechanism works: a registered plugin's matcher is
4
+ * bound per-component and produces a backend-neutral node, and the registry gates
5
+ * on the plugin's own import recognition. They use a standalone SAMPLE plugin so
6
+ * they exercise the mechanism itself, independent of the built-in plugins (like
7
+ * `queryHref`) the compiler registers by default — see `builtin-lowering-plugins`.
8
+ */
9
+
10
+ import { describe, test, expect, afterEach } from 'bun:test'
11
+ import {
12
+ registerLoweringPlugin,
13
+ getLoweringPlugins,
14
+ prepareLoweringMatchers,
15
+ matchLoweringCall,
16
+ __resetLoweringPluginsForTest,
17
+ type LoweringPlugin,
18
+ type IRMetadata,
19
+ type ParsedExpr,
20
+ } from '../index'
21
+
22
+ // A minimal sample plugin: active only when the component imports anything from
23
+ // `@sample/pkg`, and lowers a `demo(...)` call to a neutral `helper-call` node.
24
+ // This is exactly the shape a first-party / userland package would register.
25
+ const samplePlugin: LoweringPlugin = {
26
+ name: 'sample-demo',
27
+ prepare(metadata) {
28
+ const active = metadata.imports.some(i => i.source === '@sample/pkg' && !i.isTypeOnly)
29
+ if (!active) return null
30
+ return (callee, args) =>
31
+ callee.kind === 'identifier' && callee.name === 'demo'
32
+ ? { kind: 'helper-call', helper: 'demo', args }
33
+ : null
34
+ },
35
+ }
36
+
37
+ function metadataImporting(source: string): IRMetadata {
38
+ return {
39
+ imports: [{ source, isTypeOnly: false, specifiers: [] }],
40
+ } as unknown as IRMetadata
41
+ }
42
+
43
+ const demoCall = { kind: 'identifier', name: 'demo' } as ParsedExpr
44
+ const otherCall = { kind: 'identifier', name: 'other' } as ParsedExpr
45
+ const arg = { kind: 'literal', value: 'x', literalType: 'string' } as ParsedExpr
46
+
47
+ // Keep the global registry clean between tests (the sample must not leak into
48
+ // other suites that assert on the registered set).
49
+ afterEach(() => {
50
+ const remaining = getLoweringPlugins().filter(p => p.name !== 'sample-demo')
51
+ __resetLoweringPluginsForTest(remaining)
52
+ })
53
+
54
+ describe('lowering-plugin registry', () => {
55
+ test('registerLoweringPlugin adds the plugin; getLoweringPlugins returns a copy', () => {
56
+ registerLoweringPlugin(samplePlugin)
57
+ const plugins = getLoweringPlugins()
58
+ expect(plugins.some(p => p.name === 'sample-demo')).toBe(true)
59
+ // Mutating the returned array can't corrupt the registry.
60
+ ;(plugins as LoweringPlugin[]).length = 0
61
+ expect(getLoweringPlugins().some(p => p.name === 'sample-demo')).toBe(true)
62
+ })
63
+
64
+ test('re-registering the same name replaces, not duplicates (idempotent)', () => {
65
+ registerLoweringPlugin(samplePlugin)
66
+ registerLoweringPlugin(samplePlugin)
67
+ expect(getLoweringPlugins().filter(p => p.name === 'sample-demo')).toHaveLength(1)
68
+ })
69
+
70
+ test('prepare gates on the plugin recognising its own import', () => {
71
+ registerLoweringPlugin(samplePlugin)
72
+ // Active: component imports from @sample/pkg.
73
+ expect(prepareLoweringMatchers(metadataImporting('@sample/pkg'))).toHaveLength(1)
74
+ // Inactive: unrelated import → no matcher, so the adapter skips it entirely.
75
+ expect(prepareLoweringMatchers(metadataImporting('react'))).toHaveLength(0)
76
+ })
77
+
78
+ test('a bound matcher lowers a recognised call to its neutral node', () => {
79
+ registerLoweringPlugin(samplePlugin)
80
+ const [matcher] = prepareLoweringMatchers(metadataImporting('@sample/pkg'))
81
+ expect(matcher(demoCall, [arg])).toEqual({ kind: 'helper-call', helper: 'demo', args: [arg] })
82
+ // A call the plugin doesn't recognise is declined (→ generic lowering).
83
+ expect(matcher(otherCall, [arg])).toBeNull()
84
+ })
85
+
86
+ test('matchLoweringCall tries all registered plugins for the metadata', () => {
87
+ registerLoweringPlugin(samplePlugin)
88
+ expect(matchLoweringCall(demoCall, [arg], metadataImporting('@sample/pkg'))).toEqual({
89
+ kind: 'helper-call',
90
+ helper: 'demo',
91
+ args: [arg],
92
+ })
93
+ // No plugin recognises this metadata → null (the built-in queryHref plugin
94
+ // is inactive here — a `react` import isn't `@barefootjs/client`).
95
+ expect(matchLoweringCall(demoCall, [arg], metadataImporting('react'))).toBeNull()
96
+ })
97
+ })
98
+
99
+ describe('built-in plugins are applied by default', () => {
100
+ // `queryHref` is a built-in plugin the compiler registers on load — no
101
+ // `registerLoweringPlugin` call in the test. Importing `@barefootjs/jsx`
102
+ // (the import above) is what registers it, so it's present here for free.
103
+ const queryHrefCall = { kind: 'identifier', name: 'queryHref' } as ParsedExpr
104
+ const base = { kind: 'literal', value: '/x', literalType: 'string' } as ParsedExpr
105
+ const tagValue = { kind: 'literal', value: 'a', literalType: 'string' } as ParsedExpr
106
+ const paramsObj = {
107
+ kind: 'object-literal',
108
+ raw: '{ tag: "a" }',
109
+ properties: [{ key: 'tag', shorthand: false, value: tagValue }],
110
+ } as ParsedExpr
111
+
112
+ function importingQueryHref(): IRMetadata {
113
+ return {
114
+ imports: [
115
+ {
116
+ source: '@barefootjs/client',
117
+ isTypeOnly: false,
118
+ specifiers: [{ name: 'queryHref', alias: null, isDefault: false, isNamespace: false }],
119
+ },
120
+ ],
121
+ } as unknown as IRMetadata
122
+ }
123
+
124
+ test('queryHref is registered without any explicit registerLoweringPlugin call', () => {
125
+ expect(getLoweringPlugins().some(p => p.name === 'queryHref')).toBe(true)
126
+ })
127
+
128
+ test('a queryHref(base, { … }) call lowers to a neutral guard-list on the `query` helper', () => {
129
+ const node = matchLoweringCall(queryHrefCall, [base, paramsObj], importingQueryHref())
130
+ expect(node).toEqual({
131
+ kind: 'guard-list',
132
+ helper: 'query',
133
+ base,
134
+ triples: [{ guard: null, key: 'tag', value: tagValue }],
135
+ })
136
+ })
137
+
138
+ test('the built-in stays inert when the component does not import queryHref', () => {
139
+ expect(matchLoweringCall(queryHrefCall, [base, paramsObj], metadataImporting('react'))).toBeNull()
140
+ })
141
+ })
@@ -100,6 +100,29 @@ describe('reactive primitive resolver — alias fidelity (checker path)', () =>
100
100
  expect(ctx.signals[0].initialValue).toBe('0')
101
101
  })
102
102
 
103
+ test('import { createSearchParams as csp }: csp() is recognized as an env signal', () => {
104
+ // #2057: the env-signal factory resolves through the same alias path as
105
+ // createSignal. The signal must be tagged `envReader` and carry the
106
+ // as-written callee (`csp`) so client/SSR emit `csp()`, not the canonical
107
+ // name — and validateReactiveFactoryCalls must NOT raise BF110 for it.
108
+ const filePath = path.resolve(CLIENT_DIR, '../.bench-env-alias-test.tsx')
109
+ const source = `
110
+ import { createSearchParams as csp } from '@barefootjs/client'
111
+ export function SortLabel() {
112
+ const [sp] = csp()
113
+ return <p>{sp().get('sort') ?? 'none'}</p>
114
+ }
115
+ `
116
+ const program = programFor(filePath, source)
117
+ const ctx = analyzeComponent(source, filePath, undefined, program)
118
+ expect(ctx.signals).toHaveLength(1)
119
+ expect(ctx.signals[0].getter).toBe('sp')
120
+ expect(ctx.signals[0].envReader).toBe('search')
121
+ expect(ctx.signals[0].envFactory).toBe('csp')
122
+ // No spurious BF110 (unrecognized reactive factory) for the aliased call.
123
+ expect(ctx.errors.some(e => e.code === 'BF110')).toBe(false)
124
+ })
125
+
103
126
  test('user-defined function named createSignal is NOT misclassified without checker', () => {
104
127
  // Without a checker, the fast path still matches by name. This
105
128
  // documents the limitation — the fast path is optimistic. A shared
@@ -20,6 +20,7 @@ import {
20
20
  buildIdIndex,
21
21
  joinProfilerEvents,
22
22
  findUninstrumentedEffects,
23
+ evaluateProfileGates,
23
24
  } from '../profiler'
24
25
  import { buildComponentAnalysis } from '../debug'
25
26
  import type { ProfilerEvent } from '@barefootjs/shared'
@@ -533,3 +534,151 @@ describe('findUninstrumentedEffects (#1849 B6)', () => {
533
534
  expect(e1.candidates).toEqual([{ file: 'C.tsx', line: 8 }])
534
535
  })
535
536
  })
537
+
538
+ describe('agent contract: status, findings, guidance (#1841)', () => {
539
+ const src = `
540
+ 'use client'
541
+ import { createSignal, createMemo } from '@barefootjs/client'
542
+ export function Calc() {
543
+ const [count, setCount] = createSignal(0)
544
+ const a = createMemo(() => count() * 2)
545
+ return <button onClick={() => setCount(count() + 1)}>{a()}</button>
546
+ }
547
+ `
548
+ let n = 0
549
+ const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
550
+ ({ type, seq: n++, turn: null, ...f })
551
+
552
+ // A memo that re-runs 3× in a single turn → runsPerTurn 3 → flagged `hot`.
553
+ function hotRunEvents(): ProfilerEvent[] {
554
+ n = 0
555
+ const turn = 'Calc#handler:s0:click'
556
+ const events: ProfilerEvent[] = [ev('turnBegin', { handlerId: turn })]
557
+ for (let i = 0; i < 3; i++) {
558
+ events.push(ev('effectEnter', { subscriber: 'Calc#memo:a', turn }))
559
+ events.push(ev('effectExit', { subscriber: 'Calc#memo:a', dur: 1, turn }))
560
+ }
561
+ events.push(ev('turnEnd', {}))
562
+ return events
563
+ }
564
+
565
+ test('a clean run is status ok with no findings', () => {
566
+ n = 0
567
+ // One memo run in a turn → runsPerTurn 1 → not hot, nothing flagged.
568
+ const turn = 'Calc#handler:s0:click'
569
+ const events: ProfilerEvent[] = [
570
+ ev('turnBegin', { handlerId: turn }),
571
+ ev('effectEnter', { subscriber: 'Calc#memo:a', turn }),
572
+ ev('effectExit', { subscriber: 'Calc#memo:a', dur: 1, turn }),
573
+ ev('turnEnd', {}),
574
+ ]
575
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
576
+ expect(r.status).toBe('ok')
577
+ expect(r.findings).toHaveLength(0)
578
+ expect(r.coverage.ratio).toBe(1)
579
+ expect(r.guidance).toBeUndefined()
580
+ })
581
+
582
+ test('a hot subscriber becomes a warning finding with valid nextCommands', () => {
583
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events: hotRunEvents() })
584
+ expect(r.status).toBe('warning')
585
+ const hot = r.findings.find(f => f.kind === 'hot-subscriber')!
586
+ expect(hot.severity).toBe('warning')
587
+ expect(hot.actionable).toBe(true)
588
+ expect(hot.subscriber).toBe('Calc#memo:a')
589
+ // A memo id maps to a name `bf debug trace` accepts; graph is the fallback.
590
+ expect(hot.nextCommands).toContain('bf debug trace Calc a --json')
591
+ expect(hot.nextCommands).toContain('bf debug graph Calc --json')
592
+ })
593
+
594
+ test('an unresolved id is an actionable coverage-gap finding', () => {
595
+ n = 0
596
+ // A profiler-shaped id with no matching IR node → SR4 coverage gap.
597
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Calc#memo:ghost' })]
598
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
599
+ const gap = r.findings.find(f => f.kind === 'coverage-gap')!
600
+ expect(gap.actionable).toBe(true)
601
+ expect(gap.subscriber).toBe('Calc#memo:ghost')
602
+ })
603
+
604
+ test('nextCommands target the component parsed from the id, not the root', () => {
605
+ n = 0
606
+ // A scenario-file run resolves subscribers from composed children: the id's
607
+ // component (`Child`) differs from the profiled root (`Calc`). Follow-up
608
+ // commands must target `Child`, or they point an agent at the wrong file.
609
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Child#memo:ghost' })]
610
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
611
+ const gap = r.findings.find(f => f.kind === 'coverage-gap')!
612
+ expect(gap.nextCommands).toContain('bf debug trace Child ghost --json')
613
+ expect(gap.nextCommands).toContain('bf debug graph Child --json')
614
+ expect(gap.nextCommands.every(c => !c.includes('graph Calc'))).toBe(true)
615
+ })
616
+
617
+ test('coverage.ratio is clamped to 1 when the stream over-counts handlers', () => {
618
+ n = 0
619
+ // Calc exposes a single handler (handlersTotal 1), but a malformed stream
620
+ // reports two distinct turn ids — without clamping the ratio would be 2.0
621
+ // and silently pass a `--min-coverage` gate it shouldn't.
622
+ const events: ProfilerEvent[] = [
623
+ ev('effectEnter', { subscriber: 'Calc#memo:a', turn: 'Calc#handler:s0:click' }),
624
+ ev('effectEnter', { subscriber: 'Calc#memo:a', turn: 'Calc#handler:s9:click' }),
625
+ ]
626
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
627
+ expect(r.coverage.ratio).toBe(1)
628
+ expect(r.coverage.ratio).toBeLessThanOrEqual(1)
629
+ })
630
+
631
+ test('a zero-turn run emits guidance pointing at a story file', () => {
632
+ n = 0
633
+ // Handlers exist (the onClick) but none fired → no-interactions guidance.
634
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Calc#memo:a' })]
635
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
636
+ expect(r.turns).toBe(0)
637
+ expect(r.guidance?.reason).toBe('no-interactions')
638
+ expect(r.guidance?.nextCommands[0]).toContain('--scenario <story.tsx>')
639
+ })
640
+
641
+ describe('evaluateProfileGates', () => {
642
+ test('hot gate fails when a subscriber exceeds the runs/turn budget', () => {
643
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events: hotRunEvents() })
644
+ const fail = evaluateProfileGates(r, { maxRunsPerTurn: 2 })
645
+ expect(fail.passed).toBe(false)
646
+ expect(fail.failed).toContain('hot')
647
+ const pass = evaluateProfileGates(r, { maxRunsPerTurn: 5 })
648
+ expect(pass.passed).toBe(true)
649
+ })
650
+
651
+ test('bare --fail-on hot trips on any flagged hot subscriber', () => {
652
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events: hotRunEvents() })
653
+ const g = evaluateProfileGates(r, { failOn: ['hot'] })
654
+ expect(g.passed).toBe(false)
655
+ expect(g.checks[0].threshold).toBeNull()
656
+ })
657
+
658
+ test('coverage gate compares ratio against --min-coverage', () => {
659
+ n = 0
660
+ // Handlers exist but only mount runs, no turn → ratio 0.
661
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Calc#memo:a' })]
662
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
663
+ expect(r.coverage.ratio).toBe(0)
664
+ const g = evaluateProfileGates(r, { minCoverage: 0.8 })
665
+ expect(g.passed).toBe(false)
666
+ expect(g.failed).toContain('coverage')
667
+ })
668
+
669
+ test('unresolved gate counts actionable gaps against --max-unresolved', () => {
670
+ n = 0
671
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Calc#memo:ghost' })]
672
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
673
+ expect(evaluateProfileGates(r, { maxUnresolved: 0 }).passed).toBe(false)
674
+ expect(evaluateProfileGates(r, { maxUnresolved: 5 }).passed).toBe(true)
675
+ })
676
+
677
+ test('no configured gate yields an empty, passing result', () => {
678
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events: hotRunEvents() })
679
+ const g = evaluateProfileGates(r, {})
680
+ expect(g.passed).toBe(true)
681
+ expect(g.checks).toHaveLength(0)
682
+ })
683
+ })
684
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `queryHrefLocalNames` — recognises the `queryHref` import (incl. aliases) so
3
+ * adapters can gate the `queryHref(base, { … })` → query-helper lowering (#2042).
4
+ */
5
+ import { describe, test, expect } from 'bun:test'
6
+ import { compileJSX, queryHrefLocalNames, type ComponentIR } from '../index'
7
+ import { TestAdapter } from '../adapters/test-adapter'
8
+
9
+ function metadata(src: string): ComponentIR['metadata'] {
10
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter(), outputIR: true })
11
+ const ir = JSON.parse(result.files.find(f => f.type === 'ir')!.content) as ComponentIR
12
+ return ir.metadata
13
+ }
14
+
15
+ describe('queryHrefLocalNames (#2042)', () => {
16
+ test('recognises a plain queryHref import', () => {
17
+ const md = metadata(`
18
+ 'use client'
19
+ import { queryHref } from '@barefootjs/client'
20
+ export function P(props: { base: string }) {
21
+ return <a href={queryHref(props.base, {})}>x</a>
22
+ }
23
+ `)
24
+ expect([...queryHrefLocalNames(md)]).toEqual(['queryHref'])
25
+ })
26
+
27
+ test('binds to the local alias', () => {
28
+ const md = metadata(`
29
+ 'use client'
30
+ import { queryHref as qh } from '@barefootjs/client'
31
+ export function P(props: { base: string }) {
32
+ return <a href={qh(props.base, {})}>x</a>
33
+ }
34
+ `)
35
+ expect([...queryHrefLocalNames(md)]).toEqual(['qh'])
36
+ })
37
+
38
+ test('recognises the @barefootjs/client/runtime re-export too', () => {
39
+ // `queryHref` is exported from both entries; importing from the runtime entry
40
+ // must still enable SSR lowering, else the call hits BF101.
41
+ const md = metadata(`
42
+ 'use client'
43
+ import { queryHref } from '@barefootjs/client/runtime'
44
+ export function P(props: { base: string }) {
45
+ return <a href={queryHref(props.base, {})}>x</a>
46
+ }
47
+ `)
48
+ expect([...queryHrefLocalNames(md)]).toEqual(['queryHref'])
49
+ })
50
+
51
+ test('is empty when not imported', () => {
52
+ const md = metadata(`
53
+ 'use client'
54
+ export function P() { return <a href="/x">x</a> }
55
+ `)
56
+ expect(queryHrefLocalNames(md).size).toBe(0)
57
+ })
58
+ })