@barefootjs/jsx 0.21.3 → 0.23.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/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 +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +27 -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 +706 -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 +72 -0
- package/dist/to-locale-date-lowering.d.ts.map +1 -0
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +109 -0
- package/src/__tests__/reactive-factory-cross-file.test.ts +239 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
- package/src/__tests__/to-locale-date-lowering.test.ts +181 -0
- package/src/adapters/env-signal.ts +26 -3
- package/src/analyzer-context.ts +19 -4
- package/src/analyzer.ts +712 -91
- package/src/builtin-lowering-plugins.ts +8 -1
- package/src/date-lowering.ts +1 -1
- package/src/errors.ts +13 -0
- package/src/format-date-lowering.ts +51 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/emit-reactive.ts +80 -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 +79 -1
- package/src/rich-type-refusal.ts +9 -1
- package/src/to-locale-date-lowering.ts +175 -0
- package/src/types.ts +24 -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, 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'
|
|
@@ -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,373 @@ 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
|
|
3904
3946
|
}
|
|
3905
3947
|
|
|
3948
|
+
/**
|
|
3949
|
+
* Cross-file half of the factory prescan (#2325 round 2): resolve factories
|
|
3950
|
+
* defined in a relative-imported helper file so `const { count } =
|
|
3951
|
+
* createCounter(0)` inlines the same way whether `createCounter` lives in
|
|
3952
|
+
* this file or in `./hooks`. Mutates `result`'s maps in place.
|
|
3953
|
+
*
|
|
3954
|
+
* Perf: gated on a candidate-callee set collected from the ALREADY-parsed
|
|
3955
|
+
* entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
|
|
3956
|
+
* imports with regex" rule) — files with no tuple/object-destructured call
|
|
3957
|
+
* at all skip every filesystem access below. A second cheap gate (does the
|
|
3958
|
+
* helper file's raw text contain any `REACTIVE_PRIMITIVES` substring) skips
|
|
3959
|
+
* the AST parse of helper files that plainly aren't reactive; this is a
|
|
3960
|
+
* skip-gate over content, not an import parse, so it doesn't run afoul of
|
|
3961
|
+
* that same rule.
|
|
3962
|
+
*
|
|
3963
|
+
* Name-collision precedence: a same-file factory/declined/reactive-shaped
|
|
3964
|
+
* entry always wins over a same-named cross-file import — every write
|
|
3965
|
+
* below is guarded on the name being unclaimed in all three buckets.
|
|
3966
|
+
*/
|
|
3967
|
+
function prescanImportedReactiveFactories(
|
|
3968
|
+
entrySourceFile: ts.SourceFile,
|
|
3969
|
+
filePath: string,
|
|
3970
|
+
result: PrescanResult
|
|
3971
|
+
): void {
|
|
3972
|
+
// 1. Candidate gate: names destructured (tuple or object) from a direct
|
|
3973
|
+
// call-expression initializer, anywhere in the file. Cheap AST walk,
|
|
3974
|
+
// no filesystem access.
|
|
3975
|
+
const candidateCallees = new Set<string>()
|
|
3976
|
+
function collectCandidates(node: ts.Node): void {
|
|
3977
|
+
if (
|
|
3978
|
+
ts.isVariableDeclaration(node) &&
|
|
3979
|
+
(ts.isArrayBindingPattern(node.name) || ts.isObjectBindingPattern(node.name)) &&
|
|
3980
|
+
node.initializer &&
|
|
3981
|
+
ts.isCallExpression(node.initializer) &&
|
|
3982
|
+
ts.isIdentifier(node.initializer.expression)
|
|
3983
|
+
) {
|
|
3984
|
+
candidateCallees.add(node.initializer.expression.text)
|
|
3985
|
+
}
|
|
3986
|
+
ts.forEachChild(node, collectCandidates)
|
|
3987
|
+
}
|
|
3988
|
+
collectCandidates(entrySourceFile)
|
|
3989
|
+
if (candidateCallees.size === 0) return
|
|
3990
|
+
|
|
3991
|
+
// 2. Relative imports whose named specifiers overlap the candidate set.
|
|
3992
|
+
interface CandidateSpec {
|
|
3993
|
+
/** Name exported from the helper file. */
|
|
3994
|
+
exported: string
|
|
3995
|
+
/** Local (call-site) binding name — the key every result map uses. */
|
|
3996
|
+
local: string
|
|
3997
|
+
}
|
|
3998
|
+
const importsToCheck: { src: string; specs: CandidateSpec[] }[] = []
|
|
3999
|
+
for (const stmt of entrySourceFile.statements) {
|
|
4000
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
4001
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
4002
|
+
const src = stmt.moduleSpecifier.text
|
|
4003
|
+
// Mirrors `scanImportedClientSignals`'s restriction to relative
|
|
4004
|
+
// specifiers — non-relative (bare/aliased) imports resolve through
|
|
4005
|
+
// bundler / tsconfig-paths configuration this layer doesn't consume.
|
|
4006
|
+
if (!src.startsWith('./') && !src.startsWith('../')) continue
|
|
4007
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
4008
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
4009
|
+
if (!namedBindings || !ts.isNamedImports(namedBindings)) continue
|
|
4010
|
+
|
|
4011
|
+
const specs: CandidateSpec[] = []
|
|
4012
|
+
for (const el of namedBindings.elements) {
|
|
4013
|
+
if (el.isTypeOnly) continue
|
|
4014
|
+
const local = el.name.text
|
|
4015
|
+
if (!candidateCallees.has(local)) continue
|
|
4016
|
+
specs.push({ exported: (el.propertyName ?? el.name).text, local })
|
|
4017
|
+
}
|
|
4018
|
+
if (specs.length === 0) continue
|
|
4019
|
+
importsToCheck.push({ src, specs })
|
|
4020
|
+
}
|
|
4021
|
+
if (importsToCheck.length === 0) return
|
|
4022
|
+
|
|
4023
|
+
for (const { src, specs } of importsToCheck) {
|
|
4024
|
+
const resolved = resolveRelativeImportToFile(src, filePath)
|
|
4025
|
+
// Unresolvable — left alone here; the name-heuristic BF110 branch in
|
|
4026
|
+
// validateReactiveFactoryCalls handles it at validation time.
|
|
4027
|
+
if (!resolved) continue
|
|
4028
|
+
|
|
4029
|
+
let content: string
|
|
4030
|
+
try {
|
|
4031
|
+
content = fs.readFileSync(resolved, 'utf8')
|
|
4032
|
+
} catch {
|
|
4033
|
+
continue
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
const alreadyKnown = (name: string): boolean =>
|
|
4037
|
+
result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
|
|
4038
|
+
|
|
4039
|
+
// Cheap text-level skip-gate (not an import/JS parse — see docstring):
|
|
4040
|
+
// a helper file with no reactive-primitive substring anywhere cannot
|
|
4041
|
+
// define a reactive factory, so skip parsing it entirely.
|
|
4042
|
+
const hasAnyPrimitiveText = [...REACTIVE_PRIMITIVES].some(p => content.includes(p))
|
|
4043
|
+
if (!hasAnyPrimitiveText) {
|
|
4044
|
+
for (const spec of specs) {
|
|
4045
|
+
if (!alreadyKnown(spec.local)) result.cleanFactoryImports.add(spec.local)
|
|
4046
|
+
}
|
|
4047
|
+
continue
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
const helperSf = ts.createSourceFile(
|
|
4051
|
+
resolved + '.prescan',
|
|
4052
|
+
content,
|
|
4053
|
+
ts.ScriptTarget.Latest,
|
|
4054
|
+
true,
|
|
4055
|
+
ts.ScriptKind.TSX
|
|
4056
|
+
)
|
|
4057
|
+
|
|
4058
|
+
// Map exported name -> module-scope FunctionDeclaration via the AST.
|
|
4059
|
+
const localFns = new Map<string, ts.FunctionDeclaration>()
|
|
4060
|
+
const exportedFns = new Map<string, ts.FunctionDeclaration>()
|
|
4061
|
+
for (const stmt of helperSf.statements) {
|
|
4062
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
|
|
4063
|
+
localFns.set(stmt.name.text, stmt)
|
|
4064
|
+
const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
4065
|
+
const hasDefaultModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false
|
|
4066
|
+
if (hasExportModifier && !hasDefaultModifier) {
|
|
4067
|
+
exportedFns.set(stmt.name.text, stmt)
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
// `export { f }` / `export { f as g }` — keyed by the EXTERNAL export name.
|
|
4072
|
+
for (const stmt of helperSf.statements) {
|
|
4073
|
+
if (
|
|
4074
|
+
ts.isExportDeclaration(stmt) &&
|
|
4075
|
+
stmt.exportClause &&
|
|
4076
|
+
ts.isNamedExports(stmt.exportClause) &&
|
|
4077
|
+
!stmt.moduleSpecifier &&
|
|
4078
|
+
!stmt.isTypeOnly
|
|
4079
|
+
) {
|
|
4080
|
+
for (const el of stmt.exportClause.elements) {
|
|
4081
|
+
if (el.isTypeOnly) continue
|
|
4082
|
+
const fn = localFns.get((el.propertyName ?? el.name).text)
|
|
4083
|
+
if (fn) exportedFns.set(el.name.text, fn)
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
// Module-scope bindings the helper file declares — an inlined factory
|
|
4089
|
+
// body must not reference any of these (#2325 §4h / BF112), since
|
|
4090
|
+
// inlining moves the body into the component file where they don't
|
|
4091
|
+
// exist.
|
|
4092
|
+
const moduleBindings = collectHelperModuleValueBindings(helperSf)
|
|
4093
|
+
|
|
4094
|
+
for (const spec of specs) {
|
|
4095
|
+
if (alreadyKnown(spec.local)) continue
|
|
4096
|
+
|
|
4097
|
+
const fn = exportedFns.get(spec.exported)
|
|
4098
|
+
if (!fn) {
|
|
4099
|
+
result.cleanFactoryImports.add(spec.local)
|
|
4100
|
+
continue
|
|
4101
|
+
}
|
|
4102
|
+
const det = detectReactiveFactory(fn, helperSf, resolved)
|
|
4103
|
+
if (!det) {
|
|
4104
|
+
result.cleanFactoryImports.add(spec.local)
|
|
4105
|
+
continue
|
|
4106
|
+
}
|
|
4107
|
+
switch (det.kind) {
|
|
4108
|
+
case 'reactive-shaped':
|
|
4109
|
+
result.reactiveShaped.add(spec.local)
|
|
4110
|
+
break
|
|
4111
|
+
case 'declined':
|
|
4112
|
+
result.declined.set(spec.local, det.declined)
|
|
4113
|
+
break
|
|
4114
|
+
case 'factory': {
|
|
4115
|
+
const offending = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
|
|
4116
|
+
if (offending.length > 0) {
|
|
4117
|
+
result.declined.set(spec.local, {
|
|
4118
|
+
code: 'BF112',
|
|
4119
|
+
detail: `'${offending.join("', '")}'`,
|
|
4120
|
+
loc: det.info.loc,
|
|
4121
|
+
})
|
|
4122
|
+
} else {
|
|
4123
|
+
det.info.sourceFilePath = resolved
|
|
4124
|
+
result.factories.set(spec.local, det.info)
|
|
4125
|
+
}
|
|
4126
|
+
break
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
/**
|
|
4134
|
+
* Value bindings at the helper file's module scope that an inlined factory
|
|
4135
|
+
* body must not capture (#2325 §4h / BF112): top-level const/let/var,
|
|
4136
|
+
* function/class/enum names, and value import specifiers — EXCEPT imports
|
|
4137
|
+
* from '@barefootjs/client' / '@barefootjs/client/runtime'. Those are
|
|
4138
|
+
* re-provisioned from usage by the client-JS emitter regardless of where
|
|
4139
|
+
* the call that used them textually came from (`resolveFinalImports` /
|
|
4140
|
+
* `detectUsedImports` regex-scan the *generated* code, not the consumer's
|
|
4141
|
+
* source imports — see #2325 spec C1), so an inlined body calling
|
|
4142
|
+
* `createSignal` is never a capture even though the helper file itself
|
|
4143
|
+
* imports it. Type-only imports/declarations carry no runtime binding and
|
|
4144
|
+
* are excluded.
|
|
4145
|
+
*/
|
|
4146
|
+
function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
|
|
4147
|
+
const names = new Set<string>()
|
|
4148
|
+
for (const stmt of sf.statements) {
|
|
4149
|
+
if (ts.isVariableStatement(stmt)) {
|
|
4150
|
+
const out: string[] = []
|
|
4151
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
4152
|
+
addBindingNames(decl.name, out)
|
|
4153
|
+
}
|
|
4154
|
+
for (const n of out) names.add(n)
|
|
4155
|
+
continue
|
|
4156
|
+
}
|
|
4157
|
+
if (
|
|
4158
|
+
(ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
|
|
4159
|
+
stmt.name
|
|
4160
|
+
) {
|
|
4161
|
+
names.add(stmt.name.text)
|
|
4162
|
+
continue
|
|
4163
|
+
}
|
|
4164
|
+
if (ts.isImportDeclaration(stmt)) {
|
|
4165
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
4166
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
4167
|
+
const src = stmt.moduleSpecifier.text
|
|
4168
|
+
if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
|
|
4169
|
+
if (stmt.importClause?.name) names.add(stmt.importClause.name.text)
|
|
4170
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
4171
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
4172
|
+
for (const el of namedBindings.elements) {
|
|
4173
|
+
if (el.isTypeOnly) continue
|
|
4174
|
+
names.add(el.name.text)
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
4178
|
+
names.add(namedBindings.name.text)
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return names
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
/**
|
|
4186
|
+
* Free identifiers of a reactive-factory body that resolve to bindings at
|
|
4187
|
+
* its own module scope (#2325 §4h / BF112) — references the inlined body
|
|
4188
|
+
* would silently lose once spliced into the component file. Returns the
|
|
4189
|
+
* offending names sorted, for stable diagnostic text.
|
|
4190
|
+
*
|
|
4191
|
+
* Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
|
|
4192
|
+
* tracks arrow-function parameters, not nested `function` declarations'
|
|
4193
|
+
* parameters or nested-block declarations — a body-nested binding that
|
|
4194
|
+
* happens to shadow a helper-module binding could false-positive into
|
|
4195
|
+
* BF112. Acceptable: the failure direction is a loud build error on a rare
|
|
4196
|
+
* shape, never a silent runtime break.
|
|
4197
|
+
*/
|
|
4198
|
+
function moduleCaptureCheck(
|
|
4199
|
+
fn: ts.FunctionDeclaration,
|
|
4200
|
+
info: ReactiveFactoryInfo,
|
|
4201
|
+
moduleBindings: Set<string>,
|
|
4202
|
+
selfName: string
|
|
4203
|
+
): string[] {
|
|
4204
|
+
if (!fn.body) return []
|
|
4205
|
+
const free = extractFreeIdentifiersFromNode(fn.body)
|
|
4206
|
+
const exclude = new Set<string>(info.params)
|
|
4207
|
+
for (const b of info.localBindings) exclude.add(b)
|
|
4208
|
+
for (const r of info.returnTupleIdentifiers) exclude.add(r)
|
|
4209
|
+
for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
|
|
4210
|
+
exclude.add(selfName)
|
|
4211
|
+
|
|
4212
|
+
const offending: string[] = []
|
|
4213
|
+
for (const id of free) {
|
|
4214
|
+
if (exclude.has(id)) continue
|
|
4215
|
+
if (moduleBindings.has(id)) offending.push(id)
|
|
4216
|
+
}
|
|
4217
|
+
return offending.sort()
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
/**
|
|
4221
|
+
* Classification result for a module-level function that might be a
|
|
4222
|
+
* reactive-factory helper (#2325). `null` means the function has no
|
|
4223
|
+
* reactive-primitive call anywhere in its body, so it isn't reactive-
|
|
4224
|
+
* related at all — every other case implies at least one such call.
|
|
4225
|
+
*/
|
|
4226
|
+
type FactoryDetection =
|
|
4227
|
+
| { kind: 'factory'; info: ReactiveFactoryInfo }
|
|
4228
|
+
| { kind: 'declined'; declined: DeclinedReactiveFactory }
|
|
4229
|
+
| { kind: 'reactive-shaped' } // wraps a reactive primitive but shape unsupported
|
|
4230
|
+
| null
|
|
4231
|
+
|
|
3906
4232
|
function detectReactiveFactory(
|
|
3907
4233
|
node: ts.FunctionDeclaration,
|
|
3908
4234
|
sourceFile: ts.SourceFile,
|
|
3909
4235
|
filePath: string
|
|
3910
|
-
):
|
|
4236
|
+
): FactoryDetection {
|
|
3911
4237
|
if (!node.body || !node.name) return null
|
|
3912
4238
|
|
|
3913
|
-
//
|
|
3914
|
-
//
|
|
3915
|
-
|
|
4239
|
+
// Body must contain at least one reactive primitive call so that the
|
|
4240
|
+
// factory is actually the right thing to inline (not just a helper that
|
|
4241
|
+
// happens to look similar). Checked FIRST: a function with zero reactive
|
|
4242
|
+
// calls is not reactive-related in any way, so it is out of scope for
|
|
4243
|
+
// every diagnostic below, not just "not a factory".
|
|
4244
|
+
let hasReactiveCall = false
|
|
4245
|
+
function checkForReactive(n: ts.Node): void {
|
|
4246
|
+
if (hasReactiveCall) return
|
|
4247
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) &&
|
|
4248
|
+
REACTIVE_PRIMITIVES.has(n.expression.text)) {
|
|
4249
|
+
hasReactiveCall = true
|
|
4250
|
+
return
|
|
4251
|
+
}
|
|
4252
|
+
ts.forEachChild(n, checkForReactive)
|
|
4253
|
+
}
|
|
4254
|
+
checkForReactive(node.body)
|
|
4255
|
+
if (!hasReactiveCall) return null
|
|
4256
|
+
|
|
4257
|
+
const loc = getSourceLocation(node, sourceFile, filePath)
|
|
4258
|
+
|
|
4259
|
+
// Require exactly one top-level `return`, whose argument (after unwrapping
|
|
4260
|
+
// parens / `as const` / type-assertion) is a tuple (array literal) or a
|
|
4261
|
+
// shorthand-object literal.
|
|
4262
|
+
let returnExpr: ts.Expression | null = null
|
|
3916
4263
|
let returnCount = 0
|
|
3917
4264
|
for (const stmt of node.body.statements) {
|
|
3918
4265
|
if (!ts.isReturnStatement(stmt)) continue
|
|
3919
4266
|
returnCount++
|
|
3920
|
-
if (!stmt.expression) return
|
|
4267
|
+
if (!stmt.expression) return { kind: 'reactive-shaped' }
|
|
3921
4268
|
let expr: ts.Expression = stmt.expression
|
|
3922
4269
|
while (ts.isParenthesizedExpression(expr)) expr = expr.expression
|
|
3923
4270
|
// Accept `... as const` / `<const>...`
|
|
3924
4271
|
if (ts.isAsExpression(expr)) expr = expr.expression
|
|
3925
4272
|
if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
|
|
3926
|
-
|
|
3927
|
-
tupleReturn = expr
|
|
4273
|
+
returnExpr = expr
|
|
3928
4274
|
}
|
|
3929
|
-
if (returnCount !== 1 || !
|
|
4275
|
+
if (returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
|
|
3930
4276
|
|
|
3931
|
-
// Every element must be a plain identifier (no spreads, computed,
|
|
3932
|
-
// call expressions). Otherwise the caller-side rename would not be sound.
|
|
3933
4277
|
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
|
|
4278
|
+
let returnKind: 'tuple' | 'object'
|
|
4279
|
+
|
|
4280
|
+
if (ts.isArrayLiteralExpression(returnExpr)) {
|
|
4281
|
+
returnKind = 'tuple'
|
|
4282
|
+
// Every element must be a plain identifier (no spreads, computed,
|
|
4283
|
+
// call expressions). Otherwise the caller-side rename would not be sound.
|
|
4284
|
+
for (const el of returnExpr.elements) {
|
|
4285
|
+
if (!ts.isIdentifier(el)) return { kind: 'reactive-shaped' }
|
|
4286
|
+
returnTupleIdentifiers.push(el.text)
|
|
3950
4287
|
}
|
|
3951
|
-
|
|
4288
|
+
if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
|
|
4289
|
+
} else if (ts.isObjectLiteralExpression(returnExpr)) {
|
|
4290
|
+
returnKind = 'object'
|
|
4291
|
+
const hasNonShorthand = returnExpr.properties.some(p => !ts.isShorthandPropertyAssignment(p))
|
|
4292
|
+
if (hasNonShorthand) {
|
|
4293
|
+
return {
|
|
4294
|
+
kind: 'declined',
|
|
4295
|
+
declined: {
|
|
4296
|
+
code: 'BF111',
|
|
4297
|
+
detail: `return object of '${node.name.text}' uses non-shorthand properties`,
|
|
4298
|
+
loc,
|
|
4299
|
+
},
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
for (const p of returnExpr.properties) {
|
|
4303
|
+
// Every property already proven ts.isShorthandPropertyAssignment above.
|
|
4304
|
+
returnTupleIdentifiers.push((p as ts.ShorthandPropertyAssignment).name.text)
|
|
4305
|
+
}
|
|
4306
|
+
if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
|
|
4307
|
+
} else {
|
|
4308
|
+
return { kind: 'reactive-shaped' }
|
|
3952
4309
|
}
|
|
3953
|
-
checkForReactive(node.body)
|
|
3954
|
-
if (!hasReactiveCall) return null
|
|
3955
4310
|
|
|
3956
4311
|
// Collect local bindings in the factory body for identifier hygiene at
|
|
3957
4312
|
// inlining time. Only direct-child declarations of the block are
|
|
@@ -3968,26 +4323,35 @@ function detectReactiveFactory(
|
|
|
3968
4323
|
}
|
|
3969
4324
|
|
|
3970
4325
|
// Serialize the body without the outer braces and without the return
|
|
3971
|
-
// statement — the return tuple is dissolved into caller-named
|
|
4326
|
+
// statement — the return tuple/object is dissolved into caller-named
|
|
4327
|
+
// identifiers.
|
|
3972
4328
|
const bodyStatements = node.body.statements
|
|
3973
4329
|
.filter(s => !ts.isReturnStatement(s))
|
|
3974
4330
|
.map(s => s.getText(sourceFile))
|
|
3975
4331
|
.join('\n')
|
|
3976
4332
|
|
|
3977
|
-
const params =
|
|
3978
|
-
|
|
4333
|
+
const params: string[] = []
|
|
4334
|
+
for (const p of node.parameters) {
|
|
4335
|
+
if (ts.isIdentifier(p.name)) {
|
|
4336
|
+
params.push(p.name.text)
|
|
4337
|
+
continue
|
|
4338
|
+
}
|
|
3979
4339
|
// Destructured params are uncommon for this helper shape and out of
|
|
3980
|
-
// initial scope;
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
4340
|
+
// initial scope; the factory still wraps a reactive primitive, so
|
|
4341
|
+
// classify it as reactive-shaped rather than silently ignoring it.
|
|
4342
|
+
return { kind: 'reactive-shaped' }
|
|
4343
|
+
}
|
|
3984
4344
|
|
|
3985
4345
|
return {
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
4346
|
+
kind: 'factory',
|
|
4347
|
+
info: {
|
|
4348
|
+
params,
|
|
4349
|
+
bodySource: bodyStatements,
|
|
4350
|
+
returnTupleIdentifiers,
|
|
4351
|
+
returnKind,
|
|
4352
|
+
localBindings,
|
|
4353
|
+
loc,
|
|
4354
|
+
},
|
|
3991
4355
|
}
|
|
3992
4356
|
}
|
|
3993
4357
|
|
|
@@ -4048,16 +4412,33 @@ function rewriteFactoryCallsInSource(
|
|
|
4048
4412
|
}
|
|
4049
4413
|
|
|
4050
4414
|
function maybeRewriteDecl(stmt: ts.VariableStatement, decl: ts.VariableDeclaration): void {
|
|
4051
|
-
if (!ts.isArrayBindingPattern(decl.name)) return
|
|
4052
4415
|
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return
|
|
4053
4416
|
if (!ts.isIdentifier(decl.initializer.expression)) return
|
|
4054
4417
|
const factoryName = decl.initializer.expression.text
|
|
4055
4418
|
const factory = factories.get(factoryName)
|
|
4056
4419
|
if (!factory) return
|
|
4057
4420
|
|
|
4421
|
+
if (ts.isArrayBindingPattern(decl.name)) {
|
|
4422
|
+
if (factory.returnKind !== 'tuple') return
|
|
4423
|
+
rewriteTupleDecl(stmt, decl.name, decl.initializer, factory)
|
|
4424
|
+
return
|
|
4425
|
+
}
|
|
4426
|
+
if (ts.isObjectBindingPattern(decl.name)) {
|
|
4427
|
+
if (factory.returnKind !== 'object') return
|
|
4428
|
+
rewriteObjectDecl(stmt, decl.name, decl.initializer, factory)
|
|
4429
|
+
return
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function rewriteTupleDecl(
|
|
4434
|
+
stmt: ts.VariableStatement,
|
|
4435
|
+
pattern: ts.ArrayBindingPattern,
|
|
4436
|
+
call: ts.CallExpression,
|
|
4437
|
+
factory: ReactiveFactoryInfo
|
|
4438
|
+
): void {
|
|
4058
4439
|
// Arity check — bail out on mismatch so the analyzer can report BF110
|
|
4059
4440
|
// on the untouched source.
|
|
4060
|
-
const elements =
|
|
4441
|
+
const elements = pattern.elements
|
|
4061
4442
|
if (elements.length !== factory.returnTupleIdentifiers.length) return
|
|
4062
4443
|
|
|
4063
4444
|
// Caller-side identifier names (one per tuple slot). Omitted slots
|
|
@@ -4069,17 +4450,74 @@ function rewriteFactoryCallsInSource(
|
|
|
4069
4450
|
callerNames.push(el.name.text)
|
|
4070
4451
|
}
|
|
4071
4452
|
|
|
4072
|
-
|
|
4453
|
+
// Exclude params + every return-tuple identifier from suffix-renaming
|
|
4454
|
+
// (they're renamed to caller names below instead).
|
|
4455
|
+
const excludeFromSuffixRename = new Set<string>(factory.params)
|
|
4456
|
+
for (const r of factory.returnTupleIdentifiers) excludeFromSuffixRename.add(r)
|
|
4457
|
+
|
|
4458
|
+
const renameReturnToCallerNames = new Map<string, string>()
|
|
4459
|
+
for (let i = 0; i < factory.returnTupleIdentifiers.length; i++) {
|
|
4460
|
+
renameReturnToCallerNames.set(factory.returnTupleIdentifiers[i], callerNames[i])
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, renameReturnToCallerNames)
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
function rewriteObjectDecl(
|
|
4467
|
+
stmt: ts.VariableStatement,
|
|
4468
|
+
pattern: ts.ObjectBindingPattern,
|
|
4469
|
+
call: ts.CallExpression,
|
|
4470
|
+
factory: ReactiveFactoryInfo
|
|
4471
|
+
): void {
|
|
4472
|
+
// Shorthand destructuring only (no renames/defaults/rest) of names the
|
|
4473
|
+
// factory actually returns. Anything else bails so the analyzer can
|
|
4474
|
+
// report BF110/BF111 on the untouched source. Subset destructures are
|
|
4475
|
+
// allowed — a caller may destructure fewer than all returned names.
|
|
4476
|
+
const destructured = new Set<string>()
|
|
4477
|
+
for (const el of pattern.elements) {
|
|
4478
|
+
if (el.dotDotDotToken) return
|
|
4479
|
+
if (el.propertyName) return
|
|
4480
|
+
if (el.initializer) return
|
|
4481
|
+
if (!ts.isIdentifier(el.name)) return
|
|
4482
|
+
if (!factory.returnTupleIdentifiers.includes(el.name.text)) return
|
|
4483
|
+
destructured.add(el.name.text)
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
// Exclude params + the destructured names from suffix-renaming (caller
|
|
4487
|
+
// name already equals the property name under shorthand, C4). Returned
|
|
4488
|
+
// names the caller did NOT destructure are ordinary internal locals and
|
|
4489
|
+
// DO get suffix-renamed — otherwise two subset calls of the same
|
|
4490
|
+
// factory collide on the undestructured name.
|
|
4491
|
+
const excludeFromSuffixRename = new Set<string>(factory.params)
|
|
4492
|
+
for (const d of destructured) excludeFromSuffixRename.add(d)
|
|
4493
|
+
|
|
4494
|
+
// No return-name→caller-name rename step: identity under shorthand.
|
|
4495
|
+
inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, null)
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
/**
|
|
4499
|
+
* Shared inlining tail for both return shapes: suffix-rename internal
|
|
4500
|
+
* bindings not in `excludeFromSuffixRename`, splice argument expressions
|
|
4501
|
+
* in for parameters, optionally rename return identifiers to caller
|
|
4502
|
+
* names (tuple path only — see C4 for why the object path passes null),
|
|
4503
|
+
* and push the resulting edit for this call site.
|
|
4504
|
+
*/
|
|
4505
|
+
function inlineFactoryCallAtSite(
|
|
4506
|
+
stmt: ts.VariableStatement,
|
|
4507
|
+
factory: ReactiveFactoryInfo,
|
|
4508
|
+
args: ts.NodeArray<ts.Expression>,
|
|
4509
|
+
excludeFromSuffixRename: Set<string>,
|
|
4510
|
+
renameReturnToCallerNames: Map<string, string> | null
|
|
4511
|
+
): void {
|
|
4512
|
+
const argTexts = args.map(a => a.getText(sourceFile))
|
|
4073
4513
|
const thisCallIndex = callSiteIndex++
|
|
4074
4514
|
const suffix = `_bf${thisCallIndex}`
|
|
4075
4515
|
|
|
4076
4516
|
// Apply renames to the factory body source.
|
|
4077
4517
|
let body = factory.bodySource
|
|
4078
|
-
// 1. Suffix-rename internal bindings
|
|
4079
|
-
// + caller names to avoid collisions).
|
|
4518
|
+
// 1. Suffix-rename internal bindings.
|
|
4080
4519
|
const internalRenames = new Set<string>(factory.localBindings)
|
|
4081
|
-
for (const
|
|
4082
|
-
for (const r of factory.returnTupleIdentifiers) internalRenames.delete(r)
|
|
4520
|
+
for (const ex of excludeFromSuffixRename) internalRenames.delete(ex)
|
|
4083
4521
|
for (const name of internalRenames) {
|
|
4084
4522
|
body = body.replace(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), name + suffix)
|
|
4085
4523
|
}
|
|
@@ -4094,11 +4532,11 @@ function rewriteFactoryCallsInSource(
|
|
|
4094
4532
|
const wrapped = atomicArg.test(a.trim()) ? a.trim() : `(${a})`
|
|
4095
4533
|
body = body.replace(new RegExp(`\\b${escapeRegex(p)}\\b`, 'g'), wrapped)
|
|
4096
4534
|
}
|
|
4097
|
-
// 3. Return
|
|
4098
|
-
|
|
4099
|
-
const n
|
|
4100
|
-
|
|
4101
|
-
|
|
4535
|
+
// 3. Return identifiers → caller destructure names (tuple path only).
|
|
4536
|
+
if (renameReturnToCallerNames) {
|
|
4537
|
+
for (const [n, caller] of renameReturnToCallerNames) {
|
|
4538
|
+
body = body.replace(new RegExp(`\\b${escapeRegex(n)}\\b`, 'g'), caller)
|
|
4539
|
+
}
|
|
4102
4540
|
}
|
|
4103
4541
|
|
|
4104
4542
|
edits.push({
|
|
@@ -4140,10 +4578,36 @@ function escapeRegex(s: string): string {
|
|
|
4140
4578
|
// =============================================================================
|
|
4141
4579
|
|
|
4142
4580
|
/**
|
|
4143
|
-
*
|
|
4144
|
-
*
|
|
4145
|
-
*
|
|
4146
|
-
* to
|
|
4581
|
+
* Build the diagnostic for a call site whose callee was recognised but
|
|
4582
|
+
* declined for inlining (#2325 — cross-file factories that rename their
|
|
4583
|
+
* return properties, or capture their own module scope). BF112's wording is
|
|
4584
|
+
* specific to module-scope capture; every other declined reason (currently
|
|
4585
|
+
* only BF111 — non-shorthand return properties) shares BF111's generic
|
|
4586
|
+
* "cannot be inlined: <detail>" phrasing, matching the wording used for the
|
|
4587
|
+
* tuple call-site path.
|
|
4588
|
+
*/
|
|
4589
|
+
function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
|
|
4590
|
+
if (d.code === 'BF112') {
|
|
4591
|
+
return (
|
|
4592
|
+
`Reactive factory '${callee}' references ${d.detail} from its own module ` +
|
|
4593
|
+
`scope and cannot be inlined. Move the referenced helper(s) into this file, ` +
|
|
4594
|
+
`pass them as factory arguments, or inline the factory here.`
|
|
4595
|
+
)
|
|
4596
|
+
}
|
|
4597
|
+
return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
/**
|
|
4601
|
+
* Scan a compiled component context for destructures (tuple or object)
|
|
4602
|
+
* whose callee is neither `createSignal` / `createMemo` nor an inlinable
|
|
4603
|
+
* reactive factory. These are the silent-failure shapes that produced
|
|
4604
|
+
* broken client JS prior to factory inlining — emit BF110 (unrecognised
|
|
4605
|
+
* shape), BF111 (unsupported rename), or BF112 (module-scope capture) so
|
|
4606
|
+
* users get a clear message instead.
|
|
4607
|
+
*
|
|
4608
|
+
* Only walks top-level component-body statements, matching the scope the
|
|
4609
|
+
* inliner itself operates on (#931) — a factory call inside a nested block
|
|
4610
|
+
* is out of scope for both inlining and this diagnostic.
|
|
4147
4611
|
*/
|
|
4148
4612
|
export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
4149
4613
|
if (!ctx.componentNode) return
|
|
@@ -4155,42 +4619,199 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
|
4155
4619
|
for (const stmt of body.statements) {
|
|
4156
4620
|
if (!ts.isVariableStatement(stmt)) continue
|
|
4157
4621
|
for (const decl of stmt.declarationList.declarations) {
|
|
4158
|
-
if (!ts.isArrayBindingPattern(decl.name)) continue
|
|
4159
4622
|
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue
|
|
4160
4623
|
if (!ts.isIdentifier(decl.initializer.expression)) continue
|
|
4161
4624
|
const callee = decl.initializer.expression.text
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4625
|
+
const loc = getSourceLocation(stmt, ctx.sourceFile, ctx.filePath)
|
|
4626
|
+
|
|
4627
|
+
if (ts.isArrayBindingPattern(decl.name)) {
|
|
4628
|
+
if (callee === 'createSignal' || callee === 'createMemo') continue
|
|
4629
|
+
// Env-signal factories (`createSearchParams`, #2057) are `createSignal`-
|
|
4630
|
+
// shaped and recognised structurally — a valid tuple destructure. Resolve
|
|
4631
|
+
// via the same path as recognition (`resolveEnvSignalKey`) so an aliased
|
|
4632
|
+
// import (`import { createSearchParams as csp }`) is accepted here too,
|
|
4633
|
+
// rather than falling through to a spurious BF110.
|
|
4634
|
+
if (resolveEnvSignalKey(decl.initializer, ctx)) continue
|
|
4635
|
+
|
|
4636
|
+
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
4637
|
+
if (declinedEntry) {
|
|
4638
|
+
ctx.errors.push(createError(
|
|
4639
|
+
declinedEntry.code === 'BF112'
|
|
4640
|
+
? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4641
|
+
: ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
|
|
4642
|
+
loc,
|
|
4643
|
+
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
4644
|
+
))
|
|
4645
|
+
continue
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
const objectFactory = ctx.reactiveFactories.get(callee)
|
|
4649
|
+
if (objectFactory && objectFactory.returnKind === 'object') {
|
|
4650
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4178
4651
|
severity: 'error',
|
|
4179
4652
|
message:
|
|
4180
|
-
`
|
|
4181
|
-
`
|
|
4182
|
-
|
|
4183
|
-
|
|
4653
|
+
`'${callee}' is a reactive factory that returns an object — destructure ` +
|
|
4654
|
+
`it with a matching object pattern: const { ${objectFactory.returnTupleIdentifiers.join(', ')} } = ${callee}(...)`,
|
|
4655
|
+
}))
|
|
4656
|
+
continue
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4659
|
+
// Inlined factories were rewritten away before this analysis, so
|
|
4660
|
+
// anything still matching the shape is a destructure of an
|
|
4661
|
+
// unrecognised callee (imported helper, ad-hoc tuple fn, factory
|
|
4662
|
+
// with arity mismatch).
|
|
4663
|
+
ctx.errors.push(
|
|
4664
|
+
createError(
|
|
4665
|
+
ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY,
|
|
4666
|
+
loc,
|
|
4667
|
+
{
|
|
4668
|
+
severity: 'error',
|
|
4184
4669
|
message:
|
|
4185
|
-
`
|
|
4186
|
-
`
|
|
4187
|
-
`
|
|
4188
|
-
|
|
4189
|
-
|
|
4670
|
+
`Tuple destructuring of '${callee}(...)': this helper is not a ` +
|
|
4671
|
+
`recognised reactive factory (createSignal / createMemo / a ` +
|
|
4672
|
+
`same-file helper that wraps them with a single \`return [a, b, ...]\`).`,
|
|
4673
|
+
suggestion: {
|
|
4674
|
+
message:
|
|
4675
|
+
`Inline the createSignal call at the call site, or move the ` +
|
|
4676
|
+
`helper into this file as a function that returns a tuple of ` +
|
|
4677
|
+
`identifiers at its single exit point.`,
|
|
4678
|
+
},
|
|
4679
|
+
}
|
|
4680
|
+
)
|
|
4190
4681
|
)
|
|
4191
|
-
|
|
4682
|
+
continue
|
|
4683
|
+
}
|
|
4684
|
+
|
|
4685
|
+
if (ts.isObjectBindingPattern(decl.name)) {
|
|
4686
|
+
validateObjectFactoryDestructure(ctx, decl.name, callee, loc)
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4692
|
+
/**
|
|
4693
|
+
* Object-destructure half of `validateReactiveFactoryCalls` (#2325). Split
|
|
4694
|
+
* out so the tuple path above stays a straight read of the pre-#2325 logic
|
|
4695
|
+
* (the tuple diagnostics are pinned by pre-existing tests) while this path
|
|
4696
|
+
* covers the previously-silent object-destructure failure modes: an
|
|
4697
|
+
* unrecognised callee, a tuple factory destructured as an object, a rename/
|
|
4698
|
+
* default/rest destructure of a shorthand-only factory, an unknown
|
|
4699
|
+
* property, a declined (BF111/BF112) factory, or an uninspectable import
|
|
4700
|
+
* that looks reactive-factory-shaped by name.
|
|
4701
|
+
*/
|
|
4702
|
+
function validateObjectFactoryDestructure(
|
|
4703
|
+
ctx: AnalyzerContext,
|
|
4704
|
+
pattern: ts.ObjectBindingPattern,
|
|
4705
|
+
callee: string,
|
|
4706
|
+
loc: SourceLocation
|
|
4707
|
+
): void {
|
|
4708
|
+
const factory = ctx.reactiveFactories.get(callee)
|
|
4709
|
+
if (factory) {
|
|
4710
|
+
// Return-shape mismatch takes priority over element-form validation: a
|
|
4711
|
+
// tuple-return factory destructured as an object is *never* valid,
|
|
4712
|
+
// regardless of whether the object pattern happens to use shorthand or
|
|
4713
|
+
// a rename/default/rest element — always point the caller at positional
|
|
4714
|
+
// destructuring (BF110) instead of the shorthand-only guidance below
|
|
4715
|
+
// (BF111), which only makes sense for genuinely object-return factories.
|
|
4716
|
+
if (factory.returnKind === 'tuple') {
|
|
4717
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4718
|
+
severity: 'error',
|
|
4719
|
+
message:
|
|
4720
|
+
`'${callee}' is a reactive factory that returns a tuple — destructure ` +
|
|
4721
|
+
`it positionally: const [${factory.returnTupleIdentifiers.join(', ')}] = ${callee}(...)`,
|
|
4722
|
+
}))
|
|
4723
|
+
return
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4726
|
+
const hasUnsupportedElement = pattern.elements.some(
|
|
4727
|
+
el => !!el.propertyName || !!el.initializer || !!el.dotDotDotToken || !ts.isIdentifier(el.name)
|
|
4728
|
+
)
|
|
4729
|
+
if (hasUnsupportedElement) {
|
|
4730
|
+
ctx.errors.push(createError(ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED, loc, {
|
|
4731
|
+
severity: 'error',
|
|
4732
|
+
message:
|
|
4733
|
+
`Object destructure of reactive factory '${callee}' uses a property ` +
|
|
4734
|
+
`rename, default, or rest element; only shorthand destructuring of ` +
|
|
4735
|
+
`{ ${factory.returnTupleIdentifiers.join(', ')} } is supported.`,
|
|
4736
|
+
}))
|
|
4737
|
+
return
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
const unknown = pattern.elements
|
|
4741
|
+
.map(el => (ts.isIdentifier(el.name) ? el.name.text : ''))
|
|
4742
|
+
.filter(name => name && !factory.returnTupleIdentifiers.includes(name))
|
|
4743
|
+
if (unknown.length > 0) {
|
|
4744
|
+
const label = unknown.length === 1 ? 'property' : 'properties'
|
|
4745
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4746
|
+
severity: 'error',
|
|
4747
|
+
message:
|
|
4748
|
+
`Object destructure of reactive factory '${callee}' references ${label} ` +
|
|
4749
|
+
`'${unknown.join("', '")}' not present in its return { ${factory.returnTupleIdentifiers.join(', ')} }.`,
|
|
4750
|
+
}))
|
|
4751
|
+
return
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4754
|
+
// Shorthand object pattern that matches the factory's return shape —
|
|
4755
|
+
// already inlined; nothing to report.
|
|
4756
|
+
return
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
4760
|
+
if (declinedEntry) {
|
|
4761
|
+
ctx.errors.push(createError(
|
|
4762
|
+
declinedEntry.code === 'BF112'
|
|
4763
|
+
? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4764
|
+
: ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
|
|
4765
|
+
loc,
|
|
4766
|
+
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
4767
|
+
))
|
|
4768
|
+
return
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
if (ctx.reactiveShapedHelpers.has(callee)) {
|
|
4772
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4773
|
+
severity: 'error',
|
|
4774
|
+
message:
|
|
4775
|
+
`Object destructure of '${callee}(...)': this helper wraps a reactive ` +
|
|
4776
|
+
`primitive but does not match the inlinable factory shape (single ` +
|
|
4777
|
+
'`return { a, b }` of shorthand identifiers at its one exit point).',
|
|
4778
|
+
}))
|
|
4779
|
+
return
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4782
|
+
// Proven non-reactive import (helper file resolved, read, and found to
|
|
4783
|
+
// export nothing reactive-shaped under this name) — silent, correctly
|
|
4784
|
+
// (C2: an ordinary object destructure must not become a false positive).
|
|
4785
|
+
if (ctx.cleanFactoryImports.has(callee)) return
|
|
4786
|
+
|
|
4787
|
+
// Last resort: an import the compiler cannot inspect (non-relative or
|
|
4788
|
+
// unresolvable path) whose name looks like a hook/factory. Name-based
|
|
4789
|
+
// heuristic only — false negatives here fall through to silence, which
|
|
4790
|
+
// matches this file's ordinary-object-destructure default (C2).
|
|
4791
|
+
let matchedImportSource: string | null = null
|
|
4792
|
+
for (const imp of ctx.imports) {
|
|
4793
|
+
if (imp.isTypeOnly) continue
|
|
4794
|
+
const spec = imp.specifiers.find(s => !s.isTypeOnly && (s.alias ?? s.name) === callee)
|
|
4795
|
+
if (spec) {
|
|
4796
|
+
matchedImportSource = imp.source
|
|
4797
|
+
break
|
|
4192
4798
|
}
|
|
4193
4799
|
}
|
|
4800
|
+
if (
|
|
4801
|
+
matchedImportSource !== null &&
|
|
4802
|
+
!matchedImportSource.startsWith('@barefootjs/') &&
|
|
4803
|
+
/^(use|create)[A-Z]/.test(callee)
|
|
4804
|
+
) {
|
|
4805
|
+
ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
|
|
4806
|
+
severity: 'error',
|
|
4807
|
+
message:
|
|
4808
|
+
`Object destructure of imported '${callee}(...)': the compiler cannot ` +
|
|
4809
|
+
`inspect this import (non-relative or unresolvable path), so if it wraps ` +
|
|
4810
|
+
`createSignal/createMemo the destructured bindings will not be reactive. Move ` +
|
|
4811
|
+
`the helper to a relative-imported file or inline its body.`,
|
|
4812
|
+
}))
|
|
4813
|
+
}
|
|
4814
|
+
// Otherwise: ordinary object destructure of unrelated code — leave untouched (C2).
|
|
4194
4815
|
}
|
|
4195
4816
|
|
|
4196
4817
|
// =============================================================================
|