@barefootjs/jsx 0.19.1 → 0.21.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 (39) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  3. package/dist/compiler.d.ts.map +1 -1
  4. package/dist/date-lowering.d.ts +33 -0
  5. package/dist/date-lowering.d.ts.map +1 -0
  6. package/dist/index.js +663 -218
  7. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/imports.d.ts +2 -2
  10. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/types.d.ts +14 -1
  13. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  14. package/dist/jsx-to-ir.d.ts.map +1 -1
  15. package/dist/rich-type-evidence.d.ts +67 -0
  16. package/dist/rich-type-evidence.d.ts.map +1 -0
  17. package/dist/rich-type-refusal.d.ts +35 -0
  18. package/dist/rich-type-refusal.d.ts.map +1 -0
  19. package/dist/types.d.ts +9 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/__tests__/client-js-generation.test.ts +59 -0
  23. package/src/__tests__/date-lowering.test.ts +232 -0
  24. package/src/__tests__/nested-loop-reactive-attrs.test.ts +56 -0
  25. package/src/__tests__/rich-type-method-refusal.test.ts +323 -0
  26. package/src/analyzer.ts +21 -2
  27. package/src/builtin-lowering-plugins.ts +2 -1
  28. package/src/compiler.ts +3 -0
  29. package/src/date-lowering.ts +117 -0
  30. package/src/ir-to-client-js/emit-reactive.ts +103 -2
  31. package/src/ir-to-client-js/generate-init.ts +11 -4
  32. package/src/ir-to-client-js/imports.ts +4 -0
  33. package/src/ir-to-client-js/index.ts +2 -0
  34. package/src/ir-to-client-js/reactivity.ts +13 -2
  35. package/src/ir-to-client-js/types.ts +15 -0
  36. package/src/jsx-to-ir.ts +128 -3
  37. package/src/rich-type-evidence.ts +159 -0
  38. package/src/rich-type-refusal.ts +311 -0
  39. package/src/types.ts +9 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `Date` lowering plugin (#2274) — the first catalogued rich-type lowering
3
+ * built on top of the #2273 refusal seam. Covers the matcher's own
4
+ * recognition rules directly (mirrors `query-href-recognition.test.ts`'s
5
+ * `metadata()` extraction), plus the BF021-exemption integration promised by
6
+ * `rich-type-refusal.ts`'s module doc: a call the registry claims must not
7
+ * also fire BF021, while an un-catalogued Date method on the same receiver
8
+ * still does.
9
+ */
10
+ import { describe, test, expect, afterEach } from 'bun:test'
11
+ import { compileJSX, type ComponentIR } from '../index'
12
+ import { TestAdapter } from '../adapters/test-adapter'
13
+ import { parseExpression, type ParsedExpr } from '../expression-parser'
14
+ import { datePlugin, DATE_METHODS } from '../date-lowering'
15
+ import { registerLoweringPlugin, __resetLoweringPluginsForTest, getLoweringPlugins } from '../lowering-registry'
16
+ import { ErrorCodes } from '../errors'
17
+
18
+ function metadata(src: string): ComponentIR['metadata'] {
19
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter(), outputIR: true })
20
+ const ir = JSON.parse(result.files.find((f) => f.type === 'ir')!.content) as ComponentIR
21
+ return ir.metadata
22
+ }
23
+
24
+ /** Parse a call expression source and return its callee + args, the exact
25
+ * shape a `LoweringMatcher` receives. */
26
+ function callParts(expr: string): { callee: ParsedExpr; args: ParsedExpr[] } {
27
+ const parsed = parseExpression(expr)
28
+ if (parsed.kind !== 'call') throw new Error(`expected a call expression, got ${parsed.kind}`)
29
+ return { callee: parsed.callee, args: parsed.args }
30
+ }
31
+
32
+ describe('DATE_METHODS catalogue', () => {
33
+ test('is exactly the 8 zero-arg Date.prototype accessors the spec entry names', () => {
34
+ expect([...DATE_METHODS].sort()).toEqual(
35
+ [
36
+ 'getUTCFullYear',
37
+ 'getUTCMonth',
38
+ 'getUTCDate',
39
+ 'getUTCHours',
40
+ 'getUTCMinutes',
41
+ 'getUTCSeconds',
42
+ 'getTime',
43
+ 'toISOString',
44
+ ].sort(),
45
+ )
46
+ })
47
+ })
48
+
49
+ describe('datePlugin matcher recognition (#2274)', () => {
50
+ test('props-object member chain (props.createdAt.toISOString()) matches', () => {
51
+ const md = metadata(`
52
+ export function Foo(props: { createdAt: Date }) {
53
+ return <div>{props.createdAt.toISOString()}</div>
54
+ }
55
+ `)
56
+ const matcher = datePlugin.prepare(md)
57
+ expect(matcher).not.toBeNull()
58
+ const { callee, args } = callParts('props.createdAt.toISOString()')
59
+ expect(matcher!(callee, args)).toEqual({
60
+ kind: 'helper-call',
61
+ helper: 'date',
62
+ args: [(callee as { object: ParsedExpr }).object, { kind: 'literal', value: 'toISOString', literalType: 'string' }],
63
+ })
64
+ })
65
+
66
+ test('destructured Date prop (createdAt.getUTCFullYear()) matches', () => {
67
+ const md = metadata(`
68
+ export function Foo({ createdAt }: { createdAt: Date }) {
69
+ return <div>{createdAt.getUTCFullYear()}</div>
70
+ }
71
+ `)
72
+ const matcher = datePlugin.prepare(md)
73
+ expect(matcher).not.toBeNull()
74
+ const { callee, args } = callParts('createdAt.getUTCFullYear()')
75
+ expect(matcher!(callee, args)).toEqual({
76
+ kind: 'helper-call',
77
+ helper: 'date',
78
+ args: [(callee as { object: ParsedExpr }).object, { kind: 'literal', value: 'getUTCFullYear', literalType: 'string' }],
79
+ })
80
+ })
81
+
82
+ test('renamed destructured Date prop ({ createdAt: c }) resolves via the source name', () => {
83
+ const md = metadata(`
84
+ export function Foo({ createdAt: c }: { createdAt: Date }) {
85
+ return <div>{c.getTime()}</div>
86
+ }
87
+ `)
88
+ const matcher = datePlugin.prepare(md)
89
+ expect(matcher).not.toBeNull()
90
+ const { callee, args } = callParts('c.getTime()')
91
+ expect(matcher!(callee, args)).toEqual({
92
+ kind: 'helper-call',
93
+ helper: 'date',
94
+ args: [(callee as { object: ParsedExpr }).object, { kind: 'literal', value: 'getTime', literalType: 'string' }],
95
+ })
96
+ })
97
+
98
+ test('#2274: destructured Date prop now carries a real propsParams TypeInfo (analyzer widening)', () => {
99
+ // Pre-widening this degraded to { kind: 'unknown', raw: 'unknown' }
100
+ // (analyzer.ts's #2150 primitives-only gate) — resolveReceiverType's
101
+ // destructured path reads the type from propsType, not propsParams, so
102
+ // the widening isn't load-bearing for the matcher above, but propsParams
103
+ // is itself observable IR metadata this plugin's existence is meant to
104
+ // unlock (see analyzer.ts's docstring on `isResolvablePrimitive`).
105
+ const md = metadata(`
106
+ export function Foo({ createdAt }: { createdAt: Date }) {
107
+ return <div>{createdAt.toISOString()}</div>
108
+ }
109
+ `)
110
+ const param = md.propsParams.find((p) => p.name === 'createdAt')
111
+ expect(param?.type.kind).toBe('interface')
112
+ expect(param?.type.raw).toBe('Date')
113
+ })
114
+
115
+ test('toLocaleDateString is not a catalogued method — declines (falls back to BF021)', () => {
116
+ const md = metadata(`
117
+ export function Foo({ createdAt }: { createdAt: Date }) {
118
+ return <div>{createdAt.toLocaleDateString()}</div>
119
+ }
120
+ `)
121
+ const matcher = datePlugin.prepare(md)
122
+ expect(matcher).not.toBeNull() // Date IS reachable — the gate stays active …
123
+ const { callee, args } = callParts('createdAt.toLocaleDateString()')
124
+ expect(matcher!(callee, args)).toBeNull() // … but this specific method declines.
125
+ })
126
+
127
+ test('a catalogued method name called with an argument declines (zero-arg only)', () => {
128
+ const md = metadata(`
129
+ export function Foo({ createdAt }: { createdAt: Date }) {
130
+ return <div>{createdAt.getTime()}</div>
131
+ }
132
+ `)
133
+ const matcher = datePlugin.prepare(md)
134
+ expect(matcher).not.toBeNull()
135
+ const { callee, args } = callParts('createdAt.getTime(1)')
136
+ expect(matcher!(callee, args)).toBeNull()
137
+ })
138
+
139
+ test('a non-Date receiver never activates the plugin (prepare declines entirely)', () => {
140
+ const md = metadata(`
141
+ export function Foo({ createdAt }: { createdAt: string }) {
142
+ return <div>{createdAt.toUpperCase()}</div>
143
+ }
144
+ `)
145
+ expect(datePlugin.prepare(md)).toBeNull()
146
+ })
147
+
148
+ test('a component with no props type at all never activates the plugin', () => {
149
+ const md = metadata(`
150
+ export function Foo() {
151
+ return <div>static</div>
152
+ }
153
+ `)
154
+ expect(datePlugin.prepare(md)).toBeNull()
155
+ })
156
+ })
157
+
158
+ describe('BF021 exemption via the real datePlugin (#2274 seam)', () => {
159
+ afterEach(() => {
160
+ __resetLoweringPluginsForTest(getLoweringPlugins().filter((p) => p.name !== 'date'))
161
+ })
162
+
163
+ function bf021Count(source: string): number {
164
+ registerLoweringPlugin(datePlugin)
165
+ const result = compileJSX(source.trimStart(), 'Test.tsx', { adapter: new TestAdapter() })
166
+ return result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN).length
167
+ }
168
+
169
+ test('a catalogued call the plugin claims fires zero BF021', () => {
170
+ expect(
171
+ bf021Count(`
172
+ export function Foo({ createdAt }: { createdAt: Date }) {
173
+ return <div>{createdAt.toISOString()}</div>
174
+ }
175
+ `),
176
+ ).toBe(0)
177
+ })
178
+
179
+ test('an un-catalogued Date method on the same prop still fires BF021', () => {
180
+ expect(
181
+ bf021Count(`
182
+ export function Foo({ createdAt }: { createdAt: Date }) {
183
+ return <div>{createdAt.toLocaleDateString()}</div>
184
+ }
185
+ `),
186
+ ).toBe(1)
187
+ })
188
+ })
189
+
190
+ describe('client-JS lowering (#2292)', () => {
191
+ function clientJs(src: string): string {
192
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter() })
193
+ return result.files.find((f) => f.type === 'clientJs')!.content
194
+ }
195
+
196
+ test('lowers a Date-typed prop accessor call to the date() runtime helper', () => {
197
+ const js = clientJs(`
198
+ export function Foo({ createdAt }: { createdAt: Date }) {
199
+ return <div>{createdAt.toISOString()}</div>
200
+ }
201
+ `)
202
+ expect(js).toContain('date(_p.createdAt, "toISOString")')
203
+ // auto-imported from the runtime barrel (imports.ts RUNTIME_IMPORT_CANDIDATES)
204
+ expect(js).toMatch(/import\s*\{[^}]*\bdate\b[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
205
+ })
206
+
207
+ test('leaves a non-Date receiver method call raw (parity: only what datePlugin claims)', () => {
208
+ const js = clientJs(`
209
+ export function Foo({ label }: { label: string }) {
210
+ return <div>{label.toUpperCase()}</div>
211
+ }
212
+ `)
213
+ expect(js).not.toContain('date(')
214
+ expect(js).toContain('toUpperCase()')
215
+ })
216
+
217
+ test('protects a template-literal static segment that matches the call text (#2294)', () => {
218
+ // The real call is in the ${…} interpolation; an identical-looking run
219
+ // of text sits in the static segment. Template-aware string protection
220
+ // must keep the non-global .replace from rewriting the static text
221
+ // before the real call site (Copilot review).
222
+ const js = clientJs(`
223
+ export function Foo({ createdAt }: { createdAt: Date }) {
224
+ return <div>{\`createdAt.toISOString() = \${createdAt.toISOString()}\`}</div>
225
+ }
226
+ `)
227
+ // the real (interpolated) call is lowered
228
+ expect(js).toContain('date(_p.createdAt, "toISOString")')
229
+ // the static segment is preserved verbatim, not rewritten to date(...)
230
+ expect(js).toContain('createdAt.toISOString() = ')
231
+ })
232
+ })
@@ -342,4 +342,60 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
342
342
  expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(panel\(\)\.text\)/)
