@barefootjs/jsx 0.19.1 → 0.20.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.
@@ -1162,6 +1162,65 @@ describe('Client JS generation', () => {
1162
1162
  expect(basicErrorCount).toBe(1) // only inside computeError body
1163
1163
  expect(isDuplicateCount).toBe(1) // only inside computeError body
1164
1164
  })
1165
+
1166
+ test('import used only inside a module-level helper is traced into the client bundle (#2283)', () => {
1167
+ const source = `
1168
+ 'use client'
1169
+ import { createMemo } from '@barefootjs/client'
1170
+ import { computeSheetGeometry } from '../src/lib/sheetGeometry'
1171
+
1172
+ function buildSheetVMs(count: number) {
1173
+ return computeSheetGeometry(count)
1174
+ }
1175
+
1176
+ export function PrintSheets(props: { count: number }) {
1177
+ const sheets = createMemo(() => buildSheetVMs(props.count))
1178
+ return <div>{sheets()}</div>
1179
+ }
1180
+ `
1181
+
1182
+ const result = compileJSX(source, 'PrintSheets.tsx', { adapter })
1183
+ expect(result.errors).toHaveLength(0)
1184
+
1185
+ const clientJs = result.files.find(f => f.type === 'clientJs')
1186
+ expect(clientJs).toBeDefined()
1187
+ const content = clientJs!.content
1188
+
1189
+ // The helper's body (which references the import) must be emitted...
1190
+ expect(content).toContain('computeSheetGeometry(count)')
1191
+ // ...and the import that body depends on must be traced and emitted too,
1192
+ // otherwise the browser throws `ReferenceError: computeSheetGeometry is not defined`.
1193
+ expect(content).toContain("import { computeSheetGeometry } from '../src/lib/sheetGeometry'")
1194
+ })
1195
+
1196
+ test('module-level helper body containing a literal "$&" is spliced in verbatim', () => {
1197
+ // A plain-string second argument to String.replace() specially
1198
+ // interprets `$&`/`$1`/`$$` — the placeholder-substitution step must
1199
+ // use the replacer-function form so a helper body containing one of
1200
+ // these sequences isn't corrupted (piconic-ai/barefootjs#2286 review).
1201
+ const source = `
1202
+ 'use client'
1203
+ import { createMemo } from '@barefootjs/client'
1204
+
1205
+ function formatMoney(amount: number) {
1206
+ return '$&' + amount
1207
+ }
1208
+
1209
+ export function Price(props: { amount: number }) {
1210
+ const label = createMemo(() => formatMoney(props.amount))
1211
+ return <div>{label()}</div>
1212
+ }
1213
+ `
1214
+
1215
+ const result = compileJSX(source, 'Price.tsx', { adapter })
1216
+ expect(result.errors).toHaveLength(0)
1217
+
1218
+ const clientJs = result.files.find(f => f.type === 'clientJs')
1219
+ expect(clientJs).toBeDefined()
1220
+ const content = clientJs!.content
1221
+
1222
+ expect(content).toContain("'$&' + amount")
1223
+ })
1165
1224
  })
1166
1225
 
