@barefootjs/jsx 0.24.1 → 0.26.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.
Files changed (46) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +52 -24
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/errors.d.ts +1 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.js +473 -165
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  11. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  13. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  15. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  18. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/types.d.ts +13 -0
  20. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  21. package/dist/to-locale-date-lowering.d.ts +31 -10
  22. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  23. package/dist/types.d.ts +29 -5
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  27. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  28. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +833 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
  31. package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
  32. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  33. package/src/adapters/dangerous-inner-html.ts +101 -48
  34. package/src/analyzer.ts +607 -142
  35. package/src/errors.ts +4 -0
  36. package/src/ir-to-client-js/collect-elements.ts +28 -2
  37. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  38. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  39. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  40. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  41. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  42. package/src/ir-to-client-js/reactivity.ts +61 -7
  43. package/src/ir-to-client-js/types.ts +13 -0
  44. package/src/rich-type-refusal.ts +3 -2
  45. package/src/to-locale-date-lowering.ts +50 -13
  46. package/src/types.ts +30 -5
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, RequiredFactoryImport, 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'
@@ -2542,6 +2542,59 @@ export function extractFreeIdentifiersFromNode(node: ts.Node): Set<string> {
2542
2542
  return ids
2543
2543
  }
2544
2544
 
2545
+ /**
2546
+ * Identifiers referenced in TYPE position anywhere under `node` (#2350) —
2547
+ * the mirror image of `extractFreeIdentifiersFromNode`, which explicitly
2548
+ * skips type nodes since they carry no runtime reference. Walks the whole
2549
+ * tree without stopping at type nodes; every `TypeReferenceNode`'s root
2550
+ * name is collected, which by plain `forEachChild` recursion also reaches
2551
+ * nested references (generic type arguments, array/union/tuple members,
2552
+ * function-type params/returns) without needing to special-case each shape.
2553
+ * Also collects `TypeQueryNode` (`typeof Foo`) root names — `Foo` there is
2554
+ * a VALUE reference in type position, so `moduleCaptureCheck`'s fallback to
2555
+ * `moduleBindings.imported`/`local` (added for the plain-value-import case,
2556
+ * Copilot review, PR #2351) resolves it the same way (Copilot review, PR
2557
+ * #2351).
2558
+ * A function-like node's own type parameters (`<T>`) are tracked and
2559
+ * excluded within its body/signature, same idea as the value walker's
2560
+ * arrow-parameter tracking — otherwise a generic named the same as a
2561
+ * module-scope type (`function useThing<SavedList>(x: SavedList)`) would
2562
+ * be misclassified as a reference to the import (Copilot review, PR
2563
+ * #2351). Doesn't exclude body-local type/interface declarations that
2564
+ * shadow a module-scope name — rare enough here that, per this file's
2565
+ * accepted-limitation policy, a redundant-but-harmless re-provisioned
2566
+ * import is an acceptable outcome (never a silent dangling reference).
2567
+ */
2568
+ function extractFreeTypeIdentifiersFromNode(node: ts.Node): Set<string> {
2569
+ const ids = new Set<string>()
2570
+ const boundTypeParams = new Set<string>()
2571
+ function rootName(name: ts.EntityName): ts.Identifier {
2572
+ return ts.isQualifiedName(name) ? rootName(name.left) : name
2573
+ }
2574
+ function visit(n: ts.Node): void {
2575
+ if (ts.isTypeReferenceNode(n)) {
2576
+ const name = rootName(n.typeName).text
2577
+ if (!boundTypeParams.has(name)) ids.add(name)
2578
+ // Keep descending — nested references (`Promise<SavedList>`,
2579
+ // `Record<string, SavedList>`) live in this same node's type arguments.
2580
+ }
2581
+ if (ts.isTypeQueryNode(n)) {
2582
+ const name = rootName(n.exprName).text
2583
+ if (!boundTypeParams.has(name)) ids.add(name)
2584
+ }
2585
+ if (ts.isFunctionLike(n) && n.typeParameters && n.typeParameters.length > 0) {
2586
+ const names = n.typeParameters.map((p) => p.name.text)
2587
+ for (const p of names) boundTypeParams.add(p)
2588
+ ts.forEachChild(n, visit)
2589
+ for (const p of names) boundTypeParams.delete(p)
2590
+ return
2591
+ }
2592
+ ts.forEachChild(n, visit)
2593
+ }
2594
+ visit(node)
2595
+ return ids
2596
+ }
2597
+
2545
2598
  /**
2546
2599
  * Check if a const initializer expression contains JSX at a non-root
2547
2600
  * position — ternary with JSX on either side, logical-AND / OR /
@@ -3965,32 +4018,40 @@ function toComponentRelativeSpecifier(resolvedAbs: string, componentFilePath: st
3965
4018
  }
3966
4019
 
3967
4020
  /**
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.
4021
+ * localName -> identity of what the entry file's own top-level named
4022
+ * imports bind, for the satisfied-import dedupe check (#2332, type-only
4023
+ * imports included #2350): if the component file already imports the exact
4024
+ * binding a factory needs to re-provision, injecting it again would be a
4025
+ * duplicate declaration rather than a shadow, so that case is skipped
4026
+ * instead of injected. `targetKey` is the resolved absolute path for
4027
+ * relative sources (or `unresolved:<source>` when probing fails) and the
4028
+ * raw specifier for bare sources. `isTypeOnly` matters at the call site: an
4029
+ * existing type-only import satisfies a factory's type-only need but NOT a
4030
+ * value need (it has no runtime binding) — treating it as satisfying both
4031
+ * would silently skip re-provisioning a value the inlined body actually
4032
+ * calls, the same dangling-reference failure #2341 BUG-2 already covers.
3975
4033
  */
3976
4034
  function buildEntryImportIndex(
3977
4035
  sf: ts.SourceFile,
3978
4036
  filePath: string
3979
- ): Map<string, { targetKey: string; exportedName: string }> {
3980
- const index = new Map<string, { targetKey: string; exportedName: string }>()
4037
+ ): Map<string, { targetKey: string; exportedName: string; isTypeOnly: boolean }> {
4038
+ const index = new Map<string, { targetKey: string; exportedName: string; isTypeOnly: boolean }>()
3981
4039
  for (const stmt of sf.statements) {
3982
4040
  if (!ts.isImportDeclaration(stmt)) continue
3983
4041
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
3984
- if (stmt.importClause?.isTypeOnly) continue
3985
4042
  const src = stmt.moduleSpecifier.text
3986
4043
  const targetKey = src.startsWith('./') || src.startsWith('../')
3987
4044
  ? (resolveRelativeImportToFile(src, filePath) ?? 'unresolved:' + src)
3988
4045
  : src
4046
+ const wholeTypeOnly = stmt.importClause?.isTypeOnly === true
3989
4047
  const namedBindings = stmt.importClause?.namedBindings
3990
4048
  if (namedBindings && ts.isNamedImports(namedBindings)) {
3991
4049
  for (const el of namedBindings.elements) {
3992
- if (el.isTypeOnly) continue
3993
- index.set(el.name.text, { targetKey, exportedName: (el.propertyName ?? el.name).text })
4050
+ index.set(el.name.text, {
4051
+ targetKey,
4052
+ exportedName: (el.propertyName ?? el.name).text,
4053
+ isTypeOnly: wholeTypeOnly || el.isTypeOnly,
4054
+ })
3994
4055
  }
3995
4056
  }
3996
4057
  }
@@ -4034,6 +4095,9 @@ function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
4034
4095
  ) {
4035
4096
  names.add(node.name.text)
4036
4097
  }
