@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.
Files changed (46) hide show
  1. package/dist/adapters/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +3 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +30 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1124 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +111 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +47 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +125 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +1012 -93
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +19 -0
  38. package/src/format-date-lowering.ts +55 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +90 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +90 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +563 -0
  46. 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, ...]` exit + at least
3876
- * one reactive primitive call in the body). Returns a map from factory
3877
- * name to its metadata, and the parsed source file for call-site rewriting.
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 info = detectReactiveFactory(node, sourceFile, filePath)
3895
- if (info) factories.set(node.name.text, info)
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
- return { factories, sourceFile }
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
- ): ReactiveFactoryInfo | null {
4458
+ ): FactoryDetection {
3911
4459
  if (!node.body || !node.name) return null
3912
4460
 
3913
- // Require: exactly one top-level `return` whose argument is an array
3914
- // literal of plain identifiers (optionally wrapped in `as const` / parens).
3915
- let tupleReturn: ts.ArrayLiteralExpression | null = null
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 null
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
- if (!ts.isArrayLiteralExpression(expr)) return null
3927
- tupleReturn = expr
4495
+ returnExpr = expr
3928
4496
  }
3929
- if (returnCount !== 1 || !tupleReturn) return null
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
- for (const el of tupleReturn.elements) {
3935
- if (!ts.isIdentifier(el)) return null
3936
- returnTupleIdentifiers.push(el.text)
3937
- }
3938
- if (returnTupleIdentifiers.length === 0) return null
3939
-
3940
- // Body must contain at least one reactive primitive call so that the
3941
- // factory is actually the right thing to inline (not just a tuple-returning
3942
- // helper that happens to look similar).
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
- ts.forEachChild(n, checkForReactive)
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 identifiers.
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 = node.parameters.map(p => {
3978
- if (ts.isIdentifier(p.name)) return p.name.text
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; return a placeholder so we can skip the factory.
3981
- return ''
3982
- })
3983
- if (params.some(p => p === '')) return null
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
- params,
3987
- bodySource: bodyStatements,
3988
- returnTupleIdentifiers,
3989
- localBindings,
3990
- loc: getSourceLocation(node, sourceFile, filePath),
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 = decl.name.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
- const argTexts = decl.initializer.arguments.map(a => a.getText(sourceFile))
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 (exclude params + return tuple
4079
- // + caller names to avoid collisions).
4746
+ // 1. Suffix-rename internal bindings.
4080
4747
  const internalRenames = new Set<string>(factory.localBindings)
4081
- for (const p of factory.params) internalRenames.delete(p)
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 tuple identifiers → caller destructure names.
4098
- for (let i = 0; i < factory.returnTupleIdentifiers.length; i++) {
4099
- const n = factory.returnTupleIdentifiers[i]
4100
- const caller = callerNames[i]
4101
- body = body.replace(new RegExp(`\\b${escapeRegex(n)}\\b`, 'g'), caller)
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
- // Apply edits from bottom to top so earlier offsets stay valid.
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
- * Scan a compiled component context for tuple-destructures whose callee is
4144
- * neither `createSignal` / `createMemo` nor an inlinable reactive factory.
4145
- * These are the silent-failure shapes that produced broken client JS prior
4146
- * to factory inlining emit BF110 so users get a clear message instead.
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
- if (callee === 'createSignal' || callee === 'createMemo') continue
4163
- // Env-signal factories (`createSearchParams`, #2057) are `createSignal`-
4164
- // shaped and recognised structurally — a valid tuple destructure. Resolve
4165
- // via the same path as recognition (`resolveEnvSignalKey`) so an aliased
4166
- // import (`import { createSearchParams as csp }`) is accepted here too,
4167
- // rather than falling through to a spurious BF110.
4168
- if (resolveEnvSignalKey(decl.initializer, ctx)) continue
4169
- // Inlined factories were rewritten away before this analysis, so
4170
- // anything still matching the shape is a destructure of an
4171
- // unrecognised callee (imported helper, ad-hoc tuple fn, factory
4172
- // with arity mismatch).
4173
- ctx.errors.push(
4174
- createError(
4175
- ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY,
4176
- getSourceLocation(stmt, ctx.sourceFile, ctx.filePath),
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
- `Tuple destructuring of '${callee}(...)': this helper is not a ` +
4181
- `recognised reactive factory (createSignal / createMemo / a ` +
4182
- `same-file helper that wraps them with a single \`return [a, b, ...]\`).`,
4183
- suggestion: {
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
- `Inline the createSignal call at the call site, or move the ` +
4186
- `helper into this file as a function that returns a tuple of ` +
4187
- `identifiers at its single exit point.`,
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
  // =============================================================================