1167
1226
  describe('child component value/boolean prop binding', () => {
@@ -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,303 @@
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
+
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { compileJSX } from '../compiler'
14
+ import { ErrorCodes } from '../errors'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { registerLoweringPlugin, __resetLoweringPluginsForTest, getLoweringPlugins, type LoweringPlugin } from '../lowering-registry'
17
+
18
+ const adapter = new TestAdapter()
19
+
20
+ function bf021(source: string, filePath = 'Test.tsx') {
21
+ const result = compileJSX(source, filePath, { adapter })
22
+ return result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
23
+ }
24
+
25
+ describe('rich-type method-call refusal — fires (BF021)', () => {
26
+ test('inline-destructured Date in text position', () => {
27
+ const errors = bf021(`
28
+ export function Foo({ createdAt }: { createdAt: Date }) {
29
+ return <div>{createdAt.toISOString()}</div>
30
+ }
31
+ `)
32
+ expect(errors).toHaveLength(1)
33
+ expect(errors[0].message).toContain("'.toISOString()'")
34
+ expect(errors[0].message).toContain("'createdAt'")
35
+ expect(errors[0].message).toContain("'Date'")
36
+ })
37
+
38
+ test('props-object member chain in attribute position', () => {
39
+ const errors = bf021(`
40
+ export function Foo(props: { d: Date }) {
41
+ return <div data-year={props.d.getUTCFullYear()} />
42
+ }
43
+ `)
44
+ expect(errors).toHaveLength(1)
45
+ expect(errors[0].message).toContain("'.getUTCFullYear()'")
46
+ expect(errors[0].message).toContain("'props.d'")
47
+ expect(errors[0].message).toContain("'Date'")
48
+ })
49
+
50
+ test('named interface Props Date field (typeDefinitions deref)', () => {
51
+ const errors = bf021(`
52
+ interface Props { createdAt: Date }
53
+ export function Foo({ createdAt }: Props) {
54
+ return <div>{createdAt.getFullYear()}</div>
55
+ }
56
+ `)
57
+ expect(errors).toHaveLength(1)
58
+ expect(errors[0].message).toContain("'.getFullYear()'")
59
+ expect(errors[0].message).toContain("'createdAt'")
60
+ expect(errors[0].message).toContain("'Date'")
61
+ })
62
+
63
+ test('optional-chained call', () => {
64
+ const errors = bf021(`
65
+ export function Foo({ d }: { d: Date | undefined }) {
66
+ return <div>{d?.toISOString()}</div>
67
+ }
68
+ `)
69
+ expect(errors).toHaveLength(1)
70
+ expect(errors[0].message).toContain("'.toISOString()'")
71
+ })
72
+
73
+ test('Date | null union resolves to Date', () => {
74
+ const errors = bf021(`
75
+ export function Foo({ d }: { d: Date | null }) {
76
+ return <div>{d.toISOString()}</div>
77
+ }
78
+ `)
79
+ expect(errors).toHaveLength(1)
80
+ expect(errors[0].message).toContain("'Date'")
81
+ })
82
+
83
+ test('loop-item member (items.map(i => i.at.getTime()))', () => {
84
+ const errors = bf021(`
85
+ export function Foo({ items }: { items: { at: Date }[] }) {
86
+ return <ul>{items.map(i => <li>{i.at.getTime()}</li>)}</ul>
87
+ }
88
+ `)
89
+ expect(errors).toHaveLength(1)
90
+ expect(errors[0].message).toContain("'.getTime()'")
91
+ // A loop item is prop-DERIVED but not itself a prop — the message must
92
+ // not call it one (only bare / props-object receivers earn "prop").
93
+ expect(errors[0].message).toContain("on 'i.at'")
94
+ expect(errors[0].message).not.toContain("prop 'i.at'")
95
+ expect(errors[0].message).toContain("'Date'")
96
+ })
97
+
98
+ test('renamed destructured prop ({ createdAt: c })', () => {
99
+ const errors = bf021(`
100
+ export function Foo({ createdAt: c }: { createdAt: Date }) {
101
+ return <div>{c.toISOString()}</div>
102
+ }
103
+ `)
104
+ expect(errors).toHaveLength(1)
105
+ expect(errors[0].message).toContain("'.toISOString()'")
106
+ expect(errors[0].message).toContain("prop 'c'")
107
+ expect(errors[0].message).toContain("'Date'")
108
+ })
109
+
110
+ test('conditional-branch call without @client', () => {
111
+ const errors = bf021(`
112
+ export function Foo({ d }: { d: Date | null }) {
113
+ return <div>{d && <span>{d.toISOString()}</span>}</div>
114
+ }
115
+ `)
116
+ expect(errors).toHaveLength(1)
117
+ expect(errors[0].message).toContain("'.toISOString()'")
118
+ })
119
+
120
+ test('two distinct receivers at the same expression report separately', () => {
121
+ const errors = bf021(`
122
+ export function Foo({ a, b }: { a: Date; b: Date }) {
123
+ return <div>{a.getTime() + b.getTime()}</div>
124
+ }
125
+ `)
126
+ expect(errors).toHaveLength(2)
127
+ expect(errors[0].message).toContain("prop 'a'")
128
+ expect(errors[1].message).toContain("prop 'b'")
129
+ })
130
+
131
+ test('Date in component-prop position', () => {
132
+ const errors = bf021(`
133
+ function Bar(props: { value: string }) {
134
+ return <div>{props.value}</div>
135
+ }
136
+ export function Foo({ createdAt }: { createdAt: Date }) {
137
+ return <Bar value={createdAt.toISOString()} />
138
+ }
139
+ `)
140
+ expect(errors).toHaveLength(1)
141
+ expect(errors[0].message).toContain("'.toISOString()'")
142
+ expect(errors[0].message).toContain("'createdAt'")
143
+ })
144
+
145
+ test('Map.get() (broad host-type list)', () => {
146
+ const errors = bf021(`
147
+ export function Foo({ m }: { m: Map<string, string> }) {
148
+ return <div>{m.get('x')}</div>
149
+ }
150
+ `)
151
+ expect(errors).toHaveLength(1)
152
+ expect(errors[0].message).toContain("'.get()'")
153
+ expect(errors[0].message).toContain("'Map'")
154
+ })
155
+
156
+ test('diagnostic carries the @client suggestion', () => {
157
+ const errors = bf021(`
158
+ export function Foo({ createdAt }: { createdAt: Date }) {
159
+ return <div>{createdAt.toISOString()}</div>
160
+ }
161
+ `)
162
+ expect(errors[0].severity).toBe('error')
163
+ expect(errors[0].suggestion?.message).toContain('@client')
164
+ })
165
+ })
166
+
167
+ describe('rich-type method-call refusal — silent (no BF021)', () => {
168
+ test('/* @client */-prefixed Date call', () => {
169
+ const errors = bf021(`
170
+ export function Foo({ createdAt }: { createdAt: Date }) {
171
+ return <div>{/* @client */ createdAt.toISOString()}</div>
172
+ }
173
+ `)
174
+ expect(errors).toHaveLength(0)
175
+ })
176
+
177
+ test('/* @client */-wrapped conditional branch', () => {
178
+ const errors = bf021(`
179
+ export function Foo({ d }: { d: Date | null }) {
180
+ return <div>{/* @client */ d && <span>{d.toISOString()}</span>}</div>
181
+ }
182
+ `)
183
+ expect(errors).toHaveLength(0)
184
+ })
185
+
186
+ test('module const sharing a propsType field name (object-props mode)', () => {
187
+ const errors = bf021(`
188
+ const version = 'v1'
189
+ export function Foo(props: { version: Map<string, string> }) {
190
+ return <div>{version.toUpperCase()}</div>
191
+ }
192
+ `)
193
+ expect(errors).toHaveLength(0)
194
+ })
195
+
196
+ test('string method on string prop', () => {
197
+ const errors = bf021(`
198
+ export function Foo({ s }: { s: string }) {
199
+ return <div>{s.toUpperCase()}</div>
200
+ }
201
+ `)
202
+ expect(errors).toHaveLength(0)
203
+ })
204
+
205
+ test('array method on array prop', () => {
206
+ const errors = bf021(`
207
+ export function Foo({ items }: { items: string[] }) {
208
+ return <div>{items.join(',')}</div>
209
+ }
210
+ `)
211
+ expect(errors).toHaveLength(0)
212
+ })
213
+
214
+ test('untyped receiver (no type annotation)', () => {
215
+ const errors = bf021(`
216
+ export function Foo({ d }) {
217
+ return <div>{d.toISOString()}</div>
218
+ }
219
+ `)
220
+ expect(errors).toHaveLength(0)
221
+ })
222
+
223
+ test('generic type-parameter receiver', () => {
224
+ const errors = bf021(`
225
+ export function Foo<T>({ d }: { d: T }) {
226
+ return <div>{d.toISOString()}</div>
227
+ }
228
+ `)
229
+ expect(errors).toHaveLength(0)
230
+ })
231
+
232
+ test('imported named type receiver', () => {
233
+ const errors = bf021(`
234
+ import type { Widget } from './widget'
235
+ export function Foo({ w }: { w: Widget }) {
236
+ return <div>{w.render()}</div>
237
+ }
238
+ `)
239
+ expect(errors).toHaveLength(0)
240
+ })
241
+
242
+ test('signal getter call result (d().toISOString())', () => {
243
+ const errors = bf021(`
244
+ 'use client'
245
+ import { createSignal } from '@barefootjs/client'
246
+ export function Foo() {
247
+ const [d, setD] = createSignal(new Date())
248
+ return <div>{d().toISOString()}</div>
249
+ }
250
+ `)
251
+ expect(errors).toHaveLength(0)
252
+ })
253
+
254
+ test('local-function call (not a member call on the receiver)', () => {
255
+ const errors = bf021(`
256
+ function formatDate(x: Date): string { return x.toString() }
257
+ export function Foo({ createdAt }: { createdAt: Date }) {
258
+ return <div>{formatDate(createdAt)}</div>
259
+ }
260
+ `)
261
+ expect(errors).toHaveLength(0)
262
+ })
263
+
264
+ test('.length non-call access', () => {
265
+ const errors = bf021(`
266
+ export function Foo({ items }: { items: string[] }) {
267
+ return <div>{items.length}</div>
268
+ }
269
+ `)
270
+ expect(errors).toHaveLength(0)
271
+ })
272
+
273
+ test('in-file interface Date shadow', () => {
274
+ const errors = bf021(`
275
+ interface Date { iso: string }
276
+ export function Foo({ d }: { d: Date }) {
277
+ return <div>{d.toISOString()}</div>
278
+ }
279
+ `)
280
+ expect(errors).toHaveLength(0)
281
+ })
282
+
283
+ test('registry-claimed call is exempt (#2274 seam)', () => {
284
+ const samplePlugin: LoweringPlugin = {
285
+ name: 'sample-date-lowering',
286
+ prepare: () => (callee, _args) =>
287
+ callee.kind === 'member' && !callee.computed && callee.property === 'toISOString'
288
+ ? { kind: 'helper-call', helper: 'isoDate', args: [] }
289
+ : null,
290
+ }
291
+ registerLoweringPlugin(samplePlugin)
292
+ try {
293
+ const errors = bf021(`
294
+ export function Foo({ createdAt }: { createdAt: Date }) {
295
+ return <div>{createdAt.toISOString()}</div>
296
+ }
297
+ `)
298
+ expect(errors).toHaveLength(0)
299
+ } finally {
300
+ __resetLoweringPluginsForTest(getLoweringPlugins().filter((p) => p.name !== 'sample-date-lowering'))
301
+ }
302
+ })
303
+ })
package/src/analyzer.ts CHANGED
@@ -3068,6 +3068,8 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3068
3068
  optional: !!member?.optional || !!element.initializer,
3069
3069
  defaultValue,
3070
3070
  defaultContainsArrow: defaultContainsArrow || undefined,
3071
+ // Only aliased bindings carry the source key — see ParamInfo.sourceName.
3072
+ ...(sourcePropName !== localName && { sourceName: sourcePropName }),
3071
3073
  })