4098
+ if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name) {
4099
+ names.add(node.name.text)
4100
+ }
4037
4101
  if (ts.isFunctionLike(node)) {
4038
4102
  for (const p of node.parameters) {
4039
4103
  const out: string[] = []
@@ -4047,18 +4111,56 @@ function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
4047
4111
  return names
4048
4112
  }
4049
4113
 
4114
+ // One `export ... from` hop is followed when resolving a re-exported name
4115
+ // (#2341 BUG-2) — a visited-set guards against cycles regardless, so
4116
+ // raising this later is safe without further changes.
4117
+ const MAX_REEXPORT_HOPS = 1
4118
+
4119
+ /** A helper file's export surface, cached per absolute path for the
4120
+ * lifetime of one `prescanImportedReactiveFactories` call (#2341 BUG-2). */
4121
+ interface HelperFileInfo {
4122
+ sf: ts.SourceFile
4123
+ /** Module-scope function declarations exported under their external name
4124
+ * (own `export function`, or a local `export { f as g }`). */
4125
+ exportedFns: Map<string, ts.FunctionDeclaration>
4126
+ /** `export { a as b } from 'src'` re-exports, keyed by the EXTERNAL name
4127
+ * `b` — a barrel file's whole reason for existing (#2341 BUG-2). */
4128
+ reexports: Map<string, { source: string; innerName: string }>
4129
+ /** Any `export * from '...'` in this file — a named lookup that misses
4130
+ * `exportedFns`/`reexports` might still resolve through one of these, so
4131
+ * it can never be classified `'clean'`. (`export * as ns from` is a
4132
+ * `NamespaceExport` clause and is excluded: a named lookup can never
4133
+ * come through it.) */
4134
+ hasStarReexport: boolean
4135
+ moduleBindings: HelperModuleBindings
4136
+ }
4137
+ /** `'clean'` = read, parsed (or gate-skipped), and proven to define no
4138
+ * reactive factory under any name reachable from it. `null` = unreadable. */
4139
+ type LoadedHelper = HelperFileInfo | 'clean' | null
4140
+ type ExportLookup =
4141
+ | { kind: 'fn'; fn: ts.FunctionDeclaration; file: HelperFileInfo; definingPath: string }
4142
+ | { kind: 'clean' } // proven non-reactive under this name → cleanFactoryImports
4143
+ | { kind: 'unknown' } // cannot prove → leave unclassified so the BF110 name heuristic still fires
4144
+
4050
4145
  /**
4051
4146
  * Cross-file half of the factory prescan (#2325 round 2): resolve factories
4052
4147
  * defined in a relative-imported helper file so `const { count } =
4053
4148
  * createCounter(0)` inlines the same way whether `createCounter` lives in
4054
- * this file or in `./hooks`. Mutates `result`'s maps in place.
4149
+ * this file or in `./hooks`. Follows one `export ... from` hop so a barrel
4150
+ * `index.ts` re-exporting the real helper resolves to the file that
4151
+ * actually DEFINES it (#2341 BUG-2) — every classification (factory /
4152
+ * declined / reactive-shaped / clean) and every downstream anchor (module-
4153
+ * capture check, helper-import re-provisioning, `sourceFilePath`) is keyed
4154
+ * off that defining file, never the barrel. Mutates `result`'s maps in
4155
+ * place.
4055
4156
  *
4056
4157
  * Perf: gated on a candidate-callee set collected from the ALREADY-parsed
4057
4158
  * entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
4058
4159
  * imports with regex" rule) — files with no tuple/object-destructured call
4059
4160
  * 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
4161
+ * helper file's raw text contain any `REACTIVE_PRIMITIVES` substring, or
4162
+ * any `export ... from` re-export text) skips the AST parse of helper files
4163
+ * that plainly define no factory and re-export nothing; this is a
4062
4164
  * skip-gate over content, not an import parse, so it doesn't run afoul of
4063
4165
  * that same rule.
4064
4166
  *
@@ -4127,47 +4229,48 @@ function prescanImportedReactiveFactories(
4127
4229
  const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
4128
4230
  // localName -> planned injection identity; a later factory requiring the
4129
4231
  // same name from a DIFFERENT (targetKey, exportedName) is a collision.
4232
+ // Deliberately isTypeOnly-agnostic: a value need and a type need for the
4233
+ // SAME (targetKey, exportedName) aren't a real collision (the eventual
4234
+ // value import satisfies both) — the final line-generation step below
4235
+ // drops the redundant type-only line rather than declining here.
4130
4236
  const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
4131
4237
 
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
4238
+ // #2341 BUG-2 one read+parse per helper file per entry file, memoized
4239
+ // across every spec/hop that touches it (a barrel is typically visited
4240
+ // once per re-exported name it satisfies).
4241
+ const helperCache = new Map<string, LoadedHelper>()
4242
+
4243
+ function loadHelperFile(abs: string): LoadedHelper {
4244
+ const cached = helperCache.get(abs)
4245
+ if (cached !== undefined) return cached
4137
4246
 
4138
4247
  let content: string
4139
4248
  try {
4140
- content = fs.readFileSync(resolved, 'utf8')
4249
+ content = fs.readFileSync(abs, 'utf8')
4141
4250
  } catch {
4142
- continue
4251
+ helperCache.set(abs, null)
4252
+ return null
4143
4253
  }
4144
4254
 
4145
- const alreadyKnown = (name: string): boolean =>
4146
- result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4147
-
4148
4255
  // 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.
4256
+ // a file with no reactive-primitive substring AND no re-export gate
4257
+ // text (`export` ... `from`) can neither define a reactive factory nor
4258
+ // re-export one, so skip parsing it entirely. Substring checks only —
4259
+ // false positives merely cause an AST parse whose outcome is still
4260
+ // correct, they never cause a false 'clean'.
4151
4261
  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
4262
+ const hasReexportText = content.includes('export') && content.includes('from')
4263
+ if (!hasAnyPrimitiveText && !hasReexportText) {
4264
+ helperCache.set(abs, 'clean')
4265
+ return 'clean'
4157
4266
  }
4158
4267
 
4159
- const helperSf = ts.createSourceFile(
4160
- resolved + '.prescan',
4161
- content,
4162
- ts.ScriptTarget.Latest,
4163
- true,
4164
- ts.ScriptKind.TSX
4165
- )
4268
+ const sf = ts.createSourceFile(abs + '.prescan', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
4166
4269
 
4167
4270
  // Map exported name -> module-scope FunctionDeclaration via the AST.
4168
4271
  const localFns = new Map<string, ts.FunctionDeclaration>()
4169
4272
  const exportedFns = new Map<string, ts.FunctionDeclaration>()
4170
- for (const stmt of helperSf.statements) {
4273
+ for (const stmt of sf.statements) {
4171
4274
  if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
4172
4275
  localFns.set(stmt.name.text, stmt)
4173
4276
  const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
@@ -4177,20 +4280,37 @@ function prescanImportedReactiveFactories(
4177
4280
  }
4178
4281
  }
4179
4282
  }
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
- ) {
4283
+
4284
+ const reexports = new Map<string, { source: string; innerName: string }>()
4285
+ let hasStarReexport = false
4286
+ for (const stmt of sf.statements) {
4287
+ if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly) continue
4288
+ if (!stmt.moduleSpecifier) {
4289
+ // `export { f }` / `export { f as g }` — keyed by the EXTERNAL export name.
4290
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
4291
+ for (const el of stmt.exportClause.elements) {
4292
+ if (el.isTypeOnly) continue
4293
+ const fn = localFns.get((el.propertyName ?? el.name).text)
4294
+ if (fn) exportedFns.set(el.name.text, fn)
4295
+ }
4296
+ }
4297
+ continue
4298
+ }
4299
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4300
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
4301
+ // `export { a as b } from 'src'` — keyed by the EXTERNAL name `b`.
4189
4302
  for (const el of stmt.exportClause.elements) {
4190
4303
  if (el.isTypeOnly) continue
4191
- const fn = localFns.get((el.propertyName ?? el.name).text)
4192
- if (fn) exportedFns.set(el.name.text, fn)
4304
+ reexports.set(el.name.text, {
4305
+ source: stmt.moduleSpecifier.text,
4306
+ innerName: (el.propertyName ?? el.name).text,
4307
+ })
4193
4308
  }
4309
+ } else if (!stmt.exportClause) {
4310
+ // `export * from 'src'`. (`export * as ns from` carries a
4311
+ // NamespaceExport clause here, not `undefined` — excluded on
4312
+ // purpose: a named lookup can never come through it.)
4313
+ hasStarReexport = true
4194
4314
  }
4195
4315
  }
4196
4316
 
@@ -4198,17 +4318,77 @@ function prescanImportedReactiveFactories(
4198
4318
  // body must not reference any of these (#2325 §4h / BF112), since
4199
4319
  // inlining moves the body into the component file where they don't
4200
4320
  // exist.
4201
- const moduleBindings = collectHelperModuleValueBindings(helperSf)
4321
+ const moduleBindings = collectHelperModuleValueBindings(sf)
4322
+
4323
+ const info: HelperFileInfo = { sf, exportedFns, reexports, hasStarReexport, moduleBindings }
4324
+ helperCache.set(abs, info)
4325
+ return info
4326
+ }
4327
+
4328
+ /**
4329
+ * Resolve `exportedName` from the file at `abs`, following one
4330
+ * `export ... from` hop through a barrel re-export (#2341 BUG-2).
4331
+ * `visited` guards against self/indirect barrel cycles.
4332
+ */
4333
+ function lookupExportedFactory(
4334
+ abs: string,
4335
+ exportedName: string,
4336
+ visited: Set<string>,
4337
+ hopsLeft: number
4338
+ ): ExportLookup {
4339
+ if (visited.has(abs)) return { kind: 'unknown' }
4340
+ visited.add(abs)
4341
+
4342
+ const file = loadHelperFile(abs)
4343
+ if (file === null) return { kind: 'unknown' }
4344
+ if (file === 'clean') return { kind: 'clean' }
4345
+
4346
+ const fn = file.exportedFns.get(exportedName)
4347
+ if (fn) return { kind: 'fn', fn, file, definingPath: abs }
4348
+
4349
+ const re = file.reexports.get(exportedName)
4350
+ if (re) {
4351
+ if (hopsLeft <= 0) return { kind: 'unknown' }
4352
+ // Non-relative re-export specifiers (`export { x } from 'pkg'`)
4353
+ // resolve through bundler/tsconfig-paths configuration this layer
4354
+ // doesn't consume — same restriction as direct imports.
4355
+ if (!re.source.startsWith('./') && !re.source.startsWith('../')) return { kind: 'unknown' }
4356
+ const target = resolveRelativeImportToFile(re.source, abs)
4357
+ if (!target) return { kind: 'unknown' } // unresolvable re-export target: never mark clean
4358
+ return lookupExportedFactory(target, re.innerName, visited, hopsLeft - 1)
4359
+ }
4360
+
4361
+ // `export * from` might still reach this name through a file this
4362
+ // layer doesn't enumerate — never clean. Otherwise the file was fully
4363
+ // inspected and genuinely doesn't define or re-export this name.
4364
+ if (file.hasStarReexport) return { kind: 'unknown' }
4365
+ return { kind: 'clean' }
4366
+ }
4367
+
4368
+ for (const { src, specs } of importsToCheck) {
4369
+ const resolved = resolveRelativeImportToFile(src, filePath)
4370
+ // Unresolvable — left alone here; the name-heuristic BF110 branch in
4371
+ // validateReactiveFactoryCalls handles it at validation time.
4372
+ if (!resolved) continue
4373
+
4374
+ const alreadyKnown = (name: string): boolean =>
4375
+ result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4202
4376
 
4203
4377
  for (const spec of specs) {
4204
4378
  if (alreadyKnown(spec.local)) continue
4205
4379
 
4206
- const fn = exportedFns.get(spec.exported)
4207
- if (!fn) {
4380
+ const found = lookupExportedFactory(resolved, spec.exported, new Set<string>(), MAX_REEXPORT_HOPS)
4381
+ if (found.kind === 'clean') {
4208
4382
  result.cleanFactoryImports.add(spec.local)
4209
4383
  continue
4210
4384
  }
4211
- const det = detectReactiveFactory(fn, helperSf, resolved)
4385
+ if (found.kind === 'unknown') continue
4386
+
4387
+ const { fn, file, definingPath } = found
4388
+ const helperSf = file.sf
4389
+ const moduleBindings = file.moduleBindings
4390
+
4391
+ const det = detectReactiveFactory(fn, helperSf, definingPath)
4212
4392
  if (!det) {
4213
4393
  result.cleanFactoryImports.add(spec.local)
4214
4394
  continue
@@ -4221,6 +4401,9 @@ function prescanImportedReactiveFactories(
4221
4401
  result.declined.set(spec.local, det.declined)
4222
4402
  break
4223
4403
  case 'factory': {
4404
+ // Module-capture check is anchored to the DEFINING file's own
4405
+ // module bindings, not the barrel's (#2341 BUG-2) — the barrel
4406
+ // itself contributes no bindings the inlined body could reference.
4224
4407
  const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4225
4408
  if (capture.captured.length > 0) {
4226
4409
  result.declined.set(spec.local, {
@@ -4231,7 +4414,9 @@ function prescanImportedReactiveFactories(
4231
4414
  break
4232
4415
  }
4233
4416
  // #2332 — re-provision the helper file's own named value imports
4234
- // that the factory body references, instead of declining. Each
4417
+ // that the factory body references, instead of declining (type-
4418
+ // only refs included #2350 — same treatment, tagged `isTypeOnly`
4419
+ // so the line-generator below emits `import type { ... }`). Each
4235
4420
  // ref resolves to a component-relative specifier (or passes
4236
4421
  // through unchanged for bare/npm specifiers); a ref already
4237
4422
  // satisfied by an identical top-level import in the component
@@ -4243,14 +4428,20 @@ function prescanImportedReactiveFactories(
4243
4428
  const required: RequiredFactoryImport[] = []
4244
4429
  const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
4245
4430
  let declinedEntry: DeclinedReactiveFactory | null = null
4246
- for (const ref of capture.importedRefs) {
4431
+ const allRefs = [
4432
+ ...capture.importedRefs.map((r) => ({ ...r, isTypeOnly: false })),
4433
+ ...capture.importedTypeRefs.map((r) => ({ ...r, isTypeOnly: true })),
4434
+ ]
4435
+ for (const ref of allRefs) {
4247
4436
  let specifier: string
4248
4437
  let targetKey: string
4249
4438
  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
4439
+ // Resolve from the DEFINING file's directory (#2341 BUG-2
4440
+ // `definingPath` is the file that actually declares this
4441
+ // import, which may differ from the barrel that was
4442
+ // imported). Unresolvable → same posture as a local
4252
4443
  // capture: nothing importable to re-provision (BF112).
4253
- const abs = resolveRelativeImportToFile(ref.source, resolved)
4444
+ const abs = resolveRelativeImportToFile(ref.source, definingPath)
4254
4445
  if (!abs) {
4255
4446
  declinedEntry = {
4256
4447
  code: 'BF112',
@@ -4267,8 +4458,13 @@ function prescanImportedReactiveFactories(
4267
4458
  }
4268
4459
  // Already satisfied by an identical top-level import in the
4269
4460
  // component file — injecting again would redeclare the binding.
4461
+ // A type-only need is satisfiable by ANY matching import (value
4462
+ // or type — a value import brings its type into scope too); a
4463
+ // value need can only be satisfied by an existing value import,
4464
+ // since a type-only import has no runtime binding (#2350).
4270
4465
  const existing = entryImportIndex.get(ref.localName)
4271
- if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
4466
+ if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName &&
4467
+ (ref.isTypeOnly || !existing.isTypeOnly)) {
4272
4468
  continue
4273
4469
  }
4274
4470
  const planned = plannedInjections.get(ref.localName)
@@ -4285,14 +4481,17 @@ function prescanImportedReactiveFactories(
4285
4481
  break
4286
4482
  }
4287
4483
  pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
4288
- required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
4484
+ required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier, isTypeOnly: ref.isTypeOnly || undefined })
4289
4485
  }
4290
4486
  if (declinedEntry) {
4291
4487
  result.declined.set(spec.local, declinedEntry)
4292
4488
  break
4293
4489
  }
4294
4490
  for (const [name, id] of pending) plannedInjections.set(name, id)
4295
- det.info.sourceFilePath = resolved
4491
+ // #2341 BUG-2 — anchored to the file that actually defines the
4492
+ // factory, not the (possibly barrel) import path the component
4493
+ // used to reach it.
4494
+ det.info.sourceFilePath = definingPath
4296
4495
  if (required.length > 0) det.info.requiredImports = required
4297
4496
  result.factories.set(spec.local, det.info)
4298
4497
  break
@@ -4325,8 +4524,17 @@ function prescanImportedReactiveFactories(
4325
4524
  * came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
4326
4525
  * *generated* code, not the consumer's source imports — see #2325 spec C1),
4327
4526
  * 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.
4527
+ * the helper file itself imports it.
4528
+ *
4529
+ * `localTypes`/`importedTypes` are the type-position mirror of `local`/
4530
+ * `imported` (#2350): a factory body's return-type annotations, generic
4531
+ * type arguments, and local variable type annotations reference names too,
4532
+ * and those need the exact same capture-or-re-provision treatment — a type
4533
+ * declared directly in the helper file can't be re-imported (BF112,
4534
+ * folded into `local`'s capture handling since the failure mode is
4535
+ * identical), but a type the helper file itself imports (`import type {
4536
+ * X }` or the per-specifier `import { type X }`) can be re-provisioned as
4537
+ * `import type { X } from '<specifier>'` in the component file.
4330
4538
  */
4331
4539
  interface HelperModuleBindings {
4332
4540
  /** Declared directly in the helper file — unconditional BF112 capture. */
@@ -4334,10 +4542,18 @@ interface HelperModuleBindings {
4334
4542
  /** Named value-import specifiers, keyed by helper-file local name —
4335
4543
  * re-provisionable into the component file (#2332). */
4336
4544
  imported: Map<string, { source: string; exportedName: string }>
4545
+ /** Type/interface declared directly in the helper file — unconditional
4546
+ * BF112 capture, same as `local` (#2350). */
4547
+ localTypes: Set<string>
4548
+ /** The helper file's own type-only named imports, keyed by local name —
4549
+ * re-provisionable as `import type { X } from '<specifier>'` (#2350). */
4550
+ importedTypes: Map<string, { source: string; exportedName: string }>
4337
4551
  }
4338
4552
 
4339
4553
  function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
4340
4554
  const local = new Set<string>()
4555
+ const localTypes = new Set<string>()
4556
+ const importedTypes = new Map<string, { source: string; exportedName: string }>()
4341
4557
  const imported = new Map<string, { source: string; exportedName: string }>()
4342
4558
  for (const stmt of sf.statements) {
4343
4559
  if (ts.isVariableStatement(stmt)) {
@@ -4355,35 +4571,43 @@ function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindin
4355
4571
  local.add(stmt.name.text)
4356
4572
  continue
4357
4573
  }
4574
+ if ((ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) && stmt.name) {
4575
+ localTypes.add(stmt.name.text)
4576
+ continue
4577
+ }
4358
4578
  if (ts.isImportDeclaration(stmt)) {
4359
- if (stmt.importClause?.isTypeOnly) continue
4360
4579
  if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4361
4580
  const src = stmt.moduleSpecifier.text
4362
4581
  if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
4582
+ const wholeTypeOnly = stmt.importClause?.isTypeOnly === true
4363
4583
  // 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)
4584
+ // no single named export to re-provision under one local name. A
4585
+ // type-only default/namespace import has the same problem, so it
4586
+ // goes to `localTypes` rather than `importedTypes`.
4587
+ if (stmt.importClause?.name) (wholeTypeOnly ? localTypes : local).add(stmt.importClause.name.text)
4366
4588
  const namedBindings = stmt.importClause?.namedBindings
4367
4589
  if (namedBindings && ts.isNamedImports(namedBindings)) {
4368
4590
  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 })
4591
+ const entry = { source: src, exportedName: (el.propertyName ?? el.name).text }
4592
+ if (wholeTypeOnly || el.isTypeOnly) importedTypes.set(el.name.text, entry)
4593
+ else imported.set(el.name.text, entry)
4371
4594
  }
4372
4595
  }
4373
4596
  if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4374
- local.add(namedBindings.name.text)
4597
+ (wholeTypeOnly ? localTypes : local).add(namedBindings.name.text)
4375
4598
  }
4376
4599
  }
4377
4600
  }
4378
- return { local, imported }
4601
+ return { local, imported, localTypes, importedTypes }
4379
4602
  }
4380
4603
 
4381
4604
  /**
4382
4605
  * 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
4606
+ * its own module scope (#2332, type positions added #2350): `captured` are
4607
+ * unconditional BF112 hits (helper-local bindings — the body would dangle
4608
+ * if inlined verbatim, value or type alike); `importedRefs`/
4609
+ * `importedTypeRefs` are references to the helper file's own named value/
4610
+ * type imports, which the caller may re-provision into the component file
4387
4611
  * instead of declining (§3.4 in the #2332 spec).
4388
4612
  */
4389
4613
  interface ModuleCaptureResult {
@@ -4391,23 +4615,27 @@ interface ModuleCaptureResult {
4391
4615
  captured: string[]
4392
4616
  /** Free refs resolving to the helper's own named value imports, sorted by localName. */
4393
4617
  importedRefs: Array<{ localName: string; source: string; exportedName: string }>
4618
+ /** Free type-position refs resolving to the helper's own named type imports, sorted by localName. */
4619
+ importedTypeRefs: Array<{ localName: string; source: string; exportedName: string }>
4394
4620
  }
4395
4621
 
4396
4622
  /**
4397
4623
  * 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.
4624
+ * its own module scope (#2325 §4h / BF112, #2332, type positions #2350)
4625
+ * references the inlined body would silently lose once spliced into the
4626
+ * component file, unless re-provisioned as an import. Returns `captured`
4627
+ * (unconditional BF112) and `importedRefs`/`importedTypeRefs`
4628
+ * (re-provisionable) separately, each sorted for stable diagnostic/
4629
+ * injection text.
4403
4630
  *
4404
4631
  * Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
4405
4632
  * tracks arrow-function parameters, not nested `function` declarations'
4406
4633
  * parameters or nested-block declarations — a body-nested binding that
4407
4634
  * 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.
4635
+ * BF112 or `importedRefs`/`importedTypeRefs`. Acceptable: the failure
4636
+ * direction is always a loud build error (BF112/BF113) or a
4637
+ * redundant-but-harmless injected import, never a silent dangling
4638
+ * reference.
4411
4639
  */
4412
4640
  function moduleCaptureCheck(
4413
4641
  fn: ts.FunctionDeclaration,
@@ -4415,16 +4643,24 @@ function moduleCaptureCheck(
4415
4643
  moduleBindings: HelperModuleBindings,
4416
4644
  selfName: string
4417
4645
  ): ModuleCaptureResult {
4418
- if (!fn.body) return { captured: [], importedRefs: [] }
4646
+ if (!fn.body) return { captured: [], importedRefs: [], importedTypeRefs: [] }
4419
4647
  const free = extractFreeIdentifiersFromNode(fn.body)
4648
+ const freeTypes = extractFreeTypeIdentifiersFromNode(fn.body)
4420
4649
  const exclude = new Set<string>(info.params)
4421
4650
  for (const b of info.localBindings) exclude.add(b)
4422
4651
  for (const r of info.returnTupleIdentifiers) exclude.add(r)
4423
4652
  for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
4424
4653
  exclude.add(selfName)
4654
+ // extractFreeTypeIdentifiersFromNode only tracks type parameters declared
4655
+ // BY nodes it walks — it never sees `fn` itself (only `fn.body`), so the
4656
+ // factory's own `<T>` list needs excluding here instead (Copilot review,
4657
+ // PR #2351: `function useThing<Item>(...)` shadowing a module-scope
4658
+ // `Item` type import).
4659
+ if (fn.typeParameters) for (const p of fn.typeParameters) exclude.add(p.name.text)
4425
4660
 
4426
4661
  const captured: string[] = []
4427
4662
  const importedRefs: ModuleCaptureResult['importedRefs'] = []
4663
+ const importedTypeRefs: ModuleCaptureResult['importedTypeRefs'] = []
4428
4664
  for (const id of free) {
4429
4665
  if (exclude.has(id)) continue
4430
4666
  if (moduleBindings.local.has(id)) {
@@ -4434,9 +4670,29 @@ function moduleCaptureCheck(
4434
4670
  const imp = moduleBindings.imported.get(id)
4435
4671
  if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
4436
4672
  }
4673
+ for (const id of freeTypes) {
4674
+ if (exclude.has(id)) continue
4675
+ if (free.has(id)) continue // already resolved above — same name, value position wins
4676
+ if (moduleBindings.localTypes.has(id) || moduleBindings.local.has(id)) {
4677
+ captured.push(id)
4678
+ continue
4679
+ }
4680
+ const typeImp = moduleBindings.importedTypes.get(id)
4681
+ if (typeImp) {
4682
+ importedTypeRefs.push({ localName: id, source: typeImp.source, exportedName: typeImp.exportedName })
4683
+ continue
4684
+ }
4685
+ // A name used ONLY in type position can still resolve to the helper's
4686
+ // own VALUE import (e.g. a class referenced purely as `(): Foo`) —
4687
+ // re-provision it as a normal value import, which brings the type into
4688
+ // scope too, instead of missing it entirely (Copilot review, PR #2351).
4689
+ const valueImp = moduleBindings.imported.get(id)
4690
+ if (valueImp) importedRefs.push({ localName: id, source: valueImp.source, exportedName: valueImp.exportedName })
4691
+ }
4437
4692
  captured.sort()
4438
4693
  importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4439
- return { captured, importedRefs }
4694
+ importedTypeRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
4695
+ return { captured, importedRefs, importedTypeRefs }
4440
4696
  }
4441
4697
 
4442
4698
  /**
@@ -4478,6 +4734,27 @@ function detectReactiveFactory(
4478
4734
 
4479
4735
  const loc = getSourceLocation(node, sourceFile, filePath)
4480
4736
 
4737
+ // Count `return`s ANYWHERE in the body, stopping at nested function-like
4738
+ // boundaries (ts.isFunctionLike — functions, arrows, methods, accessors,
4739
+ // and constructors; broader than isMultiReturnJsxFunctionBody's #932
4740
+ // three-kind check, which only needs to catch JSX-returning callbacks and
4741
+ // was never exercised against class/object methods) — a return inside a
4742
+ // nested callback OR a class/object method declared in the factory body
4743
+ // belongs to that inner scope, not this factory (Copilot review, PR
4744
+ // #2342: the narrower check would misclassify a factory with, e.g., a
4745
+ // helper class whose method contains a `return` as having multiple
4746
+ // returns, declining it unnecessarily). A return nested in if/try/loop
4747
+ // blocks DOES count, so guard-clause factories are declassified instead
4748
+ // of splicing an early `return` into the component's init function
4749
+ // (#2341 BUG-3).
4750
+ let totalReturnCount = 0
4751
+ function countReturns(n: ts.Node): void {
4752
+ if (ts.isFunctionLike(n)) return
4753
+ if (ts.isReturnStatement(n)) { totalReturnCount++; return }
4754
+ ts.forEachChild(n, countReturns)
4755
+ }
4756
+ ts.forEachChild(node.body, countReturns)
4757
+
4481
4758
  // Require exactly one top-level `return`, whose argument (after unwrapping
4482
4759
  // parens / `as const` / type-assertion) is a tuple (array literal) or a
4483
4760
  // shorthand-object literal.
@@ -4494,7 +4771,12 @@ function detectReactiveFactory(
4494
4771
  if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
4495
4772
  returnExpr = expr
4496
4773
  }
4497
- if (returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
4774
+ // `totalReturnCount === 1 && returnCount === 1` jointly guarantee the
4775
+ // single return is a direct child of `node.body` (top-level returns are a
4776
+ // subset of total) — so a guard-clause / try-catch / loop return
4777
+ // declassifies the factory instead of silently producing an inert
4778
+ // component (#2341 BUG-3).
4779
+ if (totalReturnCount !== 1 || returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
4498
4780
 
4499
4781
  const returnTupleIdentifiers: string[] = []
4500
4782
  let returnKind: 'tuple' | 'object'
@@ -4530,6 +4812,22 @@ function detectReactiveFactory(
4530
4812
  return { kind: 'reactive-shaped' }
4531
4813
  }
4532
4814
 
4815
+ // Collect parameter names first — the rename-site walk below needs them
4816
+ // as part of `relevantNames` and to detect param-shadowing declarations
4817
+ // (BF114). Hoisted above local-binding/body-serialization collection
4818
+ // (#2341 BUG-1); it has no dependency on either.
4819
+ const params: string[] = []
4820
+ for (const p of node.parameters) {
4821
+ if (ts.isIdentifier(p.name)) {
4822
+ params.push(p.name.text)
4823
+ continue
4824
+ }
4825
+ // Destructured params are uncommon for this helper shape and out of
4826
+ // initial scope; the factory still wraps a reactive primitive, so
4827
+ // classify it as reactive-shaped rather than silently ignoring it.
4828
+ return { kind: 'reactive-shaped' }
4829
+ }
4830
+
4533
4831
  // Collect local bindings in the factory body for identifier hygiene at
4534
4832
  // inlining time. Only direct-child declarations of the block are
4535
4833
  // considered — good enough for the typical helper shape.
@@ -4544,24 +4842,146 @@ function detectReactiveFactory(
4544
4842
  }
4545
4843
  }
4546
4844
 
4845
+ // Names that call-site inlining may rename: params (substituted with
4846
+ // arbitrary argument expressions), local bindings (suffix-renamed for
4847
+ // per-call-site hygiene), and return identifiers (renamed to the
4848
+ // caller's destructure names). Every other identifier in the body is
4849
+ // left untouched — the #2341 BUG-1 fix for the old whole-body regex
4850
+ // renames, which corrupted string/template literals, property keys,
4851
+ // and `.prop` tails that merely happened to share a relevant name.
4852
+ const relevantNames = new Set<string>([...params, ...localBindings, ...returnTupleIdentifiers])
4853
+
4854
+ const renameSites: FactoryRenameSite[] = []
4855
+ let shadowedParam: string | null = null
4856
+
4857
+ /**
4858
+ * Recursive AST walk collecting rename sites for one kept statement.
4859
+ * `toBodyOffset` converts a position in `sourceFile` (this statement's
4860
+ * original source) to an offset in the final joined `bodyStatements`
4861
+ * string — see the join loop below for why this must stay a pure
4862
+ * function of `stmtStart`/`base`.
4863
+ */
4864
+ function collectRenameSites(root: ts.Node, toBodyOffset: (pos: number) => number): void {
4865
+ function push(id: ts.Identifier, form: 'plain' | 'shorthand'): void {
4866
+ renameSites.push({
4867
+ name: id.text,
4868
+ start: toBodyOffset(id.getStart(sourceFile)),
4869
+ end: toBodyOffset(id.getEnd()),
4870
+ form,
4871
+ })
4872
+ }
4873
+
4874
+ function classify(id: ts.Identifier): void {
4875
+ if (!relevantNames.has(id.text)) return
4876
+ const p = id.parent
4877
+ // Pure-key / non-reference positions — never rename:
4878
+ if (ts.isPropertyAccessExpression(p) && p.name === id) return // obj.name tail (incl. ?. chains)
4879
+ if (ts.isPropertyAssignment(p) && p.name === id) return // { name: v } key
4880
+ if (ts.isBindingElement(p) && p.propertyName === id) return // { name: local } pattern key
4881
+ if ((ts.isMethodDeclaration(p) || ts.isGetAccessorDeclaration(p) ||
4882
+ ts.isSetAccessorDeclaration(p) || ts.isPropertyDeclaration(p) ||
4883
+ ts.isEnumMember(p)) && p.name === id) return // member keys
4884
+ if (ts.isJsxAttribute(p) && p.name === id) return // JSX attr name
4885
+ if ((ts.isLabeledStatement(p) && p.label === id) ||
4886
+ ((ts.isBreakStatement(p) || ts.isContinueStatement(p)) && p.label === id)) return
4887
+ if ((ts.isJsxOpeningElement(p) || ts.isJsxSelfClosingElement(p) || ts.isJsxClosingElement(p)) &&
4888
+ p.tagName === id && /^[a-z]/.test(id.text)) return // intrinsic tag <div>
4889
+
4890
+ // Shorthand dual-role positions → expansion form (renaming must
4891
+ // preserve the implied key): `{ name }` object literal / pattern.
4892
+ if (ts.isShorthandPropertyAssignment(p) && p.name === id) { push(id, 'shorthand'); return }
4893
+ if (ts.isBindingElement(p) && p.name === id && !p.propertyName &&
4894
+ ts.isObjectBindingPattern(p.parent)) {
4895
+ // ArrayBindingPattern trap: a tuple destructure element
4896
+ // (`const [a, b] = ...`) ALSO has propertyName === undefined —
4897
+ // the ts.isObjectBindingPattern(p.parent) check above is what
4898
+ // keeps tuple elements out of the shorthand-expansion path.
4899
+ if (params.includes(id.text)) shadowedParam = id.text // decl of a param name → BF114
4900
+ push(id, 'shorthand')
4901
+ return
4902
+ }
4903
+
4904
+ // Declaration-name positions → plain rename, but a param name
4905
+ // re-declared here is a shadow: params are substituted with
4906
+ // arbitrary argument expressions, so a shadowing declaration would
4907
+ // receive an invalid left-hand side (BF114 declines instead).
4908
+ const isDecl =
4909
+ (ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p) ||
4910
+ ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) ||
4911
+ ts.isClassDeclaration(p) || ts.isClassExpression(p)) && p.name === id
4912
+ if (isDecl && params.includes(id.text)) shadowedParam = id.text
4913
+
4914
+ push(id, 'plain') // genuine value reference or decl
4915
+ }
4916
+
4917
+ function visit(n: ts.Node): void {
4918
+ // Never descend into type-land: annotations are stripped downstream,
4919
+ // and splicing an argument EXPRESSION into a type position would be
4920
+ // invalid JS.
4921
+ if (ts.isTypeNode(n) || ts.isTypeParameterDeclaration(n) ||
4922
+ ts.isTypeAliasDeclaration(n) || ts.isInterfaceDeclaration(n)) return
4923
+ if (ts.isIdentifier(n)) { classify(n); return } // identifiers have no relevant children
4924
+ ts.forEachChild(n, visit)
4925
+ }
4926
+
4927
+ visit(root)
4928
+ }
4929
+
4547
4930
  // Serialize the body without the outer braces and without the return
4548
4931
  // statement — the return tuple/object is dissolved into caller-named
4549
- // identifiers.
4550
- const bodyStatements = node.body.statements
4551
- .filter(s => !ts.isReturnStatement(s))
4552
- .map(s => s.getText(sourceFile))
4553
- .join('\n')
4932
+ // identifiers. `renameSites` is collected in this SAME loop so its
4933
+ // offsets (relative to the joined `bodyStatements` string) can never
4934
+ // drift from the join logic — this invariant is the single most fragile
4935
+ // part of the rename mechanism (#2341 BUG-1): site collection and
4936
+ // `bodyStatements` construction must use the identical statement filter,
4937
+ // identical `getText` slices, and identical '\n' join.
4938
+ const keptStatements = node.body.statements.filter(s => !ts.isReturnStatement(s))
4939
+ const pieces: string[] = []
4940
+ let base = 0
4941
+ for (const stmt of keptStatements) {
4942
+ const text = stmt.getText(sourceFile)
4943
+ const stmtStart = stmt.getStart(sourceFile)
4944
+ collectRenameSites(stmt, (pos) => pos - stmtStart + base)
4945
+ pieces.push(text)
4946
+ base += text.length + 1 // +1 for the '\n' join — MUST match the join below
4947
+ }
4948
+ const bodyStatements = pieces.join('\n')
4949
+
4950
+ if (shadowedParam !== null) {
4951
+ return {
4952
+ kind: 'declined',
4953
+ declined: {
4954
+ code: 'BF114',
4955
+ detail: `parameter '${shadowedParam}' of '${node.name.text}' is shadowed by a nested declaration inside the factory body`,
4956
+ loc,
4957
+ },
4958
+ }
4959
+ }
4554
4960
 
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
4961
+ // Dev invariant: every collected site's [start, end) range must slice
4962
+ // out exactly the identifier text it was collected for, or the
4963
+ // bottom-to-top splice in inlineFactoryCallAtSite would corrupt
4964
+ // unrelated text. Cheap (bounded by body size) — kept as a permanent
4965
+ // guard against the offset math drifting under a future edit. Declines
4966
+ // rather than throwing (Copilot review, PR #2342): compileJSX does not
4967
+ // catch analyzer errors, so a thrown exception here would crash the
4968
+ // whole compilation instead of failing one factory loudly-but-
4969
+ // gracefully as a diagnostic — this should never trigger in practice,
4970
+ // but the failure mode if it ever does must stay "loud decline," not
4971
+ // "hard crash," matching this feature's whole design philosophy.
4972
+ for (const site of renameSites) {
4973
+ if (bodyStatements.slice(site.start, site.end) !== site.name) {
4974
+ return {
4975
+ kind: 'declined',
4976
+ declined: {
4977
+ code: 'BF111',
4978
+ detail:
4979
+ `internal rename-site offset mismatch for '${site.name}' — this is a compiler bug, ` +
4980
+ `please report it`,
4981
+ loc,
4982
+ },
4983
+ }
4560
4984
  }
4561
- // Destructured params are uncommon for this helper shape and out of
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
4985
  }
4566
4986
 
4567
4987
  return {
@@ -4573,6 +4993,7 @@ function detectReactiveFactory(
4573
4993
  returnKind,
4574
4994
  localBindings,
4575
4995
  loc,
4996
+ renameSites,
4576
4997
  },
4577
4998
  }
4578
4999
  }
@@ -4741,30 +5162,49 @@ function rewriteFactoryCallsInSource(
4741
5162
  const thisCallIndex = callSiteIndex++
4742
5163
  const suffix = `_bf${thisCallIndex}`
4743
5164
 
4744
- // Apply renames to the factory body source.
4745
- let body = factory.bodySource
4746
- // 1. Suffix-rename internal bindings.
4747
- const internalRenames = new Set<string>(factory.localBindings)
4748
- for (const ex of excludeFromSuffixRename) internalRenames.delete(ex)
4749
- for (const name of internalRenames) {
4750
- body = body.replace(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), name + suffix)
5165
+ // Merged rename map, one entry per old name → replacement text.
5166
+ // Precedence (matches the pre-#2341 sequential-pass outcome): param
5167
+ // substitution > return→caller rename > internal suffix rename — later
5168
+ // `.set()` calls for the same key overwrite earlier ones below.
5169
+ const renames = new Map<string, string>()
5170
+ // 1. Suffix-rename internal bindings not excluded (tuple params/returns,
5171
+ // or object-path destructured names see excludeFromSuffixRename's
5172
+ // callers).
5173
+ for (const name of factory.localBindings) {
5174
+ if (!excludeFromSuffixRename.has(name)) renames.set(name, name + suffix)
5175
+ }
5176
+ // 2. Return identifiers → caller destructure names (tuple path only).
5177
+ if (renameReturnToCallerNames) {
5178
+ for (const [n, caller] of renameReturnToCallerNames) {
5179
+ if (caller !== n) renames.set(n, caller) // identity rename = no-op, skip
5180
+ }
4751
5181
  }
4752
- // 2. Parameters → argument expressions. Atomic arguments (bare
5182
+ // 3. Parameters → argument expressions. Atomic arguments (bare
4753
5183
  // identifiers, numeric literals, string literals) are spliced
4754
5184
  // directly; anything more complex is wrapped in parens to preserve
4755
- // operator precedence at the splice site.
5185
+ // operator precedence at the splice site. Overwrites any same-name
5186
+ // return rename above — param substitution wins, as before #2341.
4756
5187
  const atomicArg = /^(?:[\w$.]+|'[^'\\]*'|"[^"\\]*"|-?\d+(?:\.\d+)?)$/
4757
5188
  for (let i = 0; i < factory.params.length; i++) {
4758
5189
  const p = factory.params[i]
4759
- const a = argTexts[i] ?? 'undefined'
4760
- const wrapped = atomicArg.test(a.trim()) ? a.trim() : `(${a})`
4761
- body = body.replace(new RegExp(`\\b${escapeRegex(p)}\\b`, 'g'), wrapped)
5190
+ const a = (argTexts[i] ?? 'undefined').trim()
5191
+ renames.set(p, atomicArg.test(a) ? a : `(${a})`)
4762
5192
  }
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
- }
5193
+
5194
+ // Apply the merged renames as a single bottom-to-top position splice
5195
+ // over `bodySource`, using the sites collected once at detection time
5196
+ // (#2341 BUG-1) — never a text search, so string/template-literal
5197
+ // contents, property keys, `.prop` tails, and JSX intrinsic tags are
5198
+ // never touched, and (unlike the old sequential regex passes) an
5199
+ // argument expression already spliced in for an earlier site can never
5200
+ // be re-scanned and corrupted by a later rename.
5201
+ let body = factory.bodySource
5202
+ for (let i = factory.renameSites.length - 1; i >= 0; i--) {
5203
+ const site = factory.renameSites[i]
5204
+ const repl = renames.get(site.name)
5205
+ if (repl === undefined) continue
5206
+ const text = site.form === 'shorthand' ? `${site.name}: ${repl}` : repl
5207
+ body = body.slice(0, site.start) + text + body.slice(site.end)
4768
5208
  }
4769
5209
 
4770
5210
  edits.push({
@@ -4780,33 +5220,61 @@ function rewriteFactoryCallsInSource(
4780
5220
  if (edits.length === 0) return source
4781
5221
 
4782
5222
  // #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
5223
+ // cross-file factory's re-provisioned imports (type-only refs #2350, kept
5224
+ // on separate `import type { ... }` lines since a value and a type
5225
+ // import can't share one specifier list). Injected as a zero-width edit
5226
+ // so the ordinary bottom-to-top splice below applies it; the result is
5227
+ // indistinguishable from a hand-written import for every downstream
4786
5228
  // consumer (ctx.imports → SSR templateImports AND client
4787
5229
  // collectExternalImports both parse this same rewritten string).
4788
5230
  const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
5231
+ const typeImportsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
4789
5232
  for (const f of inlinedFactories) {
4790
5233
  for (const r of f.requiredImports ?? []) {
4791
- let names = importsBySpecifier.get(r.specifier)
4792
- if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
5234
+ const bySpecifier = r.isTypeOnly ? typeImportsBySpecifier : importsBySpecifier
5235
+ let names = bySpecifier.get(r.specifier)
5236
+ if (!names) { names = new Map(); bySpecifier.set(r.specifier, names) }
4793
5237
  names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
4794
5238
  }
4795
5239
  }
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
- })
5240
+ // Two different factories can independently need the same (specifier,
5241
+ // localName) one only in value position, one only in type position
5242
+ // (#2350). The value import already brings the type into scope, so drop
5243
+ // the now-redundant type-only line rather than emit both (which TypeScript
5244
+ // would reject as a duplicate identifier).
5245
+ for (const [spec, typeNames] of typeImportsBySpecifier) {
5246
+ const valueNames = importsBySpecifier.get(spec)
5247
+ if (!valueNames) continue
5248
+ for (const local of [...typeNames.keys()]) {
5249
+ if (valueNames.has(local)) typeNames.delete(local)
5250
+ }
5251
+ if (typeNames.size === 0) typeImportsBySpecifier.delete(spec)
5252
+ }
5253
+ if (importsBySpecifier.size > 0 || typeImportsBySpecifier.size > 0) {
5254
+ // One sorted pass over the UNION of specifiers (value + type-only), not
5255
+ // two separately-sorted lists — the latter would leave the value/
5256
+ // type-only halves each internally sorted but not globally sorted
5257
+ // against each other (a type-only import from 'a' could land after a
5258
+ // value import from 'b'), an unstable-looking order across unrelated
5259
+ // refactors (Copilot review, PR #2351). Within each specifier, named-
5260
+ // import entries are sorted by local name too — `importsBySpecifier`/
5261
+ // `inlinedFactories` iterate in incidental AST-traversal/insertion
5262
+ // order (Copilot review, PR #2338).
5263
+ const buildLine = (names: Map<string, string>, keyword: string, spec: string) => {
5264
+ const specifiers = [...names]
5265
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
5266
+ .map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
5267
+ return `import ${keyword}{ ${specifiers.join(', ')} } from '${spec}'`
5268
+ }
5269
+ const allSpecifiers = new Set([...importsBySpecifier.keys(), ...typeImportsBySpecifier.keys()])
5270
+ const lines = [...allSpecifiers].sort().flatMap((spec) => {
5271
+ const out: string[] = []
5272
+ const valueNames = importsBySpecifier.get(spec)
5273
+ if (valueNames) out.push(buildLine(valueNames, '', spec))
5274
+ const typeNames = typeImportsBySpecifier.get(spec)
5275
+ if (typeNames) out.push(buildLine(typeNames, 'type ', spec))
5276
+ return out
5277
+ })
4810
5278
  const at = factoryImportInsertionOffset(sourceFile)
4811
5279
  edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
4812
5280
  }
@@ -4853,10 +5321,6 @@ function isPascalCaseComponentFn(node: ts.Node): boolean {
4853
5321
  return false
4854
5322
  }
4855
5323
 
4856
- function escapeRegex(s: string): string {
4857
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
4858
- }
4859
-
4860
5324
  // =============================================================================
4861
5325
  // BF110 diagnostic (#931)
4862
5326
  // =============================================================================
@@ -4895,6 +5359,7 @@ function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorC
4895
5359
  switch (code) {
4896
5360
  case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4897
5361
  case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
5362
+ case 'BF114': return ErrorCodes.REACTIVE_FACTORY_PARAM_SHADOWED
4898
5363
  default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
4899
5364
  }
4900
5365
  }