@barefootjs/jsx 0.23.0 → 0.25.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/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, DeclinedReactiveFactory, SourceLocation } from './types.ts'
10
+ import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, FactoryRenameSite, 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'
@@ -3945,18 +3945,158 @@ function prescanReactiveFactoriesInSource(
3945
3945
  return result
3946
3946
  }
3947
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
4048
+ }
4049
+
4050
+ // One `export ... from` hop is followed when resolving a re-exported name
4051
+ // (#2341 BUG-2) — a visited-set guards against cycles regardless, so
4052
+ // raising this later is safe without further changes.
4053
+ const MAX_REEXPORT_HOPS = 1
4054
+
4055
+ /** A helper file's export surface, cached per absolute path for the
4056
+ * lifetime of one `prescanImportedReactiveFactories` call (#2341 BUG-2). */
4057
+ interface HelperFileInfo {
4058
+ sf: ts.SourceFile
4059
+ /** Module-scope function declarations exported under their external name
4060
+ * (own `export function`, or a local `export { f as g }`). */
4061
+ exportedFns: Map<string, ts.FunctionDeclaration>
4062
+ /** `export { a as b } from 'src'` re-exports, keyed by the EXTERNAL name
4063
+ * `b` — a barrel file's whole reason for existing (#2341 BUG-2). */
4064
+ reexports: Map<string, { source: string; innerName: string }>
4065
+ /** Any `export * from '...'` in this file — a named lookup that misses
4066
+ * `exportedFns`/`reexports` might still resolve through one of these, so
4067
+ * it can never be classified `'clean'`. (`export * as ns from` is a
4068
+ * `NamespaceExport` clause and is excluded: a named lookup can never
4069
+ * come through it.) */
4070
+ hasStarReexport: boolean
4071
+ moduleBindings: HelperModuleBindings
4072
+ }
4073
+ /** `'clean'` = read, parsed (or gate-skipped), and proven to define no
4074
+ * reactive factory under any name reachable from it. `null` = unreadable. */
4075
+ type LoadedHelper = HelperFileInfo | 'clean' | null
4076
+ type ExportLookup =
4077
+ | { kind: 'fn'; fn: ts.FunctionDeclaration; file: HelperFileInfo; definingPath: string }
4078
+ | { kind: 'clean' } // proven non-reactive under this name → cleanFactoryImports
4079
+ | { kind: 'unknown' } // cannot prove → leave unclassified so the BF110 name heuristic still fires
4080
+
3948
4081
  /**
3949
4082
  * Cross-file half of the factory prescan (#2325 round 2): resolve factories
3950
4083
  * defined in a relative-imported helper file so `const { count } =
3951
4084
  * createCounter(0)` inlines the same way whether `createCounter` lives in
3952
- * this file or in `./hooks`. Mutates `result`'s maps in place.
4085
+ * this file or in `./hooks`. Follows one `export ... from` hop so a barrel
4086
+ * `index.ts` re-exporting the real helper resolves to the file that
4087
+ * actually DEFINES it (#2341 BUG-2) — every classification (factory /
4088
+ * declined / reactive-shaped / clean) and every downstream anchor (module-
4089
+ * capture check, helper-import re-provisioning, `sourceFilePath`) is keyed
4090
+ * off that defining file, never the barrel. Mutates `result`'s maps in
4091
+ * place.
3953
4092
  *
3954
4093
  * Perf: gated on a candidate-callee set collected from the ALREADY-parsed
3955
4094
  * entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
3956
4095
  * imports with regex" rule) — files with no tuple/object-destructured call
3957
4096
  * 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
4097
+ * helper file's raw text contain any `REACTIVE_PRIMITIVES` substring, or
4098
+ * any `export ... from` re-export text) skips the AST parse of helper files
4099
+ * that plainly define no factory and re-export nothing; this is a
3960
4100
  * skip-gate over content, not an import parse, so it doesn't run afoul of
3961
4101
  * that same rule.
3962
4102
  *
@@ -4020,45 +4160,49 @@ function prescanImportedReactiveFactories(
4020
4160
  }
4021
4161
  if (importsToCheck.length === 0) return
4022
4162
 
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
4163
+ // #2332 computed once per entry file, shared across all helper files.
4164
+ const entryBindingNames = collectEntryBindingNames(entrySourceFile)
4165
+ const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
4166
+ // localName -> planned injection identity; a later factory requiring the
4167
+ // same name from a DIFFERENT (targetKey, exportedName) is a collision.
4168
+ const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
4169
+
4170
+ // #2341 BUG-2 — one read+parse per helper file per entry file, memoized
4171
+ // across every spec/hop that touches it (a barrel is typically visited
4172
+ // once per re-exported name it satisfies).
4173
+ const helperCache = new Map<string, LoadedHelper>()
4174
+
4175
+ function loadHelperFile(abs: string): LoadedHelper {
4176
+ const cached = helperCache.get(abs)
4177
+ if (cached !== undefined) return cached
4028
4178
 
4029
4179
  let content: string
4030
4180
  try {
4031
- content = fs.readFileSync(resolved, 'utf8')
4181
+ content = fs.readFileSync(abs, 'utf8')
4032
4182
  } catch {
4033
- continue
4183
+ helperCache.set(abs, null)
4184
+ return null
4034
4185
  }
4035
4186
 
4036
- const alreadyKnown = (name: string): boolean =>
4037
- result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4038
-
4039
4187
  // 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.
4188
+ // a file with no reactive-primitive substring AND no re-export gate
4189
+ // text (`export` ... `from`) can neither define a reactive factory nor
4190
+ // re-export one, so skip parsing it entirely. Substring checks only —
4191
+ // false positives merely cause an AST parse whose outcome is still
4192
+ // correct, they never cause a false 'clean'.
4042
4193
  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
4194
+ const hasReexportText = content.includes('export') && content.includes('from')
4195
+ if (!hasAnyPrimitiveText && !hasReexportText) {
4196
+ helperCache.set(abs, 'clean')
4197
+ return 'clean'
4048
4198
  }
4049
4199
 
4050
- const helperSf = ts.createSourceFile(
4051
- resolved + '.prescan',
4052
- content,
4053
- ts.ScriptTarget.Latest,
4054
- true,
4055
- ts.ScriptKind.TSX
4056
- )
4200
+ const sf = ts.createSourceFile(abs + '.prescan', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
4057
4201
 
4058
4202
  // Map exported name -> module-scope FunctionDeclaration via the AST.
4059
4203
  const localFns = new Map<string, ts.FunctionDeclaration>()
4060
4204
  const exportedFns = new Map<string, ts.FunctionDeclaration>()
4061
- for (const stmt of helperSf.statements) {
4205
+ for (const stmt of sf.statements) {
4062
4206
  if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
4063
4207
  localFns.set(stmt.name.text, stmt)
4064
4208
  const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
@@ -4068,20 +4212,37 @@ function prescanImportedReactiveFactories(
4068
4212
  }
4069
4213
  }
4070
4214
  }
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
- ) {
4215
+
4216
+ const reexports = new Map<string, { source: string; innerName: string }>()
4217
+ let hasStarReexport = false
4218
+ for (const stmt of sf.statements) {
4219
+ if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly) continue
4220
+ if (!stmt.moduleSpecifier) {
4221
+ // `export { f }` / `export { f as g }` — keyed by the EXTERNAL export name.
4222
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
4223
+ for (const el of stmt.exportClause.elements) {
4224
+ if (el.isTypeOnly) continue
4225
+ const fn = localFns.get((el.propertyName ?? el.name).text)
4226
+ if (fn) exportedFns.set(el.name.text, fn)
4227
+ }
4228
+ }
4229
+ continue
4230
+ }
4231
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4232
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
4233
+ // `export { a as b } from 'src'` — keyed by the EXTERNAL name `b`.
4080
4234
  for (const el of stmt.exportClause.elements) {
4081
4235
  if (el.isTypeOnly) continue
4082
- const fn = localFns.get((el.propertyName ?? el.name).text)
4083
- if (fn) exportedFns.set(el.name.text, fn)
4236
+ reexports.set(el.name.text, {
4237
+ source: stmt.moduleSpecifier.text,
4238
+ innerName: (el.propertyName ?? el.name).text,
4239
+ })
4084
4240
  }
4241
+ } else if (!stmt.exportClause) {
4242
+ // `export * from 'src'`. (`export * as ns from` carries a
4243
+ // NamespaceExport clause here, not `undefined` — excluded on
4244
+ // purpose: a named lookup can never come through it.)
4245
+ hasStarReexport = true
4085
4246
  }
4086
4247
  }
4087
4248
 
@@ -4089,17 +4250,77 @@ function prescanImportedReactiveFactories(
4089
4250
  // body must not reference any of these (#2325 §4h / BF112), since
4090
4251
  // inlining moves the body into the component file where they don't
4091
4252
  // exist.
4092
- const moduleBindings = collectHelperModuleValueBindings(helperSf)
4253
+ const moduleBindings = collectHelperModuleValueBindings(sf)
4254
+
4255
+ const info: HelperFileInfo = { sf, exportedFns, reexports, hasStarReexport, moduleBindings }
4256
+ helperCache.set(abs, info)
4257
+ return info
4258
+ }
4259
+
4260
+ /**
4261
+ * Resolve `exportedName` from the file at `abs`, following one
4262
+ * `export ... from` hop through a barrel re-export (#2341 BUG-2).
4263
+ * `visited` guards against self/indirect barrel cycles.
4264
+ */
4265
+ function lookupExportedFactory(
4266
+ abs: string,
4267
+ exportedName: string,
4268
+ visited: Set<string>,
4269
+ hopsLeft: number
4270
+ ): ExportLookup {
4271
+ if (visited.has(abs)) return { kind: 'unknown' }
4272
+ visited.add(abs)
4273
+
4274
+ const file = loadHelperFile(abs)
4275
+ if (file === null) return { kind: 'unknown' }
4276
+ if (file === 'clean') return { kind: 'clean' }
4277
+
4278
+ const fn = file.exportedFns.get(exportedName)
4279
+ if (fn) return { kind: 'fn', fn, file, definingPath: abs }
4280
+
4281
+ const re = file.reexports.get(exportedName)
4282
+ if (re) {
4283
+ if (hopsLeft <= 0) return { kind: 'unknown' }
4284
+ // Non-relative re-export specifiers (`export { x } from 'pkg'`)
4285
+ // resolve through bundler/tsconfig-paths configuration this layer
4286
+ // doesn't consume — same restriction as direct imports.
4287
+ if (!re.source.startsWith('./') && !re.source.startsWith('../')) return { kind: 'unknown' }
4288
+ const target = resolveRelativeImportToFile(re.source, abs)
4289
+ if (!target) return { kind: 'unknown' } // unresolvable re-export target: never mark clean
4290
+ return lookupExportedFactory(target, re.innerName, visited, hopsLeft - 1)
4291
+ }
4292
+
4293
+ // `export * from` might still reach this name through a file this
4294
+ // layer doesn't enumerate — never clean. Otherwise the file was fully
4295
+ // inspected and genuinely doesn't define or re-export this name.
4296
+ if (file.hasStarReexport) return { kind: 'unknown' }
4297
+ return { kind: 'clean' }
4298
+ }
4299
+
4300
+ for (const { src, specs } of importsToCheck) {
4301
+ const resolved = resolveRelativeImportToFile(src, filePath)
4302
+ // Unresolvable — left alone here; the name-heuristic BF110 branch in
4303
+ // validateReactiveFactoryCalls handles it at validation time.
4304
+ if (!resolved) continue
4305
+
4306
+ const alreadyKnown = (name: string): boolean =>
4307
+ result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4093
4308
 
4094
4309
  for (const spec of specs) {
4095
4310
  if (alreadyKnown(spec.local)) continue
4096
4311
 
4097
- const fn = exportedFns.get(spec.exported)
4098
- if (!fn) {
4312
+ const found = lookupExportedFactory(resolved, spec.exported, new Set<string>(), MAX_REEXPORT_HOPS)
4313
+ if (found.kind === 'clean') {
4099
4314
  result.cleanFactoryImports.add(spec.local)
4100
4315
  continue
4101
4316
  }
4102
- const det = detectReactiveFactory(fn, helperSf, resolved)
4317
+ if (found.kind === 'unknown') continue
4318
+
4319
+ const { fn, file, definingPath } = found
4320
+ const helperSf = file.sf
4321
+ const moduleBindings = file.moduleBindings
4322
+
4323
+ const det = detectReactiveFactory(fn, helperSf, definingPath)
4103
4324
  if (!det) {
4104
4325
  result.cleanFactoryImports.add(spec.local)
4105
4326
  continue
@@ -4112,17 +4333,88 @@ function prescanImportedReactiveFactories(
4112
4333
  result.declined.set(spec.local, det.declined)
4113
4334
  break
4114
4335
  case 'factory': {
4115
- const offending = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4116
- if (offending.length > 0) {
4336
+ // Module-capture check is anchored to the DEFINING file's own
4337
+ // module bindings, not the barrel's (#2341 BUG-2) — the barrel
4338
+ // itself contributes no bindings the inlined body could reference.
4339
+ const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4340
+ if (capture.captured.length > 0) {
4117
4341
  result.declined.set(spec.local, {
4118
4342
  code: 'BF112',
4119
- detail: `'${offending.join("', '")}'`,
4343
+ detail: `'${capture.captured.join("', '")}'`,
4120
4344
  loc: det.info.loc,
4121
4345
  })
4122
- } else {
4123
- det.info.sourceFilePath = resolved
4124
- result.factories.set(spec.local, det.info)
4346
+ break
4347
+ }
4348
+ // #2332 — re-provision the helper file's own named value imports
4349
+ // that the factory body references, instead of declining. Each
4350
+ // ref resolves to a component-relative specifier (or passes
4351
+ // through unchanged for bare/npm specifiers); a ref already
4352
+ // satisfied by an identical top-level import in the component
4353
+ // file is dropped rather than injected (would redeclare it). A
4354
+ // ref whose local name collides with a DIFFERENT existing/planned
4355
+ // binding declines with BF113 — `pending` is only merged into
4356
+ // `plannedInjections` on full factory success (§3.4), so a
4357
+ // factory that declines mid-loop reserves nothing.
4358
+ const required: RequiredFactoryImport[] = []
4359
+ const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
4360
+ let declinedEntry: DeclinedReactiveFactory | null = null
4361
+ for (const ref of capture.importedRefs) {
4362
+ let specifier: string
4363
+ let targetKey: string
4364
+ if (ref.source.startsWith('./') || ref.source.startsWith('../')) {
4365
+ // Resolve from the DEFINING file's directory (#2341 BUG-2 —
4366
+ // `definingPath` is the file that actually declares this
4367
+ // import, which may differ from the barrel that was
4368
+ // imported). Unresolvable → same posture as a local
4369
+ // capture: nothing importable to re-provision (BF112).
4370
+ const abs = resolveRelativeImportToFile(ref.source, definingPath)
4371
+ if (!abs) {
4372
+ declinedEntry = {
4373
+ code: 'BF112',
4374
+ detail: `'${ref.localName}' (import '${ref.source}' did not resolve from the helper file)`,
4375
+ loc: det.info.loc,
4376
+ }
4377
+ break
4378
+ }
4379
+ specifier = toComponentRelativeSpecifier(abs, filePath)
4380
+ targetKey = abs
4381
+ } else {
4382
+ specifier = ref.source // bare/npm specifier — unchanged (#2332 test 2)
4383
+ targetKey = ref.source
4384
+ }
4385
+ // Already satisfied by an identical top-level import in the
4386
+ // component file — injecting again would redeclare the binding.
4387
+ const existing = entryImportIndex.get(ref.localName)
4388
+ if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
4389
+ continue
4390
+ }
4391
+ const planned = plannedInjections.get(ref.localName)
4392
+ const collides =
4393
+ (existing !== undefined) ||
4394
+ (planned !== undefined && (planned.targetKey !== targetKey || planned.exportedName !== ref.exportedName)) ||
4395
+ (planned === undefined && entryBindingNames.has(ref.localName))
4396
+ if (collides) {
4397
+ declinedEntry = {
4398
+ code: 'BF113',
4399
+ detail: `'${ref.localName}' from '${specifier}'`,
4400
+ loc: det.info.loc,
4401
+ }
4402
+ break
4403
+ }
4404
+ pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
4405
+ required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
4406
+ }
4407
+ if (declinedEntry) {
4408
+ result.declined.set(spec.local, declinedEntry)
4409
+ break
4125
4410
  }
4411
+ for (const [name, id] of pending) plannedInjections.set(name, id)
4412
+ // #2341 BUG-2 — anchored to the file that actually defines the
4413
+ // factory, not the (possibly barrel) import path the component
4414
+ // used to reach it.
4415
+ det.info.sourceFilePath = definingPath
4416
+ if (required.length > 0) det.info.requiredImports = required
4417
+ result.factories.set(spec.local, det.info)
4126
4418
  break
4127
4419
  }
4128
4420
  }
@@ -4131,34 +4423,56 @@ function prescanImportedReactiveFactories(
4131
4423
  }
4132
4424
 
4133
4425
  /**
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.
4426
+ * Value bindings at the helper file's module scope, split by whether they
4427
+ * have a re-importable module of their own (#2332).
4428
+ *
4429
+ * `local` bindings — top-level const/let/var, function/class/enum names,
4430
+ * plus default-import and namespace-import names have no module a
4431
+ * component file could re-import them from, so an inlined factory body
4432
+ * referencing one unconditionally declines with BF112 (#2325 §4h): moving
4433
+ * the body into the component file would leave a dangling reference.
4434
+ *
4435
+ * `imported` bindings the helper file's own named value imports — CAN be
4436
+ * re-provisioned: the component file can import the same binding under the
4437
+ * same specifier (#2332). These are collected here (keyed by the helper
4438
+ * file's local name) but are NOT captures; `moduleCaptureCheck` below
4439
+ * reports them separately from `local` hits so the caller can decide
4440
+ * whether to re-import rather than unconditionally decline.
4441
+ *
4442
+ * EXCEPT in both cases: imports from '@barefootjs/client' /
4443
+ * '@barefootjs/client/runtime'. Those are re-provisioned from usage by the
4444
+ * client-JS emitter regardless of where the call that used them textually
4445
+ * came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
4446
+ * *generated* code, not the consumer's source imports — see #2325 spec C1),
4447
+ * so an inlined body calling `createSignal` is never a capture even though
4448
+ * the helper file itself imports it. Type-only imports/declarations carry
4449
+ * no runtime binding and are excluded.
4145
4450
  */
4146
- function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
4147
- const names = new Set<string>()
4451
+ interface HelperModuleBindings {
4452
+ /** Declared directly in the helper file — unconditional BF112 capture. */
4453
+ local: Set<string>
4454
+ /** Named value-import specifiers, keyed by helper-file local name —
4455
+ * re-provisionable into the component file (#2332). */
4456
+ imported: Map<string, { source: string; exportedName: string }>
4457
+ }
4458
+
4459
+ function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
4460
+ const local = new Set<string>()
4461
+ const imported = new Map<string, { source: string; exportedName: string }>()
4148
4462
  for (const stmt of sf.statements) {
4149
4463
  if (ts.isVariableStatement(stmt)) {
4150
4464
  const out: string[] = []
4151
4465
  for (const decl of stmt.declarationList.declarations) {
4152
4466
  addBindingNames(decl.name, out)
4153
4467
  }
4154
- for (const n of out) names.add(n)
4468
+ for (const n of out) local.add(n)
4155
4469
  continue
4156
4470
  }
4157
4471
  if (
4158
4472
  (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
4159
4473
  stmt.name
4160
4474
  ) {
4161
- names.add(stmt.name.text)
4475
+ local.add(stmt.name.text)
4162
4476
  continue
4163
4477
  }
4164
4478
  if (ts.isImportDeclaration(stmt)) {
@@ -4166,42 +4480,62 @@ function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
4166
4480
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4167
4481
  const src = stmt.moduleSpecifier.text
4168
4482
  if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
4169
- if (stmt.importClause?.name) names.add(stmt.importClause.name.text)
4483
+ // Default/namespace imports stay hard BF112 (#2332 scope decision):
4484
+ // no single named export to re-provision under one local name.
4485
+ if (stmt.importClause?.name) local.add(stmt.importClause.name.text)
4170
4486
  const namedBindings = stmt.importClause?.namedBindings
4171
4487
  if (namedBindings && ts.isNamedImports(namedBindings)) {
4172
4488
  for (const el of namedBindings.elements) {
4173
4489
  if (el.isTypeOnly) continue
4174
- names.add(el.name.text)
4490
+ imported.set(el.name.text, { source: src, exportedName: (el.propertyName ?? el.name).text })
4175
4491
  }
4176
4492
  }
4177
4493
  if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4178
- names.add(namedBindings.name.text)
4494
+ local.add(namedBindings.name.text)
4179
4495
  }
4180
4496
  }
4181
4497
  }
4182
- return names
4498
+ return { local, imported }
4499
+ }
4500
+
4501
+ /**
4502
+ * Categorized free-identifier references of a reactive-factory body into
4503
+ * its own module scope (#2332): `captured` are unconditional BF112 hits
4504
+ * (helper-local bindings — the body would dangle if inlined verbatim);
4505
+ * `importedRefs` are references to the helper file's own named value
4506
+ * imports, which the caller may re-provision into the component file
4507
+ * instead of declining (§3.4 in the #2332 spec).
4508
+ */
4509
+ interface ModuleCaptureResult {
4510
+ /** Free refs resolving to helper-local bindings (BF112), sorted. */
4511
+ captured: string[]
4512
+ /** Free refs resolving to the helper's own named value imports, sorted by localName. */
4513
+ importedRefs: Array<{ localName: string; source: string; exportedName: string }>
4183
4514
  }
4184
4515
 
4185
4516
  /**
4186
4517
  * 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.
4518
+ * its own module scope (#2325 §4h / BF112, #2332) — references the inlined
4519
+ * body would silently lose once spliced into the component file, unless
4520
+ * re-provisioned as an import. Returns `captured` (unconditional BF112) and
4521
+ * `importedRefs` (re-provisionable) separately, each sorted for stable
4522
+ * diagnostic/injection text.
4190
4523
  *
4191
4524
  * Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
4192
4525
  * tracks arrow-function parameters, not nested `function` declarations'
4193
4526
  * parameters or nested-block declarations — a body-nested binding that
4194
4527
  * 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.
4528
+ * BF112 or `importedRefs`. Acceptable: the failure direction is always a
4529
+ * loud build error (BF112/BF113) or a redundant-but-harmless injected
4530
+ * import, never a silent dangling reference.
4197
4531
  */
4198
4532
  function moduleCaptureCheck(
4199
4533
  fn: ts.FunctionDeclaration,
4200
4534
  info: ReactiveFactoryInfo,
4201
- moduleBindings: Set<string>,
4535
+ moduleBindings: HelperModuleBindings,
4202
4536
  selfName: string
4203
- ): string[] {
4204
- if (!fn.body) return []
4537
+ ): ModuleCaptureResult {
4538
+ if (!fn.body) return { captured: [], importedRefs: [] }
4205
4539
  const free = extractFreeIdentifiersFromNode(fn.body)
4206
4540
  const exclude = new Set<string>(info.params)
4207
4541
  for (const b of info.localBindings) exclude.add(b)
@@ -4209,12 +4543,20 @@ function moduleCaptureCheck(
4209
4543
  for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
4210
4544
  exclude.add(selfName)
4211
4545
 
4212
- const offending: string[] = []
4546
+ const captured: string[] = []
4547
+ const importedRefs: ModuleCaptureResult['importedRefs'] = []
4213
4548
  for (const id of free) {
4214
4549
  if (exclude.has(id)) continue
4215
- if (moduleBindings.has(id)) offending.push(id)
4550
+ if (moduleBindings.local.has(id)) {
4551
+ captured.push(id)
4552
+ continue
4553
+ }
4554
+ const imp = moduleBindings.imported.get(id)
4555
+ if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
4216
4556
  }
4217
- return offending.sort()
4557
+ captured.sort()
4558
+ importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4559
+ return { captured, importedRefs }
4218
4560
  }
4219
4561
 
4220
4562
  /**
@@ -4256,6 +4598,27 @@ function detectReactiveFactory(
4256
4598
 
4257
4599
  const loc = getSourceLocation(node, sourceFile, filePath)
4258
4600
 
4601
+ // Count `return`s ANYWHERE in the body, stopping at nested function-like
4602
+ // boundaries (ts.isFunctionLike — functions, arrows, methods, accessors,
4603
+ // and constructors; broader than isMultiReturnJsxFunctionBody's #932
4604
+ // three-kind check, which only needs to catch JSX-returning callbacks and
4605
+ // was never exercised against class/object methods) — a return inside a
4606
+ // nested callback OR a class/object method declared in the factory body
4607
+ // belongs to that inner scope, not this factory (Copilot review, PR
4608
+ // #2342: the narrower check would misclassify a factory with, e.g., a
4609
+ // helper class whose method contains a `return` as having multiple
4610
+ // returns, declining it unnecessarily). A return nested in if/try/loop
4611
+ // blocks DOES count, so guard-clause factories are declassified instead
4612
+ // of splicing an early `return` into the component's init function
4613
+ // (#2341 BUG-3).
4614
+ let totalReturnCount = 0
4615
+ function countReturns(n: ts.Node): void {
4616
+ if (ts.isFunctionLike(n)) return
4617
+ if (ts.isReturnStatement(n)) { totalReturnCount++; return }
4618
+ ts.forEachChild(n, countReturns)
4619
+ }
4620
+ ts.forEachChild(node.body, countReturns)
4621
+
4259
4622
  // Require exactly one top-level `return`, whose argument (after unwrapping
4260
4623
  // parens / `as const` / type-assertion) is a tuple (array literal) or a
4261
4624
  // shorthand-object literal.
@@ -4272,7 +4635,12 @@ function detectReactiveFactory(
4272
4635
  if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
4273
4636
  returnExpr = expr
4274
4637
  }
4275
- if (returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
4638
+ // `totalReturnCount === 1 && returnCount === 1` jointly guarantee the
4639
+ // single return is a direct child of `node.body` (top-level returns are a
4640
+ // subset of total) — so a guard-clause / try-catch / loop return
4641
+ // declassifies the factory instead of silently producing an inert
4642
+ // component (#2341 BUG-3).
4643
+ if (totalReturnCount !== 1 || returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
4276
4644
 
4277
4645
  const returnTupleIdentifiers: string[] = []
4278
4646
  let returnKind: 'tuple' | 'object'
@@ -4308,6 +4676,22 @@ function detectReactiveFactory(
4308
4676
  return { kind: 'reactive-shaped' }
4309
4677
  }
4310
4678
 
4679
+ // Collect parameter names first — the rename-site walk below needs them
4680
+ // as part of `relevantNames` and to detect param-shadowing declarations
4681
+ // (BF114). Hoisted above local-binding/body-serialization collection
4682
+ // (#2341 BUG-1); it has no dependency on either.
4683
+ const params: string[] = []
4684
+ for (const p of node.parameters) {
4685
+ if (ts.isIdentifier(p.name)) {
4686
+ params.push(p.name.text)
4687
+ continue
4688
+ }
4689
+ // Destructured params are uncommon for this helper shape and out of
4690
+ // initial scope; the factory still wraps a reactive primitive, so
4691
+ // classify it as reactive-shaped rather than silently ignoring it.
4692
+ return { kind: 'reactive-shaped' }
4693
+ }
4694
+
4311
4695
  // Collect local bindings in the factory body for identifier hygiene at
4312
4696
  // inlining time. Only direct-child declarations of the block are
4313
4697
  // considered — good enough for the typical helper shape.
@@ -4322,24 +4706,146 @@ function detectReactiveFactory(
4322
4706
  }
4323
4707
  }
4324
4708
 
4709
+ // Names that call-site inlining may rename: params (substituted with
4710
+ // arbitrary argument expressions), local bindings (suffix-renamed for
4711
+ // per-call-site hygiene), and return identifiers (renamed to the
4712
+ // caller's destructure names). Every other identifier in the body is
4713
+ // left untouched — the #2341 BUG-1 fix for the old whole-body regex
4714
+ // renames, which corrupted string/template literals, property keys,
4715
+ // and `.prop` tails that merely happened to share a relevant name.
4716
+ const relevantNames = new Set<string>([...params, ...localBindings, ...returnTupleIdentifiers])
4717
+
4718
+ const renameSites: FactoryRenameSite[] = []
4719
+ let shadowedParam: string | null = null
4720
+
4721
+ /**
4722
+ * Recursive AST walk collecting rename sites for one kept statement.
4723
+ * `toBodyOffset` converts a position in `sourceFile` (this statement's
4724
+ * original source) to an offset in the final joined `bodyStatements`
4725
+ * string — see the join loop below for why this must stay a pure
4726
+ * function of `stmtStart`/`base`.
4727
+ */
4728
+ function collectRenameSites(root: ts.Node, toBodyOffset: (pos: number) => number): void {
4729
+ function push(id: ts.Identifier, form: 'plain' | 'shorthand'): void {
4730
+ renameSites.push({
4731
+ name: id.text,
4732
+ start: toBodyOffset(id.getStart(sourceFile)),
4733
+ end: toBodyOffset(id.getEnd()),
4734
+ form,
4735
+ })
4736
+ }
4737
+
4738
+ function classify(id: ts.Identifier): void {
4739
+ if (!relevantNames.has(id.text)) return
4740
+ const p = id.parent
4741
+ // Pure-key / non-reference positions — never rename:
4742
+ if (ts.isPropertyAccessExpression(p) && p.name === id) return // obj.name tail (incl. ?. chains)
4743
+ if (ts.isPropertyAssignment(p) && p.name === id) return // { name: v } key
4744
+ if (ts.isBindingElement(p) && p.propertyName === id) return // { name: local } pattern key
4745
+ if ((ts.isMethodDeclaration(p) || ts.isGetAccessorDeclaration(p) ||
4746
+ ts.isSetAccessorDeclaration(p) || ts.isPropertyDeclaration(p) ||
4747
+ ts.isEnumMember(p)) && p.name === id) return // member keys
4748
+ if (ts.isJsxAttribute(p) && p.name === id) return // JSX attr name
4749
+ if ((ts.isLabeledStatement(p) && p.label === id) ||
4750
+ ((ts.isBreakStatement(p) || ts.isContinueStatement(p)) && p.label === id)) return
4751
+ if ((ts.isJsxOpeningElement(p) || ts.isJsxSelfClosingElement(p) || ts.isJsxClosingElement(p)) &&
4752
+ p.tagName === id && /^[a-z]/.test(id.text)) return // intrinsic tag <div>
4753
+
4754
+ // Shorthand dual-role positions → expansion form (renaming must
4755
+ // preserve the implied key): `{ name }` object literal / pattern.
4756
+ if (ts.isShorthandPropertyAssignment(p) && p.name === id) { push(id, 'shorthand'); return }
4757
+ if (ts.isBindingElement(p) && p.name === id && !p.propertyName &&
4758
+ ts.isObjectBindingPattern(p.parent)) {
4759
+ // ArrayBindingPattern trap: a tuple destructure element
4760
+ // (`const [a, b] = ...`) ALSO has propertyName === undefined —
4761
+ // the ts.isObjectBindingPattern(p.parent) check above is what
4762
+ // keeps tuple elements out of the shorthand-expansion path.
4763
+ if (params.includes(id.text)) shadowedParam = id.text // decl of a param name → BF114
4764
+ push(id, 'shorthand')
4765
+ return
4766
+ }
4767
+
4768
+ // Declaration-name positions → plain rename, but a param name
4769
+ // re-declared here is a shadow: params are substituted with
4770
+ // arbitrary argument expressions, so a shadowing declaration would
4771
+ // receive an invalid left-hand side (BF114 declines instead).
4772
+ const isDecl =
4773
+ (ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p) ||
4774
+ ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) ||
4775
+ ts.isClassDeclaration(p) || ts.isClassExpression(p)) && p.name === id
4776
+ if (isDecl && params.includes(id.text)) shadowedParam = id.text
4777
+
4778
+ push(id, 'plain') // genuine value reference or decl
4779
+ }
4780
+
4781
+ function visit(n: ts.Node): void {
4782
+ // Never descend into type-land: annotations are stripped downstream,
4783
+ // and splicing an argument EXPRESSION into a type position would be
4784
+ // invalid JS.
4785
+ if (ts.isTypeNode(n) || ts.isTypeParameterDeclaration(n) ||
4786
+ ts.isTypeAliasDeclaration(n) || ts.isInterfaceDeclaration(n)) return
4787
+ if (ts.isIdentifier(n)) { classify(n); return } // identifiers have no relevant children
4788
+ ts.forEachChild(n, visit)
4789
+ }
4790
+
4791
+ visit(root)
4792
+ }
4793
+
4325
4794
  // Serialize the body without the outer braces and without the return
4326
4795
  // statement — the return tuple/object is dissolved into caller-named
4327
- // identifiers.
4328
- const bodyStatements = node.body.statements
4329
- .filter(s => !ts.isReturnStatement(s))
4330
- .map(s => s.getText(sourceFile))
4331
- .join('\n')
4796
+ // identifiers. `renameSites` is collected in this SAME loop so its
4797
+ // offsets (relative to the joined `bodyStatements` string) can never
4798
+ // drift from the join logic — this invariant is the single most fragile
4799
+ // part of the rename mechanism (#2341 BUG-1): site collection and
4800
+ // `bodyStatements` construction must use the identical statement filter,
4801
+ // identical `getText` slices, and identical '\n' join.
4802
+ const keptStatements = node.body.statements.filter(s => !ts.isReturnStatement(s))
4803
+ const pieces: string[] = []
4804
+ let base = 0
4805
+ for (const stmt of keptStatements) {
4806
+ const text = stmt.getText(sourceFile)
4807
+ const stmtStart = stmt.getStart(sourceFile)
4808
+ collectRenameSites(stmt, (pos) => pos - stmtStart + base)
4809
+ pieces.push(text)
4810
+ base += text.length + 1 // +1 for the '\n' join — MUST match the join below
4811
+ }
4812
+ const bodyStatements = pieces.join('\n')
4813
+
4814
+ if (shadowedParam !== null) {
4815
+ return {
4816
+ kind: 'declined',
4817
+ declined: {
4818
+ code: 'BF114',
4819
+ detail: `parameter '${shadowedParam}' of '${node.name.text}' is shadowed by a nested declaration inside the factory body`,
4820
+ loc,
4821
+ },
4822
+ }
4823
+ }
4332
4824
 
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
4825
+ // Dev invariant: every collected site's [start, end) range must slice
4826
+ // out exactly the identifier text it was collected for, or the
4827
+ // bottom-to-top splice in inlineFactoryCallAtSite would corrupt
4828
+ // unrelated text. Cheap (bounded by body size) — kept as a permanent
4829
+ // guard against the offset math drifting under a future edit. Declines
4830
+ // rather than throwing (Copilot review, PR #2342): compileJSX does not
4831
+ // catch analyzer errors, so a thrown exception here would crash the
4832
+ // whole compilation instead of failing one factory loudly-but-
4833
+ // gracefully as a diagnostic — this should never trigger in practice,
4834
+ // but the failure mode if it ever does must stay "loud decline," not
4835
+ // "hard crash," matching this feature's whole design philosophy.
4836
+ for (const site of renameSites) {
4837
+ if (bodyStatements.slice(site.start, site.end) !== site.name) {
4838
+ return {
4839
+ kind: 'declined',
4840
+ declined: {
4841
+ code: 'BF111',
4842
+ detail:
4843
+ `internal rename-site offset mismatch for '${site.name}' — this is a compiler bug, ` +
4844
+ `please report it`,
4845
+ loc,
4846
+ },
4847
+ }
4338
4848
  }
4339
- // Destructured params are uncommon for this helper shape and out of
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
4849
  }
4344
4850
 
4345
4851
  return {
@@ -4351,6 +4857,7 @@ function detectReactiveFactory(
4351
4857
  returnKind,
4352
4858
  localBindings,
4353
4859
  loc,
4860
+ renameSites,
4354
4861
  },
4355
4862
  }
4356
4863
  }
@@ -4393,6 +4900,12 @@ function rewriteFactoryCallsInSource(
4393
4900
  type Edit = { start: number; end: number; replacement: string }
4394
4901
  const edits: Edit[] = []
4395
4902
  let callSiteIndex = 0
4903
+ // #2332 — factories actually inlined in this walk (not merely present in
4904
+ // `prescan.factories`; `maybeRewriteDecl` bails on arity mismatch/omitted
4905
+ // elements/rename destructures without inlining), so their
4906
+ // `requiredImports` can be re-provisioned without adding a dead import
4907
+ // for a factory that was never actually spliced in.
4908
+ const inlinedFactories = new Set<ReactiveFactoryInfo>()
4396
4909
 
4397
4910
  function visitStmt(node: ts.Node, inComponent: boolean): void {
4398
4911
  if (ts.isVariableStatement(node) && inComponent) {
@@ -4513,30 +5026,49 @@ function rewriteFactoryCallsInSource(
4513
5026
  const thisCallIndex = callSiteIndex++
4514
5027
  const suffix = `_bf${thisCallIndex}`
4515
5028
 
4516
- // Apply renames to the factory body source.
4517
- let body = factory.bodySource
4518
- // 1. Suffix-rename internal bindings.
4519
- const internalRenames = new Set<string>(factory.localBindings)
4520
- for (const ex of excludeFromSuffixRename) internalRenames.delete(ex)
4521
- for (const name of internalRenames) {
4522
- body = body.replace(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), name + suffix)
5029
+ // Merged rename map, one entry per old name → replacement text.
5030
+ // Precedence (matches the pre-#2341 sequential-pass outcome): param
5031
+ // substitution > return→caller rename > internal suffix rename — later
5032
+ // `.set()` calls for the same key overwrite earlier ones below.
5033
+ const renames = new Map<string, string>()
5034
+ // 1. Suffix-rename internal bindings not excluded (tuple params/returns,
5035
+ // or object-path destructured names see excludeFromSuffixRename's
5036
+ // callers).
5037
+ for (const name of factory.localBindings) {
5038
+ if (!excludeFromSuffixRename.has(name)) renames.set(name, name + suffix)
5039
+ }
5040
+ // 2. Return identifiers → caller destructure names (tuple path only).
5041
+ if (renameReturnToCallerNames) {
5042
+ for (const [n, caller] of renameReturnToCallerNames) {
5043
+ if (caller !== n) renames.set(n, caller) // identity rename = no-op, skip
5044
+ }
4523
5045
  }
4524
- // 2. Parameters → argument expressions. Atomic arguments (bare
5046
+ // 3. Parameters → argument expressions. Atomic arguments (bare
4525
5047
  // identifiers, numeric literals, string literals) are spliced
4526
5048
  // directly; anything more complex is wrapped in parens to preserve
4527
- // operator precedence at the splice site.
5049
+ // operator precedence at the splice site. Overwrites any same-name
5050
+ // return rename above — param substitution wins, as before #2341.
4528
5051
  const atomicArg = /^(?:[\w$.]+|'[^'\\]*'|"[^"\\]*"|-?\d+(?:\.\d+)?)$/
4529
5052
  for (let i = 0; i < factory.params.length; i++) {
4530
5053
  const p = factory.params[i]
4531
- const a = argTexts[i] ?? 'undefined'
4532
- const wrapped = atomicArg.test(a.trim()) ? a.trim() : `(${a})`
4533
- body = body.replace(new RegExp(`\\b${escapeRegex(p)}\\b`, 'g'), wrapped)
5054
+ const a = (argTexts[i] ?? 'undefined').trim()
5055
+ renames.set(p, atomicArg.test(a) ? a : `(${a})`)
4534
5056
  }
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
- }
5057
+
5058
+ // Apply the merged renames as a single bottom-to-top position splice
5059
+ // over `bodySource`, using the sites collected once at detection time
5060
+ // (#2341 BUG-1) — never a text search, so string/template-literal
5061
+ // contents, property keys, `.prop` tails, and JSX intrinsic tags are
5062
+ // never touched, and (unlike the old sequential regex passes) an
5063
+ // argument expression already spliced in for an earlier site can never
5064
+ // be re-scanned and corrupted by a later rename.
5065
+ let body = factory.bodySource
5066
+ for (let i = factory.renameSites.length - 1; i >= 0; i--) {
5067
+ const site = factory.renameSites[i]
5068
+ const repl = renames.get(site.name)
5069
+ if (repl === undefined) continue
5070
+ const text = site.form === 'shorthand' ? `${site.name}: ${repl}` : repl
5071
+ body = body.slice(0, site.start) + text + body.slice(site.end)
4540
5072
  }
4541
5073
 
4542
5074
  edits.push({
@@ -4544,13 +5076,51 @@ function rewriteFactoryCallsInSource(
4544
5076
  end: stmt.getEnd(),
4545
5077
  replacement: body,
4546
5078
  })
5079
+ inlinedFactories.add(factory)
4547
5080
  }
4548
5081
 
4549
5082
  visitStmt(sourceFile, false)
4550
5083
 
4551
5084
  if (edits.length === 0) return source
4552
5085
 
4553
- // Apply edits from bottom to top so earlier offsets stay valid.
5086
+ // #2332 one deduped import statement per specifier for every inlined
5087
+ // cross-file factory's re-provisioned imports. Injected as a zero-width
5088
+ // edit so the ordinary bottom-to-top splice below applies it; the result
5089
+ // is indistinguishable from a hand-written import for every downstream
5090
+ // consumer (ctx.imports → SSR templateImports AND client
5091
+ // collectExternalImports both parse this same rewritten string).
5092
+ const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
5093
+ for (const f of inlinedFactories) {
5094
+ for (const r of f.requiredImports ?? []) {
5095
+ let names = importsBySpecifier.get(r.specifier)
5096
+ if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
5097
+ names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
5098
+ }
5099
+ }
5100
+ if (importsBySpecifier.size > 0) {
5101
+ // Sort specifiers and, within each, named-import entries by local name —
5102
+ // `importsBySpecifier`/`inlinedFactories` iterate in incidental AST-
5103
+ // traversal/insertion order, which would otherwise make this generated
5104
+ // text order-unstable across unrelated refactors (Copilot review, PR
5105
+ // #2338).
5106
+ const lines = [...importsBySpecifier]
5107
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5108
+ .map(([spec, names]) => {
5109
+ const specifiers = [...names]
5110
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5111
+ .map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
5112
+ return `import { ${specifiers.join(', ')} } from '${spec}'`
5113
+ })
5114
+ const at = factoryImportInsertionOffset(sourceFile)
5115
+ edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
5116
+ }
5117
+
5118
+ // Apply edits from bottom to top so earlier offsets stay valid. A factory
5119
+ // with `requiredImports` is by definition imported, so the entry file has
5120
+ // at least one import statement and `at` above lands strictly inside the
5121
+ // import prologue — every call-site edit's start is strictly greater
5122
+ // (separated at minimum by the statement break after the last import), so
5123
+ // starts never tie and no sort tiebreak is needed.
4554
5124
  edits.sort((a, b) => b.start - a.start)
4555
5125
  let out = source
4556
5126
  for (const e of edits) {
@@ -4559,6 +5129,24 @@ function rewriteFactoryCallsInSource(
4559
5129
  return out
4560
5130
  }
4561
5131
 
5132
+ /**
5133
+ * Offset just after the last top-level import (else after the 'use client'
5134
+ * directive, else 0) in the prescan source file — where re-provisioned
5135
+ * factory imports are injected (#2332).
5136
+ */
5137
+ function factoryImportInsertionOffset(sf: ts.SourceFile): number {
5138
+ let lastImportEnd = -1
5139
+ let directiveEnd = -1
5140
+ for (const stmt of sf.statements) {
5141
+ if (ts.isImportDeclaration(stmt)) { lastImportEnd = stmt.getEnd(); continue }
5142
+ if (directiveEnd === -1 && ts.isExpressionStatement(stmt) &&
5143
+ ts.isStringLiteral(stmt.expression) && stmt.expression.text === 'use client') {
5144
+ directiveEnd = stmt.getEnd()
5145
+ }
5146
+ }
5147
+ return lastImportEnd >= 0 ? lastImportEnd : directiveEnd >= 0 ? directiveEnd : 0
5148
+ }
5149
+
4562
5150
  function isPascalCaseComponentFn(node: ts.Node): boolean {
4563
5151
  if (ts.isFunctionDeclaration(node) && node.name) {
4564
5152
  return /^[A-Z]/.test(node.name.text)
@@ -4569,10 +5157,6 @@ function isPascalCaseComponentFn(node: ts.Node): boolean {
4569
5157
  return false
4570
5158
  }
4571
5159
 
4572
- function escapeRegex(s: string): string {
4573
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
4574
- }
4575
-
4576
5160
  // =============================================================================
4577
5161
  // BF110 diagnostic (#931)
4578
5162
  // =============================================================================
@@ -4580,11 +5164,12 @@ function escapeRegex(s: string): string {
4580
5164
  /**
4581
5165
  * Build the diagnostic for a call site whose callee was recognised but
4582
5166
  * 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.
5167
+ * return properties or capture their own module scope; #2332 a
5168
+ * re-provisioned helper import collides with an existing binding). BF112
5169
+ * and BF113's wording is specific to their respective failure; every other
5170
+ * declined reason (currently only BF111 non-shorthand return properties)
5171
+ * shares BF111's generic "cannot be inlined: <detail>" phrasing, matching
5172
+ * the wording used for the tuple call-site path.
4588
5173
  */
4589
5174
  function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
4590
5175
  if (d.code === 'BF112') {
@@ -4594,9 +5179,27 @@ function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): str
4594
5179
  `pass them as factory arguments, or inline the factory here.`
4595
5180
  )
4596
5181
  }
5182
+ if (d.code === 'BF113') {
5183
+ return (
5184
+ `Reactive factory '${callee}' cannot be inlined: it needs ${d.detail} ` +
5185
+ `imported into this file, but that name is already bound here to something ` +
5186
+ `else. Rename the conflicting binding in this file, or alias the import in ` +
5187
+ `the factory's own file (import { x as y }).`
5188
+ )
5189
+ }
4597
5190
  return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
4598
5191
  }
4599
5192
 
5193
+ /** Diagnostic code for a declined reactive-factory call site (#2325 / #2332). */
5194
+ function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorCode {
5195
+ switch (code) {
5196
+ case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
5197
+ case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
5198
+ case 'BF114': return ErrorCodes.REACTIVE_FACTORY_PARAM_SHADOWED
5199
+ default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
5200
+ }
5201
+ }
5202
+
4600
5203
  /**
4601
5204
  * Scan a compiled component context for destructures (tuple or object)
4602
5205
  * whose callee is neither `createSignal` / `createMemo` nor an inlinable
@@ -4636,9 +5239,7 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4636
5239
  const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4637
5240
  if (declinedEntry) {
4638
5241
  ctx.errors.push(createError(
4639
- declinedEntry.code === 'BF112'
4640
- ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4641
- : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
5242
+ declinedFactoryErrorCode(declinedEntry.code),
4642
5243
  loc,
4643
5244
  { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4644
5245
  ))
@@ -4696,8 +5297,8 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4696
5297
  * covers the previously-silent object-destructure failure modes: an
4697
5298
  * unrecognised callee, a tuple factory destructured as an object, a rename/
4698
5299
  * 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.
5300
+ * property, a declined (BF111/BF112/BF113) factory, or an uninspectable
5301
+ * import that looks reactive-factory-shaped by name.
4701
5302
  */
4702
5303
  function validateObjectFactoryDestructure(
4703
5304
  ctx: AnalyzerContext,
@@ -4759,9 +5360,7 @@ function validateObjectFactoryDestructure(
4759
5360
  const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4760
5361
  if (declinedEntry) {
4761
5362
  ctx.errors.push(createError(
4762
- declinedEntry.code === 'BF112'
4763
- ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4764
- : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
5363
+ declinedFactoryErrorCode(declinedEntry.code),
4765
5364
  loc,
4766
5365
  { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4767
5366
  ))