343
343
  expect(content).toContain("setAttribute('class'")
344
344
  })
345
+
346
+ test('reactive text child of a triple-nested inner loop read through an opaque helper gets an update effect (#2282)', () => {
347
+ // #2264 fixed the case where `classifyReactivity` proves the text
348
+ // reactive via the loop-param path (bare `panel.text`). It left a
349
+ // sibling gap: `collectLoopChildReactiveTexts` had no Solid-style
350
+ // AST-flag fallback, so a text read through an opaque helper the
351
+ // classifier can't see through (`labelAt(pi)` where `const labelAt =
352
+ // (i) => labels()[i]`) still silently dropped its update effect — while
353
+ // `collectLoopChildReactiveAttrs` already had that fallback (#1673,
354
+ // see `reactive-attrs-in-map.test.ts`), so the sibling `className`
355
+ // effect on the SAME element kept working. Reported as #2282 ("child
356
+ // inlined into a parent island drops the innermost reactive text
357
+ // effect"); the issue's own literal `{panel.text}` repro snippet
358
+ // doesn't reproduce it (that shape is exactly what #2264 already
359
+ // fixed) — this test pins the actual asymmetry root-caused during
360
+ // investigation, using the opaque-helper shape that does reproduce.
361
+ const source = `
362
+ 'use client'
363
+ import { createSignal } from '@barefootjs/client'
364
+
365
+ type Panel = { id: number; cls: string }
366
+ type Band = { id: string; panels: Panel[] }
367
+ type Page = { id: string; bands: Band[] }
368
+
369
+ export function Doc2() {
370
+ const [pages] = createSignal<Page[]>([])
371
+ const [labels] = createSignal<string[]>([])
372
+ const labelAt = (i: number) => labels()[i]
373
+ return (
374
+ <div>
375
+ {pages().map(page => (
376
+ <div key={page.id}>
377
+ {page.bands.map(band => (
378
+ <div key={band.id}>
379
+ {band.panels.map((panel, pi) => (
380
+ <div key={panel.id} className={panel.cls}>{labelAt(pi)}</div>
381
+ ))}
382
+ </div>
383
+ ))}
384
+ </div>
385
+ ))}
386
+ </div>
387
+ )
388
+ }
389
+ `
390
+ const result = compileJSX(source, 'Doc2.tsx', { adapter })
391
+ expect(result.errors).toHaveLength(0)
392
+ const content = result.files.find((f) => f.type === 'clientJs')!.content
393
+
394
+ // The helper call must appear inside a createEffect alongside the
395
+ // textContent write — `labelAt(` also appears in the static template
396
+ // clone, so asserting it independently would pass even with the
397
+ // effect missing (the exact regression here).
398
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(labelAt\(pi\)\)/)
399
+ expect(content).toContain("setAttribute('class'")
400
+ })
345
401
  })
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Rich-type method-call refusal (BF021, #2273).
3
+ *
4
+ * A method call on a prop typed as a built-in host rich type (`Date`,
5
+ * `Map`, …) has no catalogued lowering in any adapter — left unchecked it
6
+ * transliterates into the target template's own syntax and dies at request
7
+ * time. `checkRichTypeMethodCalls` (rich-type-refusal.ts) is wired into
8
+ * `compileJSX` (not the bare analyzer/jsxToIR pipeline other BF021 tests in
9
+ * this directory use), so these tests go through `compileJSX` directly.
10
+ *
11
+ * This suite intentionally exercises the refusal against an EMPTY plugin
12
+ * registry (only the "registry-claimed call is exempt" test opts a plugin
13
+ * back in, and cleans up after itself) — `Date`/`Map`/… calls here must
14
+ * still have no catalogued lowering for the fires/silent split below to mean
15
+ * anything. `bun test` runs every file in one process, and a sibling file
16
+ * that imports the package entry (`../index`) registers the real built-ins
17
+ * (`queryHref`, `date`, #2274) as a global side effect — including `date`,
18
+ * which legitimately claims `.toISOString()`. Snapshot + clear + restore
19
+ * around the whole file so this suite's result never depends on which other
20
+ * test files happened to run first in the same process.
21
+ */
22
+
23
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
24
+ import { compileJSX } from '../compiler'
25
+ import { ErrorCodes } from '../errors'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+ import { registerLoweringPlugin, __resetLoweringPluginsForTest, getLoweringPlugins, type LoweringPlugin } from '../lowering-registry'
28
+
29
+ const adapter = new TestAdapter()
30
+
31
+ let savedPlugins: readonly LoweringPlugin[]
32
+ beforeAll(() => {
33
+ savedPlugins = getLoweringPlugins()
34
+ __resetLoweringPluginsForTest([])
35
+ })
36
+ afterAll(() => {
37
+ __resetLoweringPluginsForTest(savedPlugins)
38
+ })
39
+
40
+ function bf021(source: string, filePath = 'Test.tsx') {
41
+ const result = compileJSX(source, filePath, { adapter })
42
+ return result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
43
+ }
44
+
45
+ describe('rich-type method-call refusal — fires (BF021)', () => {
46
+ test('inline-destructured Date in text position', () => {
47
+ const errors = bf021(`
48
+ export function Foo({ createdAt }: { createdAt: Date }) {
49
+ return <div>{createdAt.toISOString()}</div>
50
+ }
51
+ `)
52
+ expect(errors).toHaveLength(1)
53
+ expect(errors[0].message).toContain("'.toISOString()'")
54
+ expect(errors[0].message).toContain("'createdAt'")
55
+ expect(errors[0].message).toContain("'Date'")
56
+ })
57
+
58
+ test('props-object member chain in attribute position', () => {
59
+ const errors = bf021(`
60
+ export function Foo(props: { d: Date }) {
61
+ return <div data-year={props.d.getUTCFullYear()} />
62
+ }
63
+ `)
64
+ expect(errors).toHaveLength(1)
65
+ expect(errors[0].message).toContain("'.getUTCFullYear()'")
66
+ expect(errors[0].message).toContain("'props.d'")
67
+ expect(errors[0].message).toContain("'Date'")
68
+ })
69
+
70
+ test('named interface Props Date field (typeDefinitions deref)', () => {
71
+ const errors = bf021(`
72
+ interface Props { createdAt: Date }
73
+ export function Foo({ createdAt }: Props) {
74
+ return <div>{createdAt.getFullYear()}</div>
75
+ }
76
+ `)
77
+ expect(errors).toHaveLength(1)
78
+ expect(errors[0].message).toContain("'.getFullYear()'")
79
+ expect(errors[0].message).toContain("'createdAt'")
80
+ expect(errors[0].message).toContain("'Date'")
81
+ })
82
+
83
+ test('optional-chained call', () => {
84
+ const errors = bf021(`
85
+ export function Foo({ d }: { d: Date | undefined }) {
86
+ return <div>{d?.toISOString()}</div>
87
+ }
88
+ `)
89
+ expect(errors).toHaveLength(1)
90
+ expect(errors[0].message).toContain("'.toISOString()'")
91
+ })
92
+
93
+ test('Date | null union resolves to Date', () => {
94
+ const errors = bf021(`
95
+ export function Foo({ d }: { d: Date | null }) {
96
+ return <div>{d.toISOString()}</div>
97
+ }
98
+ `)
99
+ expect(errors).toHaveLength(1)
100
+ expect(errors[0].message).toContain("'Date'")
101
+ })
102
+
103
+ test('loop-item member (items.map(i => i.at.getTime()))', () => {
104
+ const errors = bf021(`
105
+ export function Foo({ items }: { items: { at: Date }[] }) {
106
+ return <ul>{items.map(i => <li>{i.at.getTime()}</li>)}</ul>
107
+ }
108
+ `)
109
+ expect(errors).toHaveLength(1)
110
+ expect(errors[0].message).toContain("'.getTime()'")
111
+ // A loop item is prop-DERIVED but not itself a prop — the message must
112
+ // not call it one (only bare / props-object receivers earn "prop").
113
+ expect(errors[0].message).toContain("on 'i.at'")
114
+ expect(errors[0].message).not.toContain("prop 'i.at'")
115
+ expect(errors[0].message).toContain("'Date'")
116
+ })
117
+
118
+ test('renamed destructured prop ({ createdAt: c })', () => {
119
+ const errors = bf021(`
120
+ export function Foo({ createdAt: c }: { createdAt: Date }) {
121
+ return <div>{c.toISOString()}</div>
122
+ }
123
+ `)
124
+ expect(errors).toHaveLength(1)
125
+ expect(errors[0].message).toContain("'.toISOString()'")
126
+ expect(errors[0].message).toContain("prop 'c'")
127
+ expect(errors[0].message).toContain("'Date'")
128
+ })
129
+
130
+ test('conditional-branch call without @client', () => {
131
+ const errors = bf021(`
132
+ export function Foo({ d }: { d: Date | null }) {
133
+ return <div>{d && <span>{d.toISOString()}</span>}</div>
134
+ }
135
+ `)
136
+ expect(errors).toHaveLength(1)
137
+ expect(errors[0].message).toContain("'.toISOString()'")
138
+ })
139
+
140
+ test('two distinct receivers at the same expression report separately', () => {
141
+ const errors = bf021(`
142
+ export function Foo({ a, b }: { a: Date; b: Date }) {
143
+ return <div>{a.getTime() + b.getTime()}</div>
144
+ }
145
+ `)
146
+ expect(errors).toHaveLength(2)
147
+ expect(errors[0].message).toContain("prop 'a'")
148
+ expect(errors[1].message).toContain("prop 'b'")
149
+ })
150
+
151
+ test('Date in component-prop position', () => {
152
+ const errors = bf021(`
153
+ function Bar(props: { value: string }) {
154
+ return <div>{props.value}</div>
155
+ }
156
+ export function Foo({ createdAt }: { createdAt: Date }) {
157
+ return <Bar value={createdAt.toISOString()} />
158
+ }
159
+ `)
160
+ expect(errors).toHaveLength(1)
161
+ expect(errors[0].message).toContain("'.toISOString()'")
162
+ expect(errors[0].message).toContain("'createdAt'")
163
+ })
164
+
165
+ test('Map.get() (broad host-type list)', () => {
166
+ const errors = bf021(`
167
+ export function Foo({ m }: { m: Map<string, string> }) {
168
+ return <div>{m.get('x')}</div>
169
+ }
170
+ `)
171
+ expect(errors).toHaveLength(1)
172
+ expect(errors[0].message).toContain("'.get()'")
173
+ expect(errors[0].message).toContain("'Map'")
174
+ })
175
+
176
+ test('diagnostic carries the @client suggestion', () => {
177
+ const errors = bf021(`
178
+ export function Foo({ createdAt }: { createdAt: Date }) {
179
+ return <div>{createdAt.toISOString()}</div>
180
+ }
181
+ `)
182
+ expect(errors[0].severity).toBe('error')
183
+ expect(errors[0].suggestion?.message).toContain('@client')
184
+ })
185
+ })
186
+
187
+ describe('rich-type method-call refusal — silent (no BF021)', () => {
188
+ test('/* @client */-prefixed Date call', () => {
189
+ const errors = bf021(`
190
+ export function Foo({ createdAt }: { createdAt: Date }) {
191
+ return <div>{/* @client */ createdAt.toISOString()}</div>
192
+ }
193
+ `)
194
+ expect(errors).toHaveLength(0)
195
+ })
196
+
197
+ test('/* @client */-wrapped conditional branch', () => {
198
+ const errors = bf021(`
199
+ export function Foo({ d }: { d: Date | null }) {
200
+ return <div>{/* @client */ d && <span>{d.toISOString()}</span>}</div>
201
+ }
202
+ `)
203
+ expect(errors).toHaveLength(0)
204
+ })
205
+
206
+ test('module const sharing a propsType field name (object-props mode)', () => {
207
+ const errors = bf021(`
208
+ const version = 'v1'
209
+ export function Foo(props: { version: Map<string, string> }) {
210
+ return <div>{version.toUpperCase()}</div>
211
+ }
212
+ `)
213
+ expect(errors).toHaveLength(0)
214
+ })
215
+
216
+ test('string method on string prop', () => {
217
+ const errors = bf021(`
218
+ export function Foo({ s }: { s: string }) {
219
+ return <div>{s.toUpperCase()}</div>
220
+ }
221
+ `)
222
+ expect(errors).toHaveLength(0)
223
+ })
224
+
225
+ test('array method on array prop', () => {
226
+ const errors = bf021(`
227
+ export function Foo({ items }: { items: string[] }) {
228
+ return <div>{items.join(',')}</div>
229
+ }
230
+ `)
231
+ expect(errors).toHaveLength(0)
232
+ })
233
+
234
+ test('untyped receiver (no type annotation)', () => {
235
+ const errors = bf021(`
236
+ export function Foo({ d }) {
237
+ return <div>{d.toISOString()}</div>
238
+ }
239
+ `)
240
+ expect(errors).toHaveLength(0)
241
+ })
242
+
243
+ test('generic type-parameter receiver', () => {
244
+ const errors = bf021(`
245
+ export function Foo<T>({ d }: { d: T }) {
246
+ return <div>{d.toISOString()}</div>
247
+ }
248
+ `)
249
+ expect(errors).toHaveLength(0)
250
+ })
251
+
252
+ test('imported named type receiver', () => {
253
+ const errors = bf021(`
254
+ import type { Widget } from './widget'
255
+ export function Foo({ w }: { w: Widget }) {
256
+ return <div>{w.render()}</div>
257
+ }
258
+ `)
259
+ expect(errors).toHaveLength(0)
260
+ })
261
+
262
+ test('signal getter call result (d().toISOString())', () => {
263
+ const errors = bf021(`
264
+ 'use client'
265
+ import { createSignal } from '@barefootjs/client'
266
+ export function Foo() {
267
+ const [d, setD] = createSignal(new Date())
268
+ return <div>{d().toISOString()}</div>
269
+ }
270
+ `)
271
+ expect(errors).toHaveLength(0)
272
+ })
273
+
274
+ test('local-function call (not a member call on the receiver)', () => {
275
+ const errors = bf021(`
276
+ function formatDate(x: Date): string { return x.toString() }
277
+ export function Foo({ createdAt }: { createdAt: Date }) {
278
+ return <div>{formatDate(createdAt)}</div>
279
+ }
280
+ `)
281
+ expect(errors).toHaveLength(0)
282
+ })
283
+
284
+ test('.length non-call access', () => {
285
+ const errors = bf021(`
286
+ export function Foo({ items }: { items: string[] }) {
287
+ return <div>{items.length}</div>
288
+ }
289
+ `)
290
+ expect(errors).toHaveLength(0)
291
+ })
292
+
293
+ test('in-file interface Date shadow', () => {
294
+ const errors = bf021(`
295
+ interface Date { iso: string }
296
+ export function Foo({ d }: { d: Date }) {
297
+ return <div>{d.toISOString()}</div>
298
+ }
299
+ `)
300
+ expect(errors).toHaveLength(0)
301
+ })
302
+
303
+ test('registry-claimed call is exempt (#2274 seam)', () => {
304
+ const samplePlugin: LoweringPlugin = {
305
+ name: 'sample-date-lowering',
306
+ prepare: () => (callee, _args) =>
307
+ callee.kind === 'member' && !callee.computed && callee.property === 'toISOString'
308
+ ? { kind: 'helper-call', helper: 'isoDate', args: [] }
309
+ : null,
310
+ }
311
+ registerLoweringPlugin(samplePlugin)
312
+ try {
313
+ const errors = bf021(`
314
+ export function Foo({ createdAt }: { createdAt: Date }) {
315
+ return <div>{createdAt.toISOString()}</div>
316
+ }
317
+ `)
318
+ expect(errors).toHaveLength(0)
319
+ } finally {
320
+ __resetLoweringPluginsForTest(getLoweringPlugins().filter((p) => p.name !== 'sample-date-lowering'))
321
+ }
322
+ })
323
+ })
package/src/analyzer.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  collectReactiveGetterNames,
25
25
  } from './analyzer-context.ts'
26
26
  import { createError, createWarning, ErrorCodes } from './errors.ts'
27
+ import { baseTypeName } from './rich-type-evidence.ts'
28
+ import { CATALOGUED_RICH_TYPE_NAMES } from './date-lowering.ts'
27
29
  import path from 'node:path'
28
30
  import fs from 'node:fs'
29
31
 
@@ -3068,6 +3070,8 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3068
3070
  optional: !!member?.optional || !!element.initializer,
3069
3071
  defaultValue,
3070
3072
  defaultContainsArrow: defaultContainsArrow || undefined,
3073
+ // Only aliased bindings carry the source key — see ParamInfo.sourceName.
3074
+ ...(sourcePropName !== localName && { sourceName: sourcePropName }),
3071
3075
  })
