@barefootjs/jsx 0.19.0 → 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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +17 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +342 -11
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts +56 -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 +22 -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__/nested-loop-reactive-attrs.test.ts +104 -0
- package/src/__tests__/rich-type-method-refusal.test.ts +303 -0
- package/src/analyzer.ts +18 -0
- package/src/compiler.ts +3 -0
- package/src/expression-parser.ts +59 -1
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +11 -13
- package/src/ir-to-client-js/csr-substitute.ts +8 -1
- package/src/ir-to-client-js/generate-init.ts +11 -4
- package/src/ir-to-client-js/reactivity.ts +13 -2
- package/src/rich-type-evidence.ts +159 -0
- package/src/rich-type-refusal.ts +311 -0
- package/src/types.ts +22 -0
package/src/analyzer.ts
CHANGED
|
@@ -1226,11 +1226,27 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
|
|
|
1226
1226
|
// the binding actually in scope rather than a hardcoded canonical name (#2057).
|
|
1227
1227
|
const envFactory = envReader ? callExpr.expression.getText(ctx.sourceFile) : undefined
|
|
1228
1228
|
|
|
1229
|
+
// Destructured-arg components only (#2265): a signal's initial value
|
|
1230
|
+
// referencing a bare destructured prop (`createSignal(size ?? 1)` with
|
|
1231
|
+
// `{ size }: { size?: number }`) needs `_p.size` for the CSR
|
|
1232
|
+
// `template:` arrow's module-scope SSR fallback — that arrow isn't a
|
|
1233
|
+
// closure over `initXxx`'s `const size = _p.size` extraction. Mirrors
|
|
1234
|
+
// the local-constant rewrite a few hundred lines up (`propNames` built
|
|
1235
|
+
// the same way from `ctx.propsParams`).
|
|
1236
|
+
let templateInitialValue: string | undefined
|
|
1237
|
+
if (!ctx.propsObjectName && callExpr.arguments[0]) {
|
|
1238
|
+
const propNames = new Set(ctx.propsParams.map(p => p.name))
|
|
1239
|
+
if (propNames.size > 0) {
|
|
1240
|
+
templateInitialValue = rewriteBarePropRefs(initialValue, callExpr.arguments[0], propNames)
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1229
1244
|
ctx.signals.push({
|
|
1230
1245
|
getter,
|
|
1231
1246
|
setter,
|
|
1232
1247
|
initialValue,
|
|
1233
1248
|
typedInitialValue: typedInitialValue !== initialValue ? typedInitialValue : undefined,
|
|
1249
|
+
templateInitialValue,
|
|
1234
1250
|
type,
|
|
1235
1251
|
loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
|
|
1236
1252
|
initialFreeIdentifiers: callExpr.arguments[0]
|
|
@@ -3052,6 +3068,8 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
|
|
|
3052
3068
|
optional: !!member?.optional || !!element.initializer,
|
|
3053
3069
|
defaultValue,
|
|
3054
3070
|
defaultContainsArrow: defaultContainsArrow || undefined,
|
|
3071
|
+
// Only aliased bindings carry the source key — see ParamInfo.sourceName.
|
|
3072
|
+
...(sourcePropName !== localName && { sourceName: sourcePropName }),
|
|
3055
3073
|
})
|
|
3056
3074
|
}
|
|
3057
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.
|
package/src/expression-parser.ts
CHANGED
|
@@ -591,6 +591,58 @@ export function cssKebabCase(name: string): string {
|
|
|
591
591
|
return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase()).replace(/^ms-/, '-ms-')
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Whether a dynamic CSS style VALUE is "unsafe" — the CSS-injection guard
|
|
596
|
+
* Hono's real JSX runtime (`hono/jsx/utils.ts`'s `hasUnsafeStyleValue`,
|
|
597
|
+
* the ORACLE this compiler's adapters must match, #2261) applies before
|
|
598
|
+
* emitting a `style={{...}}` object-literal property. A value flagged
|
|
599
|
+
* unsafe is DROPPED — the whole `key:value` pair is omitted from the CSS
|
|
600
|
+
* string — rather than escaped; this is a hand-rolled structural scan for
|
|
601
|
+
* characters that could break out of a CSS declaration (unbalanced
|
|
602
|
+
* quotes/brackets, `{`/`}`, a bare `;`, an unterminated CSS comment
|
|
603
|
+
* or backslash-escape), NOT real CSSOM property validation. Ported
|
|
604
|
+
* character-for-character (character-CODE comparisons, not codepoints —
|
|
605
|
+
* every tested character is ASCII, so scanning consistently by UTF-16
|
|
606
|
+
* code unit, byte, or codepoint all agree) so every adapter's own port
|
|
607
|
+
* (Go/Ruby/Python/Rust/PHP/Perl) stays byte-isomorphic with this
|
|
608
|
+
* reference and with Hono.
|
|
609
|
+
*/
|
|
610
|
+
export function hasUnsafeStyleValue(value: string): boolean {
|
|
611
|
+
let quote = 0
|
|
612
|
+
const blockStack: number[] = []
|
|
613
|
+
for (let i = 0, len = value.length; i < len; i++) {
|
|
614
|
+
const c = value.charCodeAt(i)
|
|
615
|
+
if (c === 92) {
|
|
616
|
+
// backslash — an escape sequence; an escape at the very end (nothing
|
|
617
|
+
// to escape) is unsafe, else skip the escaped character.
|
|
618
|
+
if (i === len - 1) return true
|
|
619
|
+
i++
|
|
620
|
+
} else if (quote !== 0) {
|
|
621
|
+
if (c === 10 || c === 12 || c === 13) return true // \n \f \r inside a quoted string
|
|
622
|
+
if (c === quote) quote = 0
|
|
623
|
+
} else if (c === 47 && value.charCodeAt(i + 1) === 42) {
|
|
624
|
+
// "/*" comment start — must close before the value ends.
|
|
625
|
+
const end = value.indexOf('*/', i + 2)
|
|
626
|
+
if (end === -1) return true
|
|
627
|
+
i = end + 1
|
|
628
|
+
} else if (c === 34 || c === 39) {
|
|
629
|
+
quote = c // " or '
|
|
630
|
+
} else if (c === 40) {
|
|
631
|
+
blockStack.push(41) // ( expects a matching )
|
|
632
|
+
} else if (c === 91) {
|
|
633
|
+
blockStack.push(93) // [ expects a matching ]
|
|
634
|
+
} else if (c === 123 || c === 125) {
|
|
635
|
+
return true // { or } — always unsafe
|
|
636
|
+
} else if (c === 41 || c === 93) {
|
|
637
|
+
if (blockStack[blockStack.length - 1] !== c) return true // mismatched close
|
|
638
|
+
blockStack.pop()
|
|
639
|
+
} else if (c === 59 && blockStack.length === 0) {
|
|
640
|
+
return true // ; outside any bracket
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return quote !== 0 || blockStack.length !== 0 // unterminated quote/bracket
|
|
644
|
+
}
|
|
645
|
+
|
|
594
646
|
/**
|
|
595
647
|
* Parse a JSX `style={{ … }}` object-literal source into CSS entries, or
|
|
596
648
|
* `null` when the shape isn't a plain object of static-keyed properties
|
|
@@ -3538,7 +3590,13 @@ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
|
|
|
3538
3590
|
case 'regex':
|
|
3539
3591
|
return true
|
|
3540
3592
|
case 'identifier':
|
|
3541
|
-
|
|
3593
|
+
// `undefined` parses as an `identifier` node (unlike `null`, which
|
|
3594
|
+
// is a `literal`) but is never a scope reference — every adapter
|
|
3595
|
+
// emitter already lowers it to `nil`/`none`/etc. Reporting it as
|
|
3596
|
+
// free wrongly opaques an otherwise-derivable expression (#2260:
|
|
3597
|
+
// `props.pressed !== undefined` in `computeSsrSeedPlan`'s
|
|
3598
|
+
// `classify()`, whose `baseScope` has no `undefined` entry).
|
|
3599
|
+
if (e.name !== 'undefined' && !bound.has(e.name)) free.add(e.name)
|
|
3542
3600
|
return true
|
|
3543
3601
|
case 'call': {
|
|
3544
3602
|
const isBuiltinCallee = evalBuiltinCalleeName(e.callee) !== null
|
package/src/index.ts
CHANGED
|
@@ -320,7 +320,7 @@ export {
|
|
|
320
320
|
export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
|
|
321
321
|
|
|
322
322
|
// Expression Parser
|
|
323
|
-
export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
|
|
323
|
+
export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
|
|
324
324
|
export type { StyleObjectEntry } from './expression-parser.ts'
|
|
325
325
|
export { PARSED_EXPR_KINDS } from './expression-parser.ts'
|
|
326
326
|
export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
|
|
@@ -313,26 +313,24 @@ export function collectInnerLoops(
|
|
|
313
313
|
const refsOuter = outerLoopParam
|
|
314
314
|
? new RegExp(`\\b${outerLoopParam}\\b`).test(n.array)
|
|
315
315
|
: false
|
|
316
|
-
// Per-item bindings for inner loop body
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
316
|
+
// Per-item bindings for inner loop body, collected uniformly when
|
|
317
|
+
// ctx is available: reactiveTexts / reactiveAttrs / refs are each
|
|
318
|
+
// classified against the loop's OWN param via `classifyReactivity`,
|
|
319
|
+
// which already filters out non-reactive reads — a `refsOuter` gate
|
|
320
|
+
// on texts alone (removed, #2264) wrongly used the FIXED top-level
|
|
321
|
+
// loop param at every nesting depth, so an innermost loop whose
|
|
322
|
+
// array only referenced its immediate parent (not the outermost
|
|
323
|
+
// param) silently dropped its text-child update effect while the
|
|
324
|
+
// sibling attribute effect (ungated) still fired. Refs need to fire
|
|
325
|
+
// on every renderItem invocation (#1244).
|
|
324
326
|
// - events / conditionals: only in `collectBindings` (branch)
|
|
325
327
|
// mode; the legacy non-branch path didn't wire them on
|
|
326
328
|
// `NestedLoop` because event delegation handles them through
|
|
327
329
|
// the parent's bindings instead.
|
|
328
330
|
const bindings: LoopChildBindings = emptyLoopChildBindings()
|
|
329
|
-
if (refsOuter && ctx) {
|
|
330
|
-
for (const child of n.children) {
|
|
331
|
-
bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings))
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
331
|
if (ctx) {
|
|
335
332
|
for (const child of n.children) {
|
|
333
|
+
bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings))
|
|
336
334
|
bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings))
|
|
337
335
|
bindings.refs.push(...collectLoopChildRefs(child))
|
|
338
336
|
}
|
|
@@ -474,6 +474,13 @@ function normalizeSignalInitial(signal: SignalInfo, propsObjectName: string | nu
|
|
|
474
474
|
if (initialValue.startsWith(propsPrefix) && !initialValue.includes('??')) {
|
|
475
475
|
return `${initialValue} ?? ${inferDefaultValue(signal.type)}`
|
|
476
476
|
}
|
|
477
|
-
|
|
477
|
+
// Destructured mode (#2265): `templateInitialValue` (when present) has
|
|
478
|
+
// bare destructured prop refs already rewritten to `_p.X` — the
|
|
479
|
+
// module-scope CSR `template:` arrow can't see the bare name (it isn't
|
|
480
|
+
// a closure over `initXxx`'s `const size = _p.size` extraction), which
|
|
481
|
+
// otherwise throws `ReferenceError` at template-eval time. The `??`
|
|
482
|
+
// fallback branch above only applies to object-props mode (a raw
|
|
483
|
+
// `props.X` prefix match), so it's checked against the ORIGINAL value.
|
|
484
|
+
return signal.templateInitialValue ?? initialValue
|
|
478
485
|
}
|
|
479
486
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|