@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/date-lowering.d.ts +33 -0
- package/dist/date-lowering.d.ts.map +1 -0
- package/dist/index.js +663 -218
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +14 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts +67 -0
- package/dist/rich-type-evidence.d.ts.map +1 -0
- package/dist/rich-type-refusal.d.ts +35 -0
- package/dist/rich-type-refusal.d.ts.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/client-js-generation.test.ts +59 -0
- package/src/__tests__/date-lowering.test.ts +232 -0
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +56 -0
- package/src/__tests__/rich-type-method-refusal.test.ts +323 -0
- package/src/analyzer.ts +21 -2
- package/src/builtin-lowering-plugins.ts +2 -1
- package/src/compiler.ts +3 -0
- package/src/date-lowering.ts +117 -0
- package/src/ir-to-client-js/emit-reactive.ts +103 -2
- package/src/ir-to-client-js/generate-init.ts +11 -4
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/ir-to-client-js/index.ts +2 -0
- package/src/ir-to-client-js/reactivity.ts +13 -2
- package/src/ir-to-client-js/types.ts +15 -0
- package/src/jsx-to-ir.ts +128 -3
- package/src/rich-type-evidence.ts +159 -0
- package/src/rich-type-refusal.ts +311 -0
- 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
|
-
|
|
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
|