3072
3076
  }
3073
3077
  }
@@ -3172,9 +3176,24 @@ function collectMemberTypes(
3172
3176
  typeNode: ts.TypeNode,
3173
3177
  ctx: AnalyzerContext
3174
3178
  ): Map<string, { type: TypeInfo | null; optional: boolean }> | null {
3179
+ // #2150 originally restricted this gate to string/number/boolean only,
3180
+ // because a non-primitive TypeInfo here used to mean "the typed adapters
3181
+ // will emit an unchecked scalar assertion (`in.X.(int)`) that panics" for
3182
+ // a shape the template layer has no representation for. That reasoning
3183
+ // does NOT extend to a rich type with a CATALOGUED lowering (#2274: `Date`
3184
+ // → the `date` helper): its propsParams TypeInfo is consumed only as
3185
+ // call-site evidence for `resolveReceiverType` (rich-type-evidence.ts) —
3186
+ // never emitted as a concrete field type (`typeInfoToGo`'s `interface`
3187
+ // case falls through to `interface{}` for an unbacked host name exactly
3188
+ // as `unknown` already did) — so there is no assertion to panic. Gating on
3189
+ // `CATALOGUED_RICH_TYPE_NAMES` specifically (not `HOST_RICH_TYPE_NAMES`
3190
+ // wholesale) keeps an un-catalogued rich type (`Map`, `Set`, …) at
3191
+ // `unknown`, i.e. avoids resurrecting the #2150 mistake for a shape no
3192
+ // lowering plugin exists for yet.
3175
3193
  const isResolvablePrimitive = (info: TypeInfo): boolean =>
3176
- info.kind === 'primitive' &&
3177
- (info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean')
3194
+ (info.kind === 'primitive' &&
3195
+ (info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean')) ||
3196
+ (info.kind === 'interface' && CATALOGUED_RICH_TYPE_NAMES.has(baseTypeName(info.raw)))
3178
3197
 
3179
3198
  const fromMembers = (
3180
3199
  members: ts.NodeArray<ts.TypeElement>
@@ -16,6 +16,7 @@ import type { LoweringPlugin } from './lowering-registry.ts'
16
16
  import { registerLoweringPlugin } from './lowering-registry.ts'
17
17
  import { queryHrefLocalNames } from './adapters/env-signal.ts'
18
18
  import { matchQueryHrefCall } from './query-href-lowering.ts'
19
+ import { datePlugin } from './date-lowering.ts'
19
20
 
20
21
  /**
21
22
  * `queryHref(base, { … })` — the pure URL-query builder (#2042). Its runtime
@@ -41,7 +42,7 @@ export const queryHrefPlugin: LoweringPlugin = {
41
42
  }
42
43
 
43
44
  /** Every plugin the compiler ships and applies by default. */
44
- export const BUILTIN_LOWERING_PLUGINS: readonly LoweringPlugin[] = [queryHrefPlugin]
45
+ export const BUILTIN_LOWERING_PLUGINS: readonly LoweringPlugin[] = [queryHrefPlugin, datePlugin]
45
46
 
46
47
  /**
47
48
  * Register the built-in plugins into the shared registry. Called for its side