@barefootjs/jsx 0.21.4 → 0.24.1
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/adapters/env-signal.d.ts +8 -0
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +16 -5
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +10 -4
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts.map +1 -1
- package/dist/date-lowering.d.ts +16 -0
- package/dist/date-lowering.d.ts.map +1 -1
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +30 -0
- package/dist/format-date-lowering.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1124 -74
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +1 -0
- package/dist/ir-to-client-js/html-template.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/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +111 -0
- package/dist/to-locale-date-lowering.d.ts.map +1 -0
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +125 -0
- package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
- package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
- package/src/adapters/env-signal.ts +26 -3
- package/src/analyzer-context.ts +19 -4
- package/src/analyzer.ts +1012 -93
- package/src/builtin-lowering-plugins.ts +8 -1
- package/src/date-lowering.ts +1 -1
- package/src/errors.ts +19 -0
- package/src/format-date-lowering.ts +55 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/emit-reactive.ts +90 -1
- package/src/ir-to-client-js/html-template.ts +36 -2
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/jsx-to-ir.ts +90 -1
- package/src/rich-type-refusal.ts +9 -1
- package/src/to-locale-date-lowering.ts +563 -0
- package/src/types.ts +49 -1
package/src/analyzer.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import ts from 'typescript'
|
|
10
|
-
import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo } from './types.ts'
|
|
10
|
+
import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, SourceLocation } from './types.ts'
|
|
11
11
|
import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
|
|
12
12
|
import { rewriteBarePropRefs } from './prop-rewrite.ts'
|
|
13
13
|
import { incrementCounter } from './instrumentation.ts'
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
isArrowComponentFunction,
|
|
24
24
|
collectReactiveGetterNames,
|
|
25
25
|
} from './analyzer-context.ts'
|
|
26
|
-
import { createError, createWarning, ErrorCodes } from './errors.ts'
|
|
26
|
+
import { createError, createWarning, ErrorCodes, type ErrorCode } from './errors.ts'
|
|
27
27
|
import { baseTypeName } from './rich-type-evidence.ts'
|
|
28
28
|
import { CATALOGUED_RICH_TYPE_NAMES } from './date-lowering.ts'
|
|
29
29
|
import path from 'node:path'
|
|
@@ -232,6 +232,15 @@ export function analyzeComponent(
|
|
|
232
232
|
const ctx = createAnalyzerContext(sourceFile, filePath)
|
|
233
233
|
ctx.checker = checker
|
|
234
234
|
|
|
235
|
+
// Reactive-factory prescan results (#931, #2325 cross-file + object-
|
|
236
|
+
// return round 2) — same-file and relative-imported factories, plus the
|
|
237
|
+
// declined / reactive-shaped buckets `validateReactiveFactoryCalls` uses
|
|
238
|
+
// to emit a specific diagnostic instead of the generic BF110.
|
|
239
|
+
ctx.reactiveFactories = prescan.factories
|
|
240
|
+
ctx.declinedReactiveFactories = prescan.declined
|
|
241
|
+
ctx.reactiveShapedHelpers = prescan.reactiveShaped
|
|
242
|
+
ctx.cleanFactoryImports = prescan.cleanFactoryImports
|
|
243
|
+
|
|
235
244
|
// BF050 — surface "no shared Program supplied for type-based reactivity
|
|
236
245
|
// classification" as a diagnostic so callers can fail strict builds
|
|
237
246
|
// rather than silently depending on the per-file Program fallback
|
|
@@ -1865,6 +1874,10 @@ const CLIENT_EXPORTS = new Set([
|
|
|
1865
1874
|
// `searchParams`. Runs natively on the client; SSR adapters lower a
|
|
1866
1875
|
// `queryHref(base, { … })` call to their query helper (go-template: `bf_query`).
|
|
1867
1876
|
'queryHref',
|
|
1877
|
+
// Pure date formatter (#2324). Runs natively on the client; SSR adapters
|
|
1878
|
+
// lower a `formatDate(date, pattern, tz)` call to their `format_date`
|
|
1879
|
+
// helper (spec/template-helpers.md).
|
|
1880
|
+
'formatDate',
|
|
1868
1881
|
// Compile-away JSX built-ins (#1915) — importing them is what scopes the
|
|
1869
1882
|
// compiler's `<Async>` / `<Region>` recognition; the import is elided on emit.
|
|
1870
1883
|
'Async', 'Region',
|
|
@@ -3867,14 +3880,27 @@ export const REACTIVE_PRIMITIVES = new Set([
|
|
|
3867
3880
|
|
|
3868
3881
|
interface PrescanResult {
|
|
3869
3882
|
factories: Map<string, ReactiveFactoryInfo>
|
|
3883
|
+
/** Factories recognized but declined for inlining (#2325). */
|
|
3884
|
+
declined: Map<string, DeclinedReactiveFactory>
|
|
3885
|
+
/** Module-scope helpers whose body wraps a reactive primitive but whose
|
|
3886
|
+
* shape is not an inlinable factory (#2325). */
|
|
3887
|
+
reactiveShaped: Set<string>
|
|
3888
|
+
/** Imported destructured-callee names whose helper file was resolved,
|
|
3889
|
+
* read, and found reactive-free / factory-free (#2325, filled by
|
|
3890
|
+
* `prescanImportedReactiveFactories`). */
|
|
3891
|
+
cleanFactoryImports: Set<string>
|
|
3870
3892
|
sourceFile: ts.SourceFile
|
|
3871
3893
|
}
|
|
3872
3894
|
|
|
3873
3895
|
/**
|
|
3874
3896
|
* Scan a source string for module-level function declarations that match
|
|
3875
|
-
* the reactive-factory shape (single `return [a, b, ...]`
|
|
3876
|
-
* one reactive primitive call in the body). Returns a
|
|
3877
|
-
* name to its metadata, and the parsed source file for
|
|
3897
|
+
* the reactive-factory shape (single `return [a, b, ...]` / `return { a, b
|
|
3898
|
+
* }` exit + at least one reactive primitive call in the body). Returns a
|
|
3899
|
+
* map from factory name to its metadata, and the parsed source file for
|
|
3900
|
+
* call-site rewriting. Also resolves factories defined in a relative-
|
|
3901
|
+
* imported helper file (#2325, see `prescanImportedReactiveFactories`) —
|
|
3902
|
+
* every `analyzeComponent` caller gets cross-file resolution with no call-
|
|
3903
|
+
* site changes elsewhere.
|
|
3878
3904
|
*/
|
|
3879
3905
|
function prescanReactiveFactoriesInSource(
|
|
3880
3906
|
source: string,
|
|
@@ -3888,11 +3914,25 @@ function prescanReactiveFactoriesInSource(
|
|
|
3888
3914
|
ts.ScriptKind.TSX
|
|
3889
3915
|
)
|
|
3890
3916
|
const factories = new Map<string, ReactiveFactoryInfo>()
|
|
3917
|
+
const declined = new Map<string, DeclinedReactiveFactory>()
|
|
3918
|
+
const reactiveShaped = new Set<string>()
|
|
3919
|
+
const cleanFactoryImports = new Set<string>()
|
|
3891
3920
|
|
|
3892
3921
|
function visitTop(node: ts.Node): void {
|
|
3893
3922
|
if (ts.isFunctionDeclaration(node) && node.name && node.body) {
|
|
3894
|
-
const
|
|
3895
|
-
if (
|
|
3923
|
+
const det = detectReactiveFactory(node, sourceFile, filePath)
|
|
3924
|
+
if (!det) return
|
|
3925
|
+
switch (det.kind) {
|
|
3926
|
+
case 'factory':
|
|
3927
|
+
factories.set(node.name.text, det.info)
|
|
3928
|
+
break
|
|
3929
|
+
case 'declined':
|
|
3930
|
+
declined.set(node.name.text, det.declined)
|
|
3931
|
+
break
|
|
3932
|
+
case 'reactive-shaped':
|
|
3933
|
+
reactiveShaped.add(node.name.text)
|
|
3934
|
+
break
|
|
3935
|
+
}
|
|
3896
3936
|
}
|
|
3897
3937
|
// Not recursing into function bodies: factory helpers are at module
|
|
3898
3938
|
// scope only for this round (issue #931 "In scope" §1).
|
|
@@ -3900,58 +3940,595 @@ function prescanReactiveFactoriesInSource(
|
|
|
3900
3940
|
|
|
3901
3941
|
ts.forEachChild(sourceFile, visitTop)
|
|
3902
3942
|
|
|
3903
|
-
|
|
3943
|
+
const result: PrescanResult = { factories, declined, reactiveShaped, cleanFactoryImports, sourceFile }
|
|
3944
|
+
prescanImportedReactiveFactories(sourceFile, filePath, result)
|
|
3945
|
+
return result
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
/**
|
|
3949
|
+
* #2332 — portable component-relative import specifier for a resolved
|
|
3950
|
+
* absolute helper-import target. Same `'.'`/`'./'` prefix convention as the
|
|
3951
|
+
* CLI's buildRelativeImportRewriter (packages/cli/src/lib/build.ts); strips
|
|
3952
|
+
* the resolved extension to match the codebase's extensionless-import
|
|
3953
|
+
* style. Unlike `buildRelativeImportRewriter`, this normalizes
|
|
3954
|
+
* `path.relative`'s separators to POSIX (`/`) — a backslash-separated
|
|
3955
|
+
* specifier is not valid ESM syntax, so on win32 `path.relative`'s native
|
|
3956
|
+
* output would inject a broken import rather than merely an unconventional
|
|
3957
|
+
* one (Copilot review, PR #2338).
|
|
3958
|
+
*/
|
|
3959
|
+
function toComponentRelativeSpecifier(resolvedAbs: string, componentFilePath: string): string {
|
|
3960
|
+
let rel = path.relative(path.dirname(componentFilePath), resolvedAbs).split(path.sep).join('/')
|
|
3961
|
+
rel = rel.replace(/\.(tsx|ts|jsx|js)$/, '')
|
|
3962
|
+
if (rel === '') rel = '.'
|
|
3963
|
+
if (!rel.startsWith('.')) rel = './' + rel
|
|
3964
|
+
return rel
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
/**
|
|
3968
|
+
* localName -> identity of what the entry file's own top-level named value
|
|
3969
|
+
* imports bind, for the satisfied-import dedupe check (#2332): if the
|
|
3970
|
+
* component file already imports the exact binding a factory needs to
|
|
3971
|
+
* re-provision, injecting it again would be a duplicate declaration rather
|
|
3972
|
+
* than a shadow, so that case is skipped instead of injected. `targetKey` is
|
|
3973
|
+
* the resolved absolute path for relative sources (or `unresolved:<source>`
|
|
3974
|
+
* when probing fails) and the raw specifier for bare sources.
|
|
3975
|
+
*/
|
|
3976
|
+
function buildEntryImportIndex(
|
|
3977
|
+
sf: ts.SourceFile,
|
|
3978
|
+
filePath: string
|
|
3979
|
+
): Map<string, { targetKey: string; exportedName: string }> {
|
|
3980
|
+
const index = new Map<string, { targetKey: string; exportedName: string }>()
|
|
3981
|
+
for (const stmt of sf.statements) {
|
|
3982
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
3983
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
3984
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
3985
|
+
const src = stmt.moduleSpecifier.text
|
|
3986
|
+
const targetKey = src.startsWith('./') || src.startsWith('../')
|
|
3987
|
+
? (resolveRelativeImportToFile(src, filePath) ?? 'unresolved:' + src)
|
|
3988
|
+
: src
|
|
3989
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
3990
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
3991
|
+
for (const el of namedBindings.elements) {
|
|
3992
|
+
if (el.isTypeOnly) continue
|
|
3993
|
+
index.set(el.name.text, { targetKey, exportedName: (el.propertyName ?? el.name).text })
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
return index
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
/**
|
|
4001
|
+
* Every value-binding name anywhere in the entry file (imports, variable
|
|
4002
|
+
* declarations at any depth, function/class/enum names, function
|
|
4003
|
+
* parameters) — used by the #2332 re-provisioned-import collision check
|
|
4004
|
+
* (BF113). Deliberately over-broad: the inlined factory body lands INSIDE a
|
|
4005
|
+
* component function, where any nested binding (params, destructures,
|
|
4006
|
+
* locals) would silently shadow a top-level import injected by this round.
|
|
4007
|
+
* A scan limited to top-level bindings would miss that, so any hit anywhere
|
|
4008
|
+
* in the file declines re-provisioning with a loud BF113 rather than
|
|
4009
|
+
* risking a silent shadow — matching `moduleCaptureCheck`'s stated failure-
|
|
4010
|
+
* direction philosophy (loud build error over silent runtime break).
|
|
4011
|
+
*/
|
|
4012
|
+
function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
|
|
4013
|
+
const names = new Set<string>()
|
|
4014
|
+
function visit(node: ts.Node): void {
|
|
4015
|
+
if (ts.isImportDeclaration(node) && node.importClause) {
|
|
4016
|
+
if (node.importClause.name) names.add(node.importClause.name.text)
|
|
4017
|
+
const namedBindings = node.importClause.namedBindings
|
|
4018
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
4019
|
+
// Type-only specifiers still occupy the identifier at the TS level.
|
|
4020
|
+
for (const el of namedBindings.elements) names.add(el.name.text)
|
|
4021
|
+
}
|
|
4022
|
+
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
4023
|
+
names.add(namedBindings.name.text)
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
if (ts.isVariableDeclaration(node)) {
|
|
4027
|
+
const out: string[] = []
|
|
4028
|
+
addBindingNames(node.name, out)
|
|
4029
|
+
for (const n of out) names.add(n)
|
|
4030
|
+
}
|
|
4031
|
+
if (
|
|
4032
|
+
(ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
|
|
4033
|
+
node.name
|
|
4034
|
+
) {
|
|
4035
|
+
names.add(node.name.text)
|
|
4036
|
+
}
|
|
4037
|
+
if (ts.isFunctionLike(node)) {
|
|
4038
|
+
for (const p of node.parameters) {
|
|
4039
|
+
const out: string[] = []
|
|
4040
|
+
addBindingNames(p.name, out)
|
|
4041
|
+
for (const n of out) names.add(n)
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
ts.forEachChild(node, visit)
|
|
4045
|
+
}
|
|
4046
|
+
visit(sf)
|
|
4047
|
+
return names
|
|
3904
4048
|
}
|
|
3905
4049
|
|
|
4050
|
+
/**
|
|
4051
|
+
* Cross-file half of the factory prescan (#2325 round 2): resolve factories
|
|
4052
|
+
* defined in a relative-imported helper file so `const { count } =
|
|
4053
|
+
* createCounter(0)` inlines the same way whether `createCounter` lives in
|
|
4054
|
+
* this file or in `./hooks`. Mutates `result`'s maps in place.
|
|
4055
|
+
*
|
|
4056
|
+
* Perf: gated on a candidate-callee set collected from the ALREADY-parsed
|
|
4057
|
+
* entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
|
|
4058
|
+
* imports with regex" rule) — files with no tuple/object-destructured call
|
|
4059
|
+
* at all skip every filesystem access below. A second cheap gate (does the
|
|
4060
|
+
* helper file's raw text contain any `REACTIVE_PRIMITIVES` substring) skips
|
|
4061
|
+
* the AST parse of helper files that plainly aren't reactive; this is a
|
|
4062
|
+
* skip-gate over content, not an import parse, so it doesn't run afoul of
|
|
4063
|
+
* that same rule.
|
|
4064
|
+
*
|
|
4065
|
+
* Name-collision precedence: a same-file factory/declined/reactive-shaped
|
|
4066
|
+
* entry always wins over a same-named cross-file import — every write
|
|
4067
|
+
* below is guarded on the name being unclaimed in all three buckets.
|
|
4068
|
+
*/
|
|
4069
|
+
function prescanImportedReactiveFactories(
|
|
4070
|
+
entrySourceFile: ts.SourceFile,
|
|
4071
|
+
filePath: string,
|
|
4072
|
+
result: PrescanResult
|
|
4073
|
+
): void {
|
|
4074
|
+
// 1. Candidate gate: names destructured (tuple or object) from a direct
|
|
4075
|
+
// call-expression initializer, anywhere in the file. Cheap AST walk,
|
|
4076
|
+
// no filesystem access.
|
|
4077
|
+
const candidateCallees = new Set<string>()
|
|
4078
|
+
function collectCandidates(node: ts.Node): void {
|
|
4079
|
+
if (
|
|
4080
|
+
ts.isVariableDeclaration(node) &&
|
|
4081
|
+
(ts.isArrayBindingPattern(node.name) || ts.isObjectBindingPattern(node.name)) &&
|
|
4082
|
+
node.initializer &&
|
|
4083
|
+
ts.isCallExpression(node.initializer) &&
|
|
4084
|
+
ts.isIdentifier(node.initializer.expression)
|
|
4085
|
+
) {
|
|
4086
|
+
candidateCallees.add(node.initializer.expression.text)
|
|
4087
|
+
}
|
|
4088
|
+
ts.forEachChild(node, collectCandidates)
|
|
4089
|
+
}
|
|
4090
|
+
collectCandidates(entrySourceFile)
|
|
4091
|
+
if (candidateCallees.size === 0) return
|
|
4092
|
+
|
|
4093
|
+
// 2. Relative imports whose named specifiers overlap the candidate set.
|
|
4094
|
+
interface CandidateSpec {
|
|
4095
|
+
/** Name exported from the helper file. */
|
|
4096
|
+
exported: string
|
|
4097
|
+
/** Local (call-site) binding name — the key every result map uses. */
|
|
4098
|
+
local: string
|
|
4099
|
+
}
|
|
4100
|
+
const importsToCheck: { src: string; specs: CandidateSpec[] }[] = []
|
|
4101
|
+
for (const stmt of entrySourceFile.statements) {
|
|
4102
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
4103
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
4104
|
+
const src = stmt.moduleSpecifier.text
|
|
4105
|
+
// Mirrors `scanImportedClientSignals`'s restriction to relative
|
|
4106
|
+
// specifiers — non-relative (bare/aliased) imports resolve through
|
|
4107
|
+
// bundler / tsconfig-paths configuration this layer doesn't consume.
|
|
4108
|
+
if (!src.startsWith('./') && !src.startsWith('../')) continue
|
|
4109
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
4110
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
4111
|
+
if (!namedBindings || !ts.isNamedImports(namedBindings)) continue
|
|
4112
|
+
|
|
4113
|
+
const specs: CandidateSpec[] = []
|
|
4114
|
+
for (const el of namedBindings.elements) {
|
|
4115
|
+
if (el.isTypeOnly) continue
|
|
4116
|
+
const local = el.name.text
|
|
4117
|
+
if (!candidateCallees.has(local)) continue
|
|
4118
|
+
specs.push({ exported: (el.propertyName ?? el.name).text, local })
|
|
4119
|
+
}
|
|
4120
|
+
if (specs.length === 0) continue
|
|
4121
|
+
importsToCheck.push({ src, specs })
|
|
4122
|
+
}
|
|
4123
|
+
if (importsToCheck.length === 0) return
|
|
4124
|
+
|
|
4125
|
+
// #2332 — computed once per entry file, shared across all helper files.
|
|
4126
|
+
const entryBindingNames = collectEntryBindingNames(entrySourceFile)
|
|
4127
|
+
const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
|
|
4128
|
+
// localName -> planned injection identity; a later factory requiring the
|
|
4129
|
+
// same name from a DIFFERENT (targetKey, exportedName) is a collision.
|
|
4130
|
+
const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
|
|
4131
|
+
|
|
4132
|
+
for (const { src, specs } of importsToCheck) {
|
|
4133
|
+
const resolved = resolveRelativeImportToFile(src, filePath)
|
|
4134
|
+
// Unresolvable — left alone here; the name-heuristic BF110 branch in
|
|
4135
|
+
// validateReactiveFactoryCalls handles it at validation time.
|
|
4136
|
+
if (!resolved) continue
|
|
4137
|
+
|
|
4138
|
+
let content: string
|
|
4139
|
+
try {
|
|
4140
|
+
content = fs.readFileSync(resolved, 'utf8')
|
|
4141
|
+
} catch {
|
|
4142
|
+
continue
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
const alreadyKnown = (name: string): boolean =>
|
|
4146
|
+
result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
|
|
4147
|
+
|
|
4148
|
+
// Cheap text-level skip-gate (not an import/JS parse — see docstring):
|
|
4149
|
+
// a helper file with no reactive-primitive substring anywhere cannot
|
|
4150
|
+
// define a reactive factory, so skip parsing it entirely.
|
|
4151
|
+
const hasAnyPrimitiveText = [...REACTIVE_PRIMITIVES].some(p => content.includes(p))
|
|
4152
|
+
if (!hasAnyPrimitiveText) {
|
|
4153
|
+
for (const spec of specs) {
|
|
4154
|
+
if (!alreadyKnown(spec.local)) result.cleanFactoryImports.add(spec.local)
|
|
4155
|
+
}
|
|
4156
|
+
continue
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
const helperSf = ts.createSourceFile(
|
|
4160
|
+
resolved + '.prescan',
|
|
4161
|
+
content,
|
|
4162
|
+
ts.ScriptTarget.Latest,
|
|
4163
|
+
true,
|
|
4164
|
+
ts.ScriptKind.TSX
|
|
4165
|
+
)
|
|
4166
|
+
|
|
4167
|
+
// Map exported name -> module-scope FunctionDeclaration via the AST.
|
|
4168
|
+
const localFns = new Map<string, ts.FunctionDeclaration>()
|
|
4169
|
+
const exportedFns = new Map<string, ts.FunctionDeclaration>()
|
|
4170
|
+
for (const stmt of helperSf.statements) {
|
|
4171
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
|
|
4172
|
+
localFns.set(stmt.name.text, stmt)
|
|
4173
|
+
const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
4174
|
+
const hasDefaultModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false
|
|
4175
|
+
if (hasExportModifier && !hasDefaultModifier) {
|
|
4176
|
+
exportedFns.set(stmt.name.text, stmt)
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
// `export { f }` / `export { f as g }` — keyed by the EXTERNAL export name.
|
|
4181
|
+
for (const stmt of helperSf.statements) {
|
|
4182
|
+
if (
|
|
4183
|
+
ts.isExportDeclaration(stmt) &&
|
|
4184
|
+
stmt.exportClause &&
|
|
4185
|
+
ts.isNamedExports(stmt.exportClause) &&
|
|
4186
|
+
!stmt.moduleSpecifier &&
|
|
4187
|
+
!stmt.isTypeOnly
|
|
4188
|
+
) {
|
|
4189
|
+
for (const el of stmt.exportClause.elements) {
|
|
4190
|
+
if (el.isTypeOnly) continue
|
|
4191
|
+
const fn = localFns.get((el.propertyName ?? el.name).text)
|
|
4192
|
+
if (fn) exportedFns.set(el.name.text, fn)
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
// Module-scope bindings the helper file declares — an inlined factory
|
|
4198
|
+
// body must not reference any of these (#2325 §4h / BF112), since
|
|
4199
|
+
// inlining moves the body into the component file where they don't
|
|
4200
|
+
// exist.
|
|
4201
|
+
const moduleBindings = collectHelperModuleValueBindings(helperSf)
|
|
4202
|
+
|
|
4203
|
+
for (const spec of specs) {
|
|
4204
|
+
if (alreadyKnown(spec.local)) continue
|
|
4205
|
+
|
|
4206
|
+
const fn = exportedFns.get(spec.exported)
|
|
4207
|
+
if (!fn) {
|
|
4208
|
+
result.cleanFactoryImports.add(spec.local)
|
|
4209
|
+
continue
|
|
4210
|
+
}
|
|
4211
|
+
const det = detectReactiveFactory(fn, helperSf, resolved)
|
|
4212
|
+
if (!det) {
|
|
4213
|
+
result.cleanFactoryImports.add(spec.local)
|
|
4214
|
+
continue
|
|
4215
|
+
}
|
|
4216
|
+
switch (det.kind) {
|
|
4217
|
+
case 'reactive-shaped':
|
|
4218
|
+
result.reactiveShaped.add(spec.local)
|
|
4219
|
+
break
|
|
4220
|
+
case 'declined':
|
|
4221
|
+
result.declined.set(spec.local, det.declined)
|
|
4222
|
+
break
|
|
4223
|
+
case 'factory': {
|
|
4224
|
+
const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
|
|
4225
|
+
if (capture.captured.length > 0) {
|
|
4226
|
+
result.declined.set(spec.local, {
|
|
4227
|
+
code: 'BF112',
|
|
4228
|
+
detail: `'${capture.captured.join("', '")}'`,
|
|
4229
|
+
loc: det.info.loc,
|
|
4230
|
+
})
|
|
4231
|
+
break
|
|
4232
|
+
}
|
|
4233
|
+
// #2332 — re-provision the helper file's own named value imports
|
|
4234
|
+
// that the factory body references, instead of declining. Each
|
|
4235
|
+
// ref resolves to a component-relative specifier (or passes
|
|
4236
|
+
// through unchanged for bare/npm specifiers); a ref already
|
|
4237
|
+
// satisfied by an identical top-level import in the component
|
|
4238
|
+
// file is dropped rather than injected (would redeclare it). A
|
|
4239
|
+
// ref whose local name collides with a DIFFERENT existing/planned
|
|
4240
|
+
// binding declines with BF113 — `pending` is only merged into
|
|
4241
|
+
// `plannedInjections` on full factory success (§3.4), so a
|
|
4242
|
+
// factory that declines mid-loop reserves nothing.
|
|
4243
|
+
const required: RequiredFactoryImport[] = []
|
|
4244
|
+
const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
|
|
4245
|
+
let declinedEntry: DeclinedReactiveFactory | null = null
|
|
4246
|
+
for (const ref of capture.importedRefs) {
|
|
4247
|
+
let specifier: string
|
|
4248
|
+
let targetKey: string
|
|
4249
|
+
if (ref.source.startsWith('./') || ref.source.startsWith('../')) {
|
|
4250
|
+
// Resolve from the HELPER file's directory (`resolved` is its
|
|
4251
|
+
// absolute path). Unresolvable → same posture as a local
|
|
4252
|
+
// capture: nothing importable to re-provision (BF112).
|
|
4253
|
+
const abs = resolveRelativeImportToFile(ref.source, resolved)
|
|
4254
|
+
if (!abs) {
|
|
4255
|
+
declinedEntry = {
|
|
4256
|
+
code: 'BF112',
|
|
4257
|
+
detail: `'${ref.localName}' (import '${ref.source}' did not resolve from the helper file)`,
|
|
4258
|
+
loc: det.info.loc,
|
|
4259
|
+
}
|
|
4260
|
+
break
|
|
4261
|
+
}
|
|
4262
|
+
specifier = toComponentRelativeSpecifier(abs, filePath)
|
|
4263
|
+
targetKey = abs
|
|
4264
|
+
} else {
|
|
4265
|
+
specifier = ref.source // bare/npm specifier — unchanged (#2332 test 2)
|
|
4266
|
+
targetKey = ref.source
|
|
4267
|
+
}
|
|
4268
|
+
// Already satisfied by an identical top-level import in the
|
|
4269
|
+
// component file — injecting again would redeclare the binding.
|
|
4270
|
+
const existing = entryImportIndex.get(ref.localName)
|
|
4271
|
+
if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
|
|
4272
|
+
continue
|
|
4273
|
+
}
|
|
4274
|
+
const planned = plannedInjections.get(ref.localName)
|
|
4275
|
+
const collides =
|
|
4276
|
+
(existing !== undefined) ||
|
|
4277
|
+
(planned !== undefined && (planned.targetKey !== targetKey || planned.exportedName !== ref.exportedName)) ||
|
|
4278
|
+
(planned === undefined && entryBindingNames.has(ref.localName))
|
|
4279
|
+
if (collides) {
|
|
4280
|
+
declinedEntry = {
|
|
4281
|
+
code: 'BF113',
|
|
4282
|
+
detail: `'${ref.localName}' from '${specifier}'`,
|
|
4283
|
+
loc: det.info.loc,
|
|
4284
|
+
}
|
|
4285
|
+
break
|
|
4286
|
+
}
|
|
4287
|
+
pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
|
|
4288
|
+
required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
|
|
4289
|
+
}
|
|
4290
|
+
if (declinedEntry) {
|
|
4291
|
+
result.declined.set(spec.local, declinedEntry)
|
|
4292
|
+
break
|
|
4293
|
+
}
|
|
4294
|
+
for (const [name, id] of pending) plannedInjections.set(name, id)
|
|
4295
|
+
det.info.sourceFilePath = resolved
|
|
4296
|
+
if (required.length > 0) det.info.requiredImports = required
|
|
4297
|
+
result.factories.set(spec.local, det.info)
|
|
4298
|
+
break
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
/**
|
|
4306
|
+
* Value bindings at the helper file's module scope, split by whether they
|
|
4307
|
+
* have a re-importable module of their own (#2332).
|
|
4308
|
+
*
|
|
4309
|
+
* `local` bindings — top-level const/let/var, function/class/enum names,
|
|
4310
|
+
* plus default-import and namespace-import names — have no module a
|
|
4311
|
+
* component file could re-import them from, so an inlined factory body
|
|
4312
|
+
* referencing one unconditionally declines with BF112 (#2325 §4h): moving
|
|
4313
|
+
* the body into the component file would leave a dangling reference.
|
|
4314
|
+
*
|
|
4315
|
+
* `imported` bindings — the helper file's own named value imports — CAN be
|
|
4316
|
+
* re-provisioned: the component file can import the same binding under the
|
|
4317
|
+
* same specifier (#2332). These are collected here (keyed by the helper
|
|
4318
|
+
* file's local name) but are NOT captures; `moduleCaptureCheck` below
|
|
4319
|
+
* reports them separately from `local` hits so the caller can decide
|
|
4320
|
+
* whether to re-import rather than unconditionally decline.
|
|
4321
|
+
*
|
|
4322
|
+
* EXCEPT in both cases: imports from '@barefootjs/client' /
|
|
4323
|
+
* '@barefootjs/client/runtime'. Those are re-provisioned from usage by the
|
|
4324
|
+
* client-JS emitter regardless of where the call that used them textually
|
|
4325
|
+
* came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
|
|
4326
|
+
* *generated* code, not the consumer's source imports — see #2325 spec C1),
|
|
4327
|
+
* so an inlined body calling `createSignal` is never a capture even though
|
|
4328
|
+
* the helper file itself imports it. Type-only imports/declarations carry
|
|
4329
|
+
* no runtime binding and are excluded.
|
|
4330
|
+
*/
|
|
4331
|
+
interface HelperModuleBindings {
|
|
4332
|
+
/** Declared directly in the helper file — unconditional BF112 capture. */
|
|
4333
|
+
local: Set<string>
|
|
4334
|
+
/** Named value-import specifiers, keyed by helper-file local name —
|
|
4335
|
+
* re-provisionable into the component file (#2332). */
|
|
4336
|
+
imported: Map<string, { source: string; exportedName: string }>
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
|
|
4340
|
+
const local = new Set<string>()
|
|
4341
|
+
const imported = new Map<string, { source: string; exportedName: string }>()
|
|
4342
|
+
for (const stmt of sf.statements) {
|
|
4343
|
+
if (ts.isVariableStatement(stmt)) {
|
|
4344
|
+
const out: string[] = []
|
|
4345
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
4346
|
+
addBindingNames(decl.name, out)
|
|
4347
|
+
}
|
|
4348
|
+
for (const n of out) local.add(n)
|
|
4349
|
+
continue
|
|
4350
|
+
}
|
|
4351
|
+
if (
|
|
4352
|
+
(ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
|
|
4353
|
+
stmt.name
|
|
4354
|
+
) {
|
|
4355
|
+
local.add(stmt.name.text)
|
|
4356
|
+
continue
|
|
4357
|
+
}
|
|
4358
|
+
if (ts.isImportDeclaration(stmt)) {
|
|
4359
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
4360
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
4361
|
+
const src = stmt.moduleSpecifier.text
|
|
4362
|
+
if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
|
|
4363
|
+
// Default/namespace imports stay hard BF112 (#2332 scope decision):
|
|
4364
|
+
// no single named export to re-provision under one local name.
|
|
4365
|
+
if (stmt.importClause?.name) local.add(stmt.importClause.name.text)
|
|
4366
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
4367
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
4368
|
+
for (const el of namedBindings.elements) {
|
|
4369
|
+
if (el.isTypeOnly) continue
|
|
4370
|
+
imported.set(el.name.text, { source: src, exportedName: (el.propertyName ?? el.name).text })
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
4374
|
+
local.add(namedBindings.name.text)
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
return { local, imported }
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
/**
|
|
4382
|
+
* Categorized free-identifier references of a reactive-factory body into
|
|
4383
|
+
* its own module scope (#2332): `captured` are unconditional BF112 hits
|
|
4384
|
+
* (helper-local bindings — the body would dangle if inlined verbatim);
|
|
4385
|
+
* `importedRefs` are references to the helper file's own named value
|
|
4386
|
+
* imports, which the caller may re-provision into the component file
|
|
4387
|
+
* instead of declining (§3.4 in the #2332 spec).
|
|
4388
|
+
*/
|
|
4389
|
+
interface ModuleCaptureResult {
|
|
4390
|
+
/** Free refs resolving to helper-local bindings (BF112), sorted. */
|
|
4391
|
+
captured: string[]
|
|
4392
|
+
/** Free refs resolving to the helper's own named value imports, sorted by localName. */
|
|
4393
|
+
importedRefs: Array<{ localName: string; source: string; exportedName: string }>
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
/**
|
|
4397
|
+
* Free identifiers of a reactive-factory body that resolve to bindings at
|
|
4398
|
+
* its own module scope (#2325 §4h / BF112, #2332) — references the inlined
|
|
4399
|
+
* body would silently lose once spliced into the component file, unless
|
|
4400
|
+
* re-provisioned as an import. Returns `captured` (unconditional BF112) and
|
|
4401
|
+
* `importedRefs` (re-provisionable) separately, each sorted for stable
|
|
4402
|
+
* diagnostic/injection text.
|
|
4403
|
+
*
|
|
4404
|
+
* Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
|
|
4405
|
+
* tracks arrow-function parameters, not nested `function` declarations'
|
|
4406
|
+
* parameters or nested-block declarations — a body-nested binding that
|
|
4407
|
+
* happens to shadow a helper-module binding could false-positive into
|
|
4408
|
+
* BF112 or `importedRefs`. Acceptable: the failure direction is always a
|
|
4409
|
+
* loud build error (BF112/BF113) or a redundant-but-harmless injected
|
|
4410
|
+
* import, never a silent dangling reference.
|
|
4411
|
+
*/
|
|
4412
|
+
function moduleCaptureCheck(
|
|
4413
|
+
fn: ts.FunctionDeclaration,
|
|
4414
|
+
info: ReactiveFactoryInfo,
|
|
4415
|
+
moduleBindings: HelperModuleBindings,
|
|
4416
|
+
selfName: string
|
|
4417
|
+
): ModuleCaptureResult {
|
|
4418
|
+
if (!fn.body) return { captured: [], importedRefs: [] }
|
|
4419
|
+
const free = extractFreeIdentifiersFromNode(fn.body)
|
|
4420
|
+
const exclude = new Set<string>(info.params)
|
|
4421
|
+
for (const b of info.localBindings) exclude.add(b)
|
|
4422
|
+
for (const r of info.returnTupleIdentifiers) exclude.add(r)
|
|
4423
|
+
for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
|
|
4424
|
+
exclude.add(selfName)
|
|
4425
|
+
|
|
4426
|
+
const captured: string[] = []
|
|
4427
|
+
const importedRefs: ModuleCaptureResult['importedRefs'] = []
|
|
4428
|
+
for (const id of free) {
|
|
4429
|
+
if (exclude.has(id)) continue
|
|
4430
|
+
if (moduleBindings.local.has(id)) {
|
|
4431
|
+
captured.push(id)
|
|
4432
|
+
continue
|
|
4433
|
+
}
|
|
4434
|
+
const imp = moduleBindings.imported.get(id)
|
|
4435
|
+
if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
|
|
4436
|
+
}
|
|
4437
|
+
captured.sort()
|
|
4438
|
+
importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
|
|
4439
|
+
return { captured, importedRefs }
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
/**
|
|
4443
|
+
* Classification result for a module-level function that might be a
|
|
4444
|
+
* reactive-factory helper (#2325). `null` means the function has no
|
|
4445
|
+
* reactive-primitive call anywhere in its body, so it isn't reactive-
|
|
4446
|
+
* related at all — every other case implies at least one such call.
|
|
4447
|
+
*/
|
|
4448
|
+
type FactoryDetection =
|
|
4449
|
+
| { kind: 'factory'; info: ReactiveFactoryInfo }
|
|
4450
|
+
| { kind: 'declined'; declined: DeclinedReactiveFactory }
|
|
4451
|
+
| { kind: 'reactive-shaped' } // wraps a reactive primitive but shape unsupported
|
|
4452
|
+
| null
|
|
4453
|
+
|
|
3906
4454
|
function detectReactiveFactory(
|
|
3907
4455
|
node: ts.FunctionDeclaration,
|
|
3908
4456
|
sourceFile: ts.SourceFile,
|
|
3909
4457
|
filePath: string
|
|
3910
|
-
):
|
|
4458
|
+
): FactoryDetection {
|
|
3911
4459
|
if (!node.body || !node.name) return null
|
|
3912
4460
|
|
|
3913
|
-
//
|
|
3914
|
-
//
|
|
3915
|
-
|
|
4461
|
+
// Body must contain at least one reactive primitive call so that the
|
|
4462
|
+
// factory is actually the right thing to inline (not just a helper that
|
|
4463
|
+
// happens to look similar). Checked FIRST: a function with zero reactive
|
|
4464
|
+
// calls is not reactive-related in any way, so it is out of scope for
|
|
4465
|
+
// every diagnostic below, not just "not a factory".
|
|
4466
|
+
let hasReactiveCall = false
|
|
4467
|
+
function checkForReactive(n: ts.Node): void {
|
|
4468
|
+
if (hasReactiveCall) return
|
|
4469
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) &&
|
|
4470
|
+
REACTIVE_PRIMITIVES.has(n.expression.text)) {
|
|
4471
|
+
hasReactiveCall = true
|
|
4472
|
+
return
|
|
4473
|
+
}
|
|
4474
|
+
ts.forEachChild(n, checkForReactive)
|
|
4475
|
+
}
|
|
4476
|
+
checkForReactive(node.body)
|
|
4477
|
+
if (!hasReactiveCall) return null
|
|
4478
|
+
|
|
4479
|
+
const loc = getSourceLocation(node, sourceFile, filePath)
|
|
4480
|
+
|
|
4481
|
+
// Require exactly one top-level `return`, whose argument (after unwrapping
|
|
4482
|
+
// parens / `as const` / type-assertion) is a tuple (array literal) or a
|
|
4483
|
+
// shorthand-object literal.
|
|
4484
|
+
let returnExpr: ts.Expression | null = null
|
|
3916
4485
|
let returnCount = 0
|
|
3917
4486
|
for (const stmt of node.body.statements) {
|
|
3918
4487
|
if (!ts.isReturnStatement(stmt)) continue
|
|
3919
4488
|
returnCount++
|
|
3920
|
-
if (!stmt.expression) return
|
|
4489
|
+
if (!stmt.expression) return { kind: 'reactive-shaped' }
|
|
3921
4490
|
let expr: ts.Expression = stmt.expression
|
|
3922
4491
|
while (ts.isParenthesizedExpression(expr)) expr = expr.expression
|
|
3923
4492
|
// Accept `... as const` / `<const>...`
|
|
3924
4493
|
if (ts.isAsExpression(expr)) expr = expr.expression
|
|
3925
4494
|
if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
|
|
3926
|
-
|
|
3927
|
-
tupleReturn = expr
|
|
4495
|
+
returnExpr = expr
|
|
3928
4496
|
}
|
|
3929
|
-
if (returnCount !== 1 || !
|
|
4497
|
+
if (returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
|
|
3930
4498
|
|
|
3931
|
-
// Every element must be a plain identifier (no spreads, computed,
|
|
3932
|
-
// call expressions). Otherwise the caller-side rename would not be sound.
|
|
3933
4499
|
const returnTupleIdentifiers: string[] = []
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
let hasReactiveCall = false
|
|
3944
|
-
function checkForReactive(n: ts.Node): void {
|
|
3945
|
-
if (hasReactiveCall) return
|
|
3946
|
-
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) &&
|
|
3947
|
-
REACTIVE_PRIMITIVES.has(n.expression.text)) {
|
|
3948
|
-
hasReactiveCall = true
|
|
3949
|
-
return
|
|
4500
|
+
let returnKind: 'tuple' | 'object'
|
|
4501
|
+
|
|
4502
|
+
if (ts.isArrayLiteralExpression(returnExpr)) {
|
|
4503
|
+
returnKind = 'tuple'
|
|
4504
|
+
// Every element must be a plain identifier (no spreads, computed,
|
|
4505
|
+
// call expressions). Otherwise the caller-side rename would not be sound.
|
|
4506
|
+
for (const el of returnExpr.elements) {
|
|
4507
|
+
if (!ts.isIdentifier(el)) return { kind: 'reactive-shaped' }
|
|
4508
|
+
returnTupleIdentifiers.push(el.text)
|
|
3950
4509
|
}
|
|
3951
|
-
|
|
4510
|
+
if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
|
|
4511
|
+
} else if (ts.isObjectLiteralExpression(returnExpr)) {
|
|
4512
|
+
returnKind = 'object'
|
|
4513
|
+
const hasNonShorthand = returnExpr.properties.some(p => !ts.isShorthandPropertyAssignment(p))
|
|
4514
|
+
if (hasNonShorthand) {
|
|
4515
|
+
return {
|
|
4516
|
+
kind: 'declined',
|
|
4517
|
+
declined: {
|
|
4518
|
+
code: 'BF111',
|
|
4519
|
+
detail: `return object of '${node.name.text}' uses non-shorthand properties`,
|
|
4520
|
+
loc,
|
|
4521
|
+
},
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
for (const p of returnExpr.properties) {
|
|
4525
|
+
// Every property already proven ts.isShorthandPropertyAssignment above.
|
|
4526
|
+
returnTupleIdentifiers.push((p as ts.ShorthandPropertyAssignment).name.text)
|
|
4527
|
+
}
|
|
4528
|
+
if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
|
|
4529
|
+
} else {
|
|
4530
|
+
return { kind: 'reactive-shaped' }
|
|
3952
4531
|
}
|
|
3953
|
-
checkForReactive(node.body)
|
|
3954
|
-
if (!hasReactiveCall) return null
|
|
3955
4532
|
|
|
3956
4533
|
// Collect local bindings in the factory body for identifier hygiene at
|
|
3957
4534
|
// inlining time. Only direct-child declarations of the block are
|
|
@@ -3968,26 +4545,35 @@ function detectReactiveFactory(
|
|
|
3968
4545
|
}
|
|
3969
4546
|
|
|
3970
4547
|
// Serialize the body without the outer braces and without the return
|
|
3971
|
-
// statement — the return tuple is dissolved into caller-named
|
|
4548
|
+
// statement — the return tuple/object is dissolved into caller-named
|
|
4549
|
+
// identifiers.
|
|
3972
4550
|
const bodyStatements = node.body.statements
|
|
3973
4551
|
.filter(s => !ts.isReturnStatement(s))
|
|
3974
4552
|
.map(s => s.getText(sourceFile))
|
|
3975
4553
|
.join('\n')
|
|
3976
4554
|
|
|
3977
|
-
const params =
|
|
3978
|
-
|
|
4555
|
+
const params: string[] = []
|
|
4556
|
+
for (const p of node.parameters) {
|
|
4557
|
+
if (ts.isIdentifier(p.name)) {
|
|
4558
|
+
params.push(p.name.text)
|
|
4559
|
+
continue
|
|
4560
|
+
}
|
|
3979
4561
|
// Destructured params are uncommon for this helper shape and out of
|
|
3980
|
-
// initial scope;
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
4562
|
+
// initial scope; the factory still wraps a reactive primitive, so
|
|
4563
|
+
// classify it as reactive-shaped rather than silently ignoring it.
|
|
4564
|
+
return { kind: 'reactive-shaped' }
|
|
4565
|
+
}
|
|
3984
4566
|
|
|
3985
4567
|
return {
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
4568
|
+
kind: 'factory',
|
|
4569
|
+
info: {
|
|
4570
|
+
params,
|
|
4571
|
+
bodySource: bodyStatements,
|
|
4572
|
+
returnTupleIdentifiers,
|
|
4573
|
+
returnKind,
|
|
4574
|
+
localBindings,
|
|
4575
|
+
loc,
|
|
4576
|
+
},
|
|
3991
4577
|
}
|
|
3992
4578
|
}
|
|
3993
4579
|
|
|
@@ -4029,6 +4615,12 @@ function rewriteFactoryCallsInSource(
|
|
|
4029
4615
|
type Edit = { start: number; end: number; replacement: string }
|
|
4030
4616
|
const edits: Edit[] = []
|
|
4031
4617
|
let callSiteIndex = 0
|
|
4618
|
+
// #2332 — factories actually inlined in this walk (not merely present in
|
|
4619
|
+
// `prescan.factories`; `maybeRewriteDecl` bails on arity mismatch/omitted
|
|
4620
|
+
// elements/rename destructures without inlining), so their
|
|
4621
|
+
// `requiredImports` can be re-provisioned without adding a dead import
|
|
4622
|
+
// for a factory that was never actually spliced in.
|
|
4623
|
+
const inlinedFactories = new Set<ReactiveFactoryInfo>()
|
|
4032
4624
|
|
|
4033
4625
|
function visitStmt(node: ts.Node, inComponent: boolean): void {
|
|
4034
4626
|
if (ts.isVariableStatement(node) && inComponent) {
|
|
@@ -4048,16 +4640,33 @@ function rewriteFactoryCallsInSource(
|
|
|
4048
4640
|
}
|
|
4049
4641
|
|
|
4050
4642
|
function maybeRewriteDecl(stmt: ts.VariableStatement, decl: ts.VariableDeclaration): void {
|
|
4051
|
-
if (!ts.isArrayBindingPattern(decl.name)) return
|
|
4052
4643
|
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return
|
|
4053
4644
|
if (!ts.isIdentifier(decl.initializer.expression)) return
|
|
4054
4645
|
const factoryName = decl.initializer.expression.text
|
|
4055
4646
|
const factory = factories.get(factoryName)
|
|
4056
4647
|
if (!factory) return
|
|
4057
4648
|
|
|
4649
|
+
if (ts.isArrayBindingPattern(decl.name)) {
|
|
4650
|
+
if (factory.returnKind !== 'tuple') return
|
|
4651
|
+
rewriteTupleDecl(stmt, decl.name, decl.initializer, factory)
|
|
4652
|
+
return
|
|
4653
|
+
}
|
|
4654
|
+
if (ts.isObjectBindingPattern(decl.name)) {
|
|
4655
|
+
if (factory.returnKind !== 'object') return
|
|
4656
|
+
rewriteObjectDecl(stmt, decl.name, decl.initializer, factory)
|
|
4657
|
+
return
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
function rewriteTupleDecl(
|
|
4662
|
+
stmt: ts.VariableStatement,
|
|
4663
|
+
pattern: ts.ArrayBindingPattern,
|
|
4664
|
+
call: ts.CallExpression,
|
|
4665
|
+
factory: ReactiveFactoryInfo
|
|
4666
|
+
): void {
|
|
4058
4667
|
// Arity check — bail out on mismatch so the analyzer can report BF110
|
|
4059
4668
|
// on the untouched source.
|
|
4060
|
-
const elements =
|
|
4669
|
+
const elements = pattern.elements
|
|
4061
4670
|
if (elements.length !== factory.returnTupleIdentifiers.length) return
|
|
4062
4671
|
|
|
4063
4672
|
// Caller-side identifier names (one per tuple slot). Omitted slots
|
|
@@ -4069,17 +4678,74 @@ function rewriteFactoryCallsInSource(
|
|
|
4069
4678
|
callerNames.push(el.name.text)
|
|
4070
4679
|
}
|
|
4071
4680
|
|
|
4072
|
-
|
|
4681
|
+
// Exclude params + every return-tuple identifier from suffix-renaming
|
|
4682
|
+
// (they're renamed to caller names below instead).
|
|
4683
|
+
const excludeFromSuffixRename = new Set<string>(factory.params)
|
|
4684
|
+
for (const r of factory.returnTupleIdentifiers) excludeFromSuffixRename.add(r)
|
|
4685
|
+
|
|
4686
|
+
const renameReturnToCallerNames = new Map<string, string>()
|
|
4687
|
+
for (let i = 0; i < factory.returnTupleIdentifiers.length; i++) {
|
|
4688
|
+
renameReturnToCallerNames.set(factory.returnTupleIdentifiers[i], callerNames[i])
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, renameReturnToCallerNames)
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
function rewriteObjectDecl(
|
|
4695
|
+
stmt: ts.VariableStatement,
|
|
4696
|
+
pattern: ts.ObjectBindingPattern,
|
|
4697
|
+
call: ts.CallExpression,
|
|
4698
|
+
factory: ReactiveFactoryInfo
|
|
4699
|
+
): void {
|
|
4700
|
+
// Shorthand destructuring only (no renames/defaults/rest) of names the
|
|
4701
|
+
// factory actually returns. Anything else bails so the analyzer can
|
|
4702
|
+
// report BF110/BF111 on the untouched source. Subset destructures are
|
|
4703
|
+
// allowed — a caller may destructure fewer than all returned names.
|
|
4704
|
+
const destructured = new Set<string>()
|
|
4705
|
+
for (const el of pattern.elements) {
|
|
4706
|
+
if (el.dotDotDotToken) return
|
|
4707
|
+
if (el.propertyName) return
|
|
4708
|
+
if (el.initializer) return
|
|
4709
|
+
if (!ts.isIdentifier(el.name)) return
|
|
4710
|
+
if (!factory.returnTupleIdentifiers.includes(el.name.text)) return
|
|
4711
|
+
destructured.add(el.name.text)
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
// Exclude params + the destructured names from suffix-renaming (caller
|
|
4715
|
+
// name already equals the property name under shorthand, C4). Returned
|
|
4716
|
+
// names the caller did NOT destructure are ordinary internal locals and
|
|
4717
|
+
// DO get suffix-renamed — otherwise two subset calls of the same
|
|
4718
|
+
// factory collide on the undestructured name.
|
|
4719
|
+
const excludeFromSuffixRename = new Set<string>(factory.params)
|
|
4720
|
+
for (const d of destructured) excludeFromSuffixRename.add(d)
|
|
4721
|
+
|
|
4722
|
+
// No return-name→caller-name rename step: identity under shorthand.
|
|
4723
|
+
inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, null)
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4726
|
+
/**
|
|
4727
|
+
* Shared inlining tail for both return shapes: suffix-rename internal
|
|
4728
|
+
* bindings not in `excludeFromSuffixRename`, splice argument expressions
|
|
4729
|
+
* in for parameters, optionally rename return identifiers to caller
|
|
4730
|
+
* names (tuple path only — see C4 for why the object path passes null),
|
|
4731
|
+
* and push the resulting edit for this call site.
|
|
4732
|
+
*/
|
|
4733
|
+
function inlineFactoryCallAtSite(
|
|
4734
|
+
stmt: ts.VariableStatement,
|
|
4735
|
+
factory: ReactiveFactoryInfo,
|
|
4736
|
+
args: ts.NodeArray<ts.Expression>,
|
|
4737
|
+
excludeFromSuffixRename: Set<string>,
|
|
4738
|
+
renameReturnToCallerNames: Map<string, string> | null
|
|
4739
|
+
): void {
|
|
4740
|
+
const argTexts = args.map(a => a.getText(sourceFile))
|
|
4073
4741
|
const thisCallIndex = callSiteIndex++
|
|
4074
4742
|
const suffix = `_bf${thisCallIndex}`
|
|
4075
4743
|
|
|
4076
4744
|
// Apply renames to the factory body source.
|
|
4077
4745
|
let body = factory.bodySource
|
|
4078
|
-
// 1. Suffix-rename internal bindings
|
|
4079
|
-
// + caller names to avoid collisions).
|
|
4746
|
+
// 1. Suffix-rename internal bindings.
|
|
4080
4747
|
const internalRenames = new Set<string>(factory.localBindings)
|
|
4081
|
-
for (const
|
|
4082
|
-
for (const r of factory.returnTupleIdentifiers) internalRenames.delete(r)
|
|
4748
|
+
for (const ex of excludeFromSuffixRename) internalRenames.delete(ex)
|
|
4083
4749
|
for (const name of internalRenames) {
|
|
4084
4750
|
body = body.replace(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), name + suffix)
|
|
4085
4751
|
}
|
|
@@ -4094,11 +4760,11 @@ function rewriteFactoryCallsInSource(
|
|
|
4094
4760
|
const wrapped = atomicArg.test(a.trim()) ? a.trim() : `(${a})`
|
|
4095
4761
|
body = body.replace(new RegExp(`\\b${escapeRegex(p)}\\b`, 'g'), wrapped)
|
|
4096
4762
|
}
|
|
4097
|
-
// 3. Return
|
|
4098
|
-
|
|
4099
|
-
const n
|
|
4100
|
-
|
|
4101
|
-
|
|
4763
|
+
// 3. Return identifiers → caller destructure names (tuple path only).
|
|
4764
|
+
if (renameReturnToCallerNames) {
|
|
4765
|
+
for (const [n, caller] of renameReturnToCallerNames) {
|
|
4766
|
+
body = body.replace(new RegExp(`\\b${escapeRegex(n)}\\b`, 'g'), caller)
|
|
4767
|
+
}
|
|
4102
4768
|
}
|
|
4103
4769
|
|
|
4104
4770
|
edits.push({
|
|
@@ -4106,13 +4772,51 @@ function rewriteFactoryCallsInSource(
|
|
|
4106
4772
|
end: stmt.getEnd(),
|
|
4107
4773
|
replacement: body,
|
|
4108
4774
|
})
|
|
4775
|
+
inlinedFactories.add(factory)
|
|
4109
4776
|
}
|
|
4110
4777
|
|
|
4111
4778
|
visitStmt(sourceFile, false)
|
|
4112
4779
|
|
|
4113
4780
|
if (edits.length === 0) return source
|
|
4114
4781
|
|
|
4115
|
-
//
|
|
4782
|
+
// #2332 — one deduped import statement per specifier for every inlined
|
|
4783
|
+
// cross-file factory's re-provisioned imports. Injected as a zero-width
|
|
4784
|
+
// edit so the ordinary bottom-to-top splice below applies it; the result
|
|
4785
|
+
// is indistinguishable from a hand-written import for every downstream
|
|
4786
|
+
// consumer (ctx.imports → SSR templateImports AND client
|
|
4787
|
+
// collectExternalImports both parse this same rewritten string).
|
|
4788
|
+
const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
|
|
4789
|
+
for (const f of inlinedFactories) {
|
|
4790
|
+
for (const r of f.requiredImports ?? []) {
|
|
4791
|
+
let names = importsBySpecifier.get(r.specifier)
|
|
4792
|
+
if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
|
|
4793
|
+
names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
if (importsBySpecifier.size > 0) {
|
|
4797
|
+
// Sort specifiers and, within each, named-import entries by local name —
|
|
4798
|
+
// `importsBySpecifier`/`inlinedFactories` iterate in incidental AST-
|
|
4799
|
+
// traversal/insertion order, which would otherwise make this generated
|
|
4800
|
+
// text order-unstable across unrelated refactors (Copilot review, PR
|
|
4801
|
+
// #2338).
|
|
4802
|
+
const lines = [...importsBySpecifier]
|
|
4803
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
4804
|
+
.map(([spec, names]) => {
|
|
4805
|
+
const specifiers = [...names]
|
|
4806
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
4807
|
+
.map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
|
|
4808
|
+
return `import { ${specifiers.join(', ')} } from '${spec}'`
|
|
4809
|
+
})
|
|
4810
|
+
const at = factoryImportInsertionOffset(sourceFile)
|
|
4811
|
+
edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
// Apply edits from bottom to top so earlier offsets stay valid. A factory
|
|
4815
|
+
// with `requiredImports` is by definition imported, so the entry file has
|
|
4816
|
+
// at least one import statement and `at` above lands strictly inside the
|
|
4817
|
+
// import prologue — every call-site edit's start is strictly greater
|
|
4818
|
+
// (separated at minimum by the statement break after the last import), so
|
|
4819
|
+
// starts never tie and no sort tiebreak is needed.
|
|
4116
4820
|
edits.sort((a, b) => b.start - a.start)
|
|
4117
4821
|
let out = source
|
|
4118
4822
|
for (const e of edits) {
|
|
@@ -4121,6 +4825,24 @@ function rewriteFactoryCallsInSource(
|
|
|
4121
4825
|
return out
|
|
4122
4826
|
}
|
|
4123
4827
|
|
|
4828
|
+
/**
|
|
4829
|
+
* Offset just after the last top-level import (else after the 'use client'
|
|
4830
|
+
* directive, else 0) in the prescan source file — where re-provisioned
|
|
4831
|
+
* factory imports are injected (#2332).
|
|
4832
|
+
*/
|
|
4833
|
+
function factoryImportInsertionOffset(sf: ts.SourceFile): number {
|
|
4834
|
+
let lastImportEnd = -1
|
|
4835
|
+
let directiveEnd = -1
|
|
4836
|
+
for (const stmt of sf.statements) {
|
|
4837
|
+
if (ts.isImportDeclaration(stmt)) { lastImportEnd = stmt.getEnd(); continue }
|
|
4838
|
+
if (directiveEnd === -1 && ts.isExpressionStatement(stmt) &&
|
|
4839
|
+
ts.isStringLiteral(stmt.expression) && stmt.expression.text === 'use client') {
|
|
4840
|
+
directiveEnd = stmt.getEnd()
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
return lastImportEnd >= 0 ? lastImportEnd : directiveEnd >= 0 ? directiveEnd : 0
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4124
4846
|
function isPascalCaseComponentFn(node: ts.Node): boolean {
|
|
4125
4847
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
4126
4848
|
return /^[A-Z]/.test(node.name.text)
|
|
@@ -4140,10 +4862,54 @@ function escapeRegex(s: string): string {
|
|
|
4140
4862
|
// =============================================================================
|
|
4141
4863
|
|
|
4142
4864
|
/**
|
|
4143
|
-
*
|
|
4144
|
-
*
|
|
4145
|
-
*
|
|
4146
|
-
*
|
|
4865
|
+
* Build the diagnostic for a call site whose callee was recognised but
|
|
4866
|
+
* declined for inlining (#2325 — cross-file factories that rename their
|
|
4867
|
+
* return properties or capture their own module scope; #2332 — a
|
|
4868
|
+
* re-provisioned helper import collides with an existing binding). BF112
|
|
4869
|
+
* and BF113's wording is specific to their respective failure; every other
|
|
4870
|
+
* declined reason (currently only BF111 — non-shorthand return properties)
|
|
4871
|
+
* shares BF111's generic "cannot be inlined: <detail>" phrasing, matching
|
|
4872
|
+
* the wording used for the tuple call-site path.
|
|
4873
|
+
*/
|
|
4874
|
+
function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
|
|
4875
|
+
if (d.code === 'BF112') {
|
|
4876
|
+
return (
|
|
4877
|
+
`Reactive factory '${callee}' references ${d.detail} from its own module ` +
|
|
4878
|
+
`scope and cannot be inlined. Move the referenced helper(s) into this file, ` +
|
|
4879
|
+
`pass them as factory arguments, or inline the factory here.`
|
|
4880
|
+
)
|
|
4881
|
+
}
|
|
4882
|
+
if (d.code === 'BF113') {
|
|
4883
|
+
return (
|
|
4884
|
+
`Reactive factory '${callee}' cannot be inlined: it needs ${d.detail} ` +
|
|
4885
|
+
`imported into this file, but that name is already bound here to something ` +
|
|
4886
|
+
`else. Rename the conflicting binding in this file, or alias the import in ` +
|
|
4887
|
+
`the factory's own file (import { x as y }).`
|
|
4888
|
+
)
|
|
4889
|
+
}
|
|
4890
|
+
return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
/** Diagnostic code for a declined reactive-factory call site (#2325 / #2332). */
|
|
4894
|
+
function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorCode {
|
|
4895
|
+
switch (code) {
|
|
4896
|
+
case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4897
|
+
case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
|
|
4898
|
+
default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
/**
|
|
4903
|
+
* Scan a compiled component context for destructures (tuple or object)
|
|
4904
|
+
* whose callee is neither `createSignal` / `createMemo` nor an inlinable
|
|
4905
|
+
* reactive factory. These are the silent-failure shapes that produced
|
|
4906
|
+
* broken client JS prior to factory inlining — emit BF110 (unrecognised
|
|
4907
|
+
* shape), BF111 (unsupported rename), or BF112 (module-scope capture) so
|
|
4908
|
+
* users get a clear message instead.
|
|
4909
|
+
*
|
|
4910
|
+
* Only walks top-level component-body statements, matching the scope the
|
|
4911
|
+
* inliner itself operates on (#931) — a factory call inside a nested block
|
|
4912
|
+
* is out of scope for both inlining and this diagnostic.
|
|
4147
4913
|
*/
|
|
4148
4914
|
export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
4149
4915
|
if (!ctx.componentNode) return
|
|
@@ -4155,44 +4921,197 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
|
4155
4921
|
for (const stmt of body.statements) {
|
|
4156
4922
|
if (!ts.isVariableStatement(stmt)) continue
|
|
4157
4923
|
for (const decl of stmt.declarationList.declarations) {
|
|
4158
|
-
if (!ts.isArrayBindingPattern(decl.name)) continue
|
|
4159
4924
|
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue
|
|
4160
4925
|
if (!ts.isIdentifier(decl.initializer.expression)) continue
|
|
4161
4926
|
const callee = decl.initializer.expression.text
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4927
|
+
const loc = getSourceLocation(stmt, ctx.sourceFile, ctx.filePath)
|
|
4928
|
+
|
|
4929
|
+
if (ts.isArrayBindingPattern(decl.name)) {
|
|
4930
|
+
if (callee === 'createSignal' || callee === 'createMemo') continue
|
|
4931
|
+
// Env-signal factories (`createSearchParams`, #2057) are `createSignal`-
|
|
4932
|
+
// shaped and recognised structurally — a valid tuple destructure. Resolve
|
|
4933
|
+
// via the same path as recognition (`resolveEnvSignalKey`) so an aliased
|
|
4934
|
+
// import (`import { createSearchParams as csp }`) is accepted here too,
|
|
4935
|
+
// rather than falling through to a spurious BF110.
|
|
4936
|
+
if (resolveEnvSignalKey(decl.initializer, ctx)) continue
|
|
4937
|
+
|
|
4938
|
+
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
4939
|
+
if (declinedEntry) {
|
|
4940
|
+
ctx.errors.push(createError(
|
|
4941
|
+
declinedFactoryErrorCode(declinedEntry.code),
|
|
4942
|
+
loc,
|
|
4943
|
+
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
4944
|
+
))
|
|
4945
|
+
continue
|
|
4946
|
+
}
|
|
4947
|
+
|
|
4948
|
+
const objectFactory = ctx.reactiveFactories.get(callee)
|
|
4949
|
+
if (objectFactory && objectFactory.returnKind === 'object') {
|
|
4950
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4178
4951
|
severity: 'error',
|
|
4179
4952
|
message:
|
|
4180
|
-
`
|
|
4181
|
-
`
|
|
4182
|
-
|
|
4183
|
-
|
|
4953
|
+
`'${callee}' is a reactive factory that returns an object — destructure ` +
|
|
4954
|
+
`it with a matching object pattern: const { ${objectFactory.returnTupleIdentifiers.join(', ')} } = ${callee}(...)`,
|
|
4955
|
+
}))
|
|
4956
|
+
continue
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4959
|
+
// Inlined factories were rewritten away before this analysis, so
|
|
4960
|
+
// anything still matching the shape is a destructure of an
|
|
4961
|
+
// unrecognised callee (imported helper, ad-hoc tuple fn, factory
|
|
4962
|
+
// with arity mismatch).
|
|
4963
|
+
ctx.errors.push(
|
|
4964
|
+
createError(
|
|
4965
|
+
ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY,
|
|
4966
|
+
loc,
|
|
4967
|
+
{
|
|
4968
|
+
severity: 'error',
|
|
4184
4969
|
message:
|
|
4185
|
-
`
|
|
4186
|
-
`
|
|
4187
|
-
`
|
|
4188
|
-
|
|
4189
|
-
|
|
4970
|
+
`Tuple destructuring of '${callee}(...)': this helper is not a ` +
|
|
4971
|
+
`recognised reactive factory (createSignal / createMemo / a ` +
|
|
4972
|
+
`same-file helper that wraps them with a single \`return [a, b, ...]\`).`,
|
|
4973
|
+
suggestion: {
|
|
4974
|
+
message:
|
|
4975
|
+
`Inline the createSignal call at the call site, or move the ` +
|
|
4976
|
+
`helper into this file as a function that returns a tuple of ` +
|
|
4977
|
+
`identifiers at its single exit point.`,
|
|
4978
|
+
},
|
|
4979
|
+
}
|
|
4980
|
+
)
|
|
4190
4981
|
)
|
|
4191
|
-
|
|
4982
|
+
continue
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
if (ts.isObjectBindingPattern(decl.name)) {
|
|
4986
|
+
validateObjectFactoryDestructure(ctx, decl.name, callee, loc)
|
|
4987
|
+
}
|
|
4192
4988
|
}
|
|
4193
4989
|
}
|
|
4194
4990
|
}
|
|
4195
4991
|
|
|
4992
|
+
/**
|
|
4993
|
+
* Object-destructure half of `validateReactiveFactoryCalls` (#2325). Split
|
|
4994
|
+
* out so the tuple path above stays a straight read of the pre-#2325 logic
|
|
4995
|
+
* (the tuple diagnostics are pinned by pre-existing tests) while this path
|
|
4996
|
+
* covers the previously-silent object-destructure failure modes: an
|
|
4997
|
+
* unrecognised callee, a tuple factory destructured as an object, a rename/
|
|
4998
|
+
* default/rest destructure of a shorthand-only factory, an unknown
|
|
4999
|
+
* property, a declined (BF111/BF112/BF113) factory, or an uninspectable
|
|
5000
|
+
* import that looks reactive-factory-shaped by name.
|
|
5001
|
+
*/
|
|
5002
|
+
function validateObjectFactoryDestructure(
|
|
5003
|
+
ctx: AnalyzerContext,
|
|
5004
|
+
pattern: ts.ObjectBindingPattern,
|
|
5005
|
+
callee: string,
|
|
5006
|
+
loc: SourceLocation
|
|
5007
|
+
): void {
|
|
5008
|
+
const factory = ctx.reactiveFactories.get(callee)
|
|
5009
|
+
if (factory) {
|
|
5010
|
+
// Return-shape mismatch takes priority over element-form validation: a
|
|
5011
|
+
// tuple-return factory destructured as an object is *never* valid,
|
|
5012
|
+
// regardless of whether the object pattern happens to use shorthand or
|
|
5013
|
+
// a rename/default/rest element — always point the caller at positional
|
|
5014
|
+
// destructuring (BF110) instead of the shorthand-only guidance below
|
|
5015
|
+
// (BF111), which only makes sense for genuinely object-return factories.
|
|
5016
|
+
if (factory.returnKind === 'tuple') {
|
|
5017
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
5018
|
+
severity: 'error',
|
|
5019
|
+
message:
|
|
5020
|
+
`'${callee}' is a reactive factory that returns a tuple — destructure ` +
|
|
5021
|
+
`it positionally: const [${factory.returnTupleIdentifiers.join(', ')}] = ${callee}(...)`,
|
|
5022
|
+
}))
|
|
5023
|
+
return
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
const hasUnsupportedElement = pattern.elements.some(
|
|
5027
|
+
el => !!el.propertyName || !!el.initializer || !!el.dotDotDotToken || !ts.isIdentifier(el.name)
|
|
5028
|
+
)
|
|
5029
|
+
if (hasUnsupportedElement) {
|
|
5030
|
+
ctx.errors.push(createError(ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED, loc, {
|
|
5031
|
+
severity: 'error',
|
|
5032
|
+
message:
|
|
5033
|
+
`Object destructure of reactive factory '${callee}' uses a property ` +
|
|
5034
|
+
`rename, default, or rest element; only shorthand destructuring of ` +
|
|
5035
|
+
`{ ${factory.returnTupleIdentifiers.join(', ')} } is supported.`,
|
|
5036
|
+
}))
|
|
5037
|
+
return
|
|
5038
|
+
}
|
|
5039
|
+
|
|
5040
|
+
const unknown = pattern.elements
|
|
5041
|
+
.map(el => (ts.isIdentifier(el.name) ? el.name.text : ''))
|
|
5042
|
+
.filter(name => name && !factory.returnTupleIdentifiers.includes(name))
|
|
5043
|
+
if (unknown.length > 0) {
|
|
5044
|
+
const label = unknown.length === 1 ? 'property' : 'properties'
|
|
5045
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
5046
|
+
severity: 'error',
|
|
5047
|
+
message:
|
|
5048
|
+
`Object destructure of reactive factory '${callee}' references ${label} ` +
|
|
5049
|
+
`'${unknown.join("', '")}' not present in its return { ${factory.returnTupleIdentifiers.join(', ')} }.`,
|
|
5050
|
+
}))
|
|
5051
|
+
return
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
// Shorthand object pattern that matches the factory's return shape —
|
|
5055
|
+
// already inlined; nothing to report.
|
|
5056
|
+
return
|
|
5057
|
+
}
|
|
5058
|
+
|
|
5059
|
+
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
5060
|
+
if (declinedEntry) {
|
|
5061
|
+
ctx.errors.push(createError(
|
|
5062
|
+
declinedFactoryErrorCode(declinedEntry.code),
|
|
5063
|
+
loc,
|
|
5064
|
+
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
5065
|
+
))
|
|
5066
|
+
return
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
if (ctx.reactiveShapedHelpers.has(callee)) {
|
|
5070
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
5071
|
+
severity: 'error',
|
|
5072
|
+
message:
|
|
5073
|
+
`Object destructure of '${callee}(...)': this helper wraps a reactive ` +
|
|
5074
|
+
`primitive but does not match the inlinable factory shape (single ` +
|
|
5075
|
+
'`return { a, b }` of shorthand identifiers at its one exit point).',
|
|
5076
|
+
}))
|
|
5077
|
+
return
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
// Proven non-reactive import (helper file resolved, read, and found to
|
|
5081
|
+
// export nothing reactive-shaped under this name) — silent, correctly
|
|
5082
|
+
// (C2: an ordinary object destructure must not become a false positive).
|
|
5083
|
+
if (ctx.cleanFactoryImports.has(callee)) return
|
|
5084
|
+
|
|
5085
|
+
// Last resort: an import the compiler cannot inspect (non-relative or
|
|
5086
|
+
// unresolvable path) whose name looks like a hook/factory. Name-based
|
|
5087
|
+
// heuristic only — false negatives here fall through to silence, which
|
|
5088
|
+
// matches this file's ordinary-object-destructure default (C2).
|
|
5089
|
+
let matchedImportSource: string | null = null
|
|
5090
|
+
for (const imp of ctx.imports) {
|
|
5091
|
+
if (imp.isTypeOnly) continue
|
|
5092
|
+
const spec = imp.specifiers.find(s => !s.isTypeOnly && (s.alias ?? s.name) === callee)
|
|
5093
|
+
if (spec) {
|
|
5094
|
+
matchedImportSource = imp.source
|
|
5095
|
+
break
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
if (
|
|
5099
|
+
matchedImportSource !== null &&
|
|
5100
|
+
!matchedImportSource.startsWith('@barefootjs/') &&
|
|
5101
|
+
/^(use|create)[A-Z]/.test(callee)
|
|
5102
|
+
) {
|
|
5103
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
5104
|
+
severity: 'error',
|
|
5105
|
+
message:
|
|
5106
|
+
`Object destructure of imported '${callee}(...)': the compiler cannot ` +
|
|
5107
|
+
`inspect this import (non-relative or unresolvable path), so if it wraps ` +
|
|
5108
|
+
`createSignal/createMemo the destructured bindings will not be reactive. Move ` +
|
|
5109
|
+
`the helper to a relative-imported file or inline its body.`,
|
|
5110
|
+
}))
|
|
5111
|
+
}
|
|
5112
|
+
// Otherwise: ordinary object destructure of unrelated code — leave untouched (C2).
|
|
5113
|
+
}
|
|
5114
|
+
|
|
4196
5115
|
// =============================================================================
|
|
4197
5116
|
// Export
|
|
4198
5117
|
// =============================================================================
|