3072
3074
  }
3073
3075
  }
package/src/compiler.ts CHANGED
@@ -24,6 +24,7 @@ import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
24
24
  import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
25
25
  import { extractSsrDefaults } from './ssr-defaults.ts'
26
26
  import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
27
+ import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
27
28
 
28
29
  /**
29
30
  * Extended compile options with required adapter
@@ -137,6 +138,7 @@ function compileMultipleComponents(
137
138
  }
138
139
 
139
140
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
141
+ checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
140
142
 
141
143
  if (options.cssLayerPrefix) {
142
144
  applyCssLayerPrefix(componentIR, options.cssLayerPrefix)
@@ -615,6 +617,7 @@ export function compileJSX(
615
617
 
616
618
  // Pre-compute client JS analysis for adapter optimization
617
619
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
620
+ checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
618
621
 
619
622
  // Cross-file @client signal sources: identify which import sources
620
623
  // need `.client.js` path rewriting in the client bundle.
@@ -99,16 +99,23 @@ export function generateInitFunction(
99
99
  let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName)
100
100
  generatedCode += '\n' + hydrateLine
101
101
 
102
- const allImportLines = resolveFinalImports(generatedCode, ir, localImportPrefixes)
102
+ // Substitute module-level declarations BEFORE import detection: a
103
+ // module-level helper's body (e.g. `buildSheetVMs` calling
104
+ // `computeSheetGeometry`) only exists in `moduleConstantsCode`, so
105
+ // scanning `generatedCode` first would miss any import referenced
106
+ // only from that body and silently drop it (#2283).
103
107
  const moduleConstantsCode = emitModuleLevelDeclarations(
104
108
  classification.moduleLevelConstants,
105
109
  classification.moduleLevelFunctions,
106
110
  classification.moduleLevelSignals,
107
111
  classification.moduleLevelMemos,
108
112
  )
113
+ // Replacer-function form: a plain replacement string would let literal
114
+ // `$&`/`$1`/`$$` sequences in user helper bodies or import paths be
115
+ // reinterpreted by `String.replace`'s special-pattern handling.
116
+ const codeWithModuleConstants = generatedCode.replace(MODULE_CONSTANTS_PLACEHOLDER, () => moduleConstantsCode)
117
+ const allImportLines = resolveFinalImports(codeWithModuleConstants, ir, localImportPrefixes)
109
118
 
110
- return generatedCode
111
- .replace(IMPORT_PLACEHOLDER, allImportLines)
112
- .replace(MODULE_CONSTANTS_PLACEHOLDER, moduleConstantsCode)
119
+ return codeWithModuleConstants.replace(IMPORT_PLACEHOLDER, () => allImportLines)
113
120
  }
114
121
 
@@ -529,8 +529,19 @@ export function collectLoopChildReactiveTexts(
529
529
  const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
530
530
  const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
531
531
  // Include if expression reads signals OR references the loop parameter
532
- // (loop param becomes a signal accessor via per-item signals).
533
- if (classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
532
+ // (loop param becomes a signal accessor via per-item signals). Falls
533
+ // back to the Solid-style AST-flag wrap decision — mirroring
534
+ // `collectLoopChildReactiveAttrs`'s `callsReactiveGetters` /
535
+ // `hasFunctionCalls` fallback (#1673) and the top-level text path's
536
+ // `decideWrapFromAstFlags` gate (`collectElements`'s `expression`
537
+ // handler) — so a loop-item text read through an opaque helper
538
+ // (`textAt(i)` where `const textAt = (i) => rows()[i]`, which
539
+ // `classifyReactivity` can't see through) still gets an update
540
+ // effect instead of silently freezing at its SSR value (#2282).
541
+ const reactive =
542
+ classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
543
+ || decideWrapFromAstFlags(n).wrap
544
+ if (!reactive) return
534
545
  texts.push({
535
546
  slotId: n.slotId,
536
547
  expression: expanded.expr,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Type-evidence resolution for the rich-type method-call refusal (#2273).
3
+ *
4
+ * `resolveReceiverType` answers one question: "what TypeScript type, if any,
5
+ * does this `ParsedExpr` evaluate to?" — using only the structured metadata
6
+ * already collected at IR-build time (`propsType` / `typeDefinitions`), never
7
+ * a fresh type-checker pass. It is deliberately conservative: any receiver
8
+ * shape it doesn't recognize (a call result, computed access, a local not in
9
+ * `bindings`, …) resolves to `null` ("no evidence"), which the caller must
10
+ * treat as "don't flag" rather than "flag as unknown". A false negative here
11
+ * only misses a refusal; a false positive would incorrectly block valid code.
12
+ */
13
+
14
+ import type { IRMetadata, PropertyInfo, TypeInfo } from './types.ts'
15
+ import type { ParsedExpr } from './expression-parser.ts'
16
+
17
+ /**
18
+ * Built-in JS/TS types whose instance methods have no catalogued lowering
19
+ * (spec/subset-conformance.md). A prop typed as one of these is opaque past
20
+ * this point — the adapters have no structural representation for `Date`,
21
+ * `Map`, etc., only for the primitives/arrays/plain-objects the IR already
22
+ * lowers. Names only (no generic args) — compare against `baseTypeName`.
23
+ *
24
+ * Two shapes deliberately escape this catalogue (conservative misses, not
25
+ * bugs — a miss only skips a refusal, never misdiagnoses):
26
+ * - keyword-typed `bigint` / `symbol` annotations lower to
27
+ * `{ kind: 'unknown' }` in `typeNodeToTypeInfo` (only the object-form
28
+ * `BigInt` / `Symbol` type references reach `kind: 'interface'` and
29
+ * match here);
30
+ * - a local alias of a host type (`type Timestamp = Date`) resolves to
31
+ * the alias NAME — `derefNamedType` only fills in `properties` from a
32
+ * declaration, it never rewrites `raw` to the alias target — so the
33
+ * catalogue lookup sees `Timestamp`, not `Date`.
34
+ */
35
+ export const HOST_RICH_TYPE_NAMES: ReadonlySet<string> = new Set([
36
+ 'Date',
37
+ 'Map',
38
+ 'Set',
39
+ 'WeakMap',
40
+ 'WeakSet',
41
+ 'URL',
42
+ 'URLSearchParams',
43
+ 'RegExp',
44
+ 'Promise',
45
+ 'Error',
46
+ 'Symbol',
47
+ 'BigInt',
48
+ 'Function',
49
+ ])
50
+
51
+ /**
52
+ * Strip generic type arguments from a `TypeInfo.raw` string (`Map<string,
53
+ * string>` → `Map`) so a parametrized host type still matches the bare-name
54
+ * catalogue above. `raw` is source-verbatim (`typeNodeToTypeInfo`), so this
55
+ * is a plain substring split on the first `<` — not a type-syntax parse.
56
+ */
57
+ export function baseTypeName(raw: string): string {
58
+ const idx = raw.indexOf('<')
59
+ return (idx === -1 ? raw : raw.slice(0, idx)).trim()
60
+ }
61
+
62
+ type EvidenceMetadata = Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'>
63
+
64
+ /**
65
+ * Collapse a union to its single non-nullish arm (`Date | null` → `Date`),
66
+ * recursively, so an optional rich-typed prop still carries evidence. A
67
+ * union with more than one non-nullish arm has no single answer and is left
68
+ * as-is (its `kind` is `'union'`, which never matches the `'interface'`
69
+ * check callers gate on).
70
+ */
71
+ function isNullishArm(t: TypeInfo): boolean {
72
+ if (t.kind === 'primitive' && (t.primitive === 'null' || t.primitive === 'undefined')) return true
73
+ // `null` as a type annotation is a `ts.LiteralTypeNode` (not the
74
+ // `NullKeyword` `typeNodeToTypeInfo`'s primitive switch checks for), so it
75
+ // falls through to `{ kind: 'unknown', raw: 'null' }` there — a pre-existing
76
+ // gap in that shared helper, out of scope to fix here. Match on `raw` too
77
+ // so `Date | null` still strips down to `Date`.
78
+ return t.kind === 'unknown' && (t.raw === 'null' || t.raw === 'undefined')
79
+ }
80
+
81
+ function stripUnion(type: TypeInfo | null): TypeInfo | null {
82
+ if (!type || type.kind !== 'union' || !type.unionTypes) return type
83
+ const nonNullish = type.unionTypes.filter((t) => !isNullishArm(t))
84
+ return nonNullish.length === 1 ? stripUnion(nonNullish[0]) : type
85
+ }
86
+
87
+ /**
88
+ * Resolve a named type (`{ kind: 'interface', raw: 'Props' }`) that carries
89
+ * no inline `properties` to its declaration's field list via
90
+ * `metadata.typeDefinitions`. A type already carrying properties (an inline
91
+ * object literal type, or a type resolved from `tsTypeToTypeInfo`) is
92
+ * returned unchanged — this only fills in the gap left by a *named*
93
+ * reference, which `typeNodeToTypeInfo` intentionally resolves to
94
+ * `{ kind: 'interface', raw }` with no member walk of its own.
95
+ */
96
+ function derefNamedType(type: TypeInfo, meta: EvidenceMetadata): TypeInfo {
97
+ if (type.kind !== 'interface') return type
98
+ if (type.properties && type.properties.length > 0) return type
99
+ const name = baseTypeName(type.raw)
100
+ const def = meta.typeDefinitions.find((d) => d.name === name)
101
+ if (!def?.properties) return type
102
+ return { ...type, properties: def.properties }
103
+ }
104
+
105
+ /**
106
+ * Resolve one property's type off an object-shaped receiver type, deref'ing
107
+ * a named type first (`Props.createdAt`) and stripping a nullable union off
108
+ * the result (`Date | null` field). Returns `null` when the receiver has no
109
+ * evidence, or the property isn't found on it.
110
+ */
111
+ function lookupProperty(objType: TypeInfo | null, propName: string, meta: EvidenceMetadata): TypeInfo | null {
112
+ const stripped = stripUnion(objType)
113
+ if (!stripped) return null
114
+ const deref = derefNamedType(stripped, meta)
115
+ const prop = deref.properties?.find((p: PropertyInfo) => p.name === propName)
116
+ return prop ? stripUnion(prop.type) : null
117
+ }
118
+
119
+ /**
120
+ * Resolve the TypeInfo of a receiver expression, using only propsType /
121
+ * propsParams / typeDefinitions and the caller-supplied local bindings.
122
+ * `bindings` maps a name to its known type — or explicitly to `null` for a
123
+ * shadow the caller has proven carries no evidence (e.g. an arrow param, a
124
+ * loop item whose array type isn't known). A `bindings` hit always wins over
125
+ * the props fallback, matching JS lexical shadowing.
126
+ *
127
+ * Only two `ParsedExpr` shapes carry evidence: a bare identifier and a
128
+ * non-computed member access. Everything else (calls, computed/index
129
+ * access, literals, …) resolves to `null` — see the module doc.
130
+ */
131
+ export function resolveReceiverType(
132
+ expr: ParsedExpr,
133
+ meta: EvidenceMetadata,
134
+ bindings: ReadonlyMap<string, TypeInfo | null>,
135
+ ): TypeInfo | null {
136
+ if (expr.kind === 'identifier') {
137
+ if (bindings.has(expr.name)) return stripUnion(bindings.get(expr.name) ?? null)
138
+ if (meta.propsObjectName !== null) {
139
+ // Object-props mode: props are only reachable through the props object,
140
+ // so a bare identifier is never a prop — treating every name that
141
+ // happens to match a propsType field as one would misattribute module
142
+ // consts / imports that share a field's name.
143
+ return expr.name === meta.propsObjectName ? stripUnion(meta.propsType) : null
144
+ }
145
+ // Destructured mode: only a declared param binding is a prop (membership
146
+ // via propsParams, which carries LOCAL names — including rename targets).
147
+ // The TYPE must come from propsType.properties keyed by the SOURCE prop
148
+ // name: propsParams' own `type` degrades to `unknown` for non-primitive
149
+ // props (`collectMemberTypes`' primitives-only gate).
150
+ const param = meta.propsParams.find((p) => p.name === expr.name && !p.isRest)
151
+ if (!param) return null
152
+ return lookupProperty(meta.propsType, param.sourceName ?? param.name, meta)
153
+ }
154
+ if (expr.kind === 'member' && !expr.computed) {
155
+ const objType = resolveReceiverType(expr.object, meta, bindings)
156
+ return lookupProperty(objType, expr.property, meta)
157
+ }
158
+ return null
159
+ }