@barefootjs/jsx 0.24.1 → 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, 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'
@@ -4047,18 +4047,56 @@ function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
4047
4047
  return names
4048
4048
  }
4049
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
+
4050
4081
  /**
4051
4082
  * Cross-file half of the factory prescan (#2325 round 2): resolve factories
4052
4083
  * defined in a relative-imported helper file so `const { count } =
4053
4084
  * createCounter(0)` inlines the same way whether `createCounter` lives in
4054
- * 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.
4055
4092
  *
4056
4093
  * Perf: gated on a candidate-callee set collected from the ALREADY-parsed
4057
4094
  * entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
4058
4095
  * imports with regex" rule) — files with no tuple/object-destructured call
4059
4096
  * 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
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
4062
4100
  * skip-gate over content, not an import parse, so it doesn't run afoul of
4063
4101
  * that same rule.
4064
4102
  *
@@ -4129,45 +4167,42 @@ function prescanImportedReactiveFactories(
4129
4167
  // same name from a DIFFERENT (targetKey, exportedName) is a collision.
4130
4168
  const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
4131
4169
 
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
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
4137
4178
 
4138
4179
  let content: string
4139
4180
  try {
4140
- content = fs.readFileSync(resolved, 'utf8')
4181
+ content = fs.readFileSync(abs, 'utf8')
4141
4182
  } catch {
4142
- continue
4183
+ helperCache.set(abs, null)
4184
+ return null
4143
4185
  }
4144
4186
 
4145
- const alreadyKnown = (name: string): boolean =>
4146
- result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4147
-
4148
4187
  // 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.
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'.
4151
4193
  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
4194
+ const hasReexportText = content.includes('export') && content.includes('from')
4195
+ if (!hasAnyPrimitiveText && !hasReexportText) {
4196
+ helperCache.set(abs, 'clean')
4197
+ return 'clean'
4157
4198
  }
4158
4199
 
4159
- const helperSf = ts.createSourceFile(
4160
- resolved + '.prescan',
4161
- content,
4162
- ts.ScriptTarget.Latest,
4163
- true,
4164
- ts.ScriptKind.TSX
4165
- )
4200
+ const sf = ts.createSourceFile(abs + '.prescan', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
4166
4201
 
4167
4202
  // Map exported name -> module-scope FunctionDeclaration via the AST.
4168
4203
  const localFns = new Map<string, ts.FunctionDeclaration>()
4169
4204
  const exportedFns = new Map<string, ts.FunctionDeclaration>()
4170
- for (const stmt of helperSf.statements) {
4205
+ for (const stmt of sf.statements) {
4171
4206
  if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
4172
4207
  localFns.set(stmt.name.text, stmt)
4173
4208
  const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
@@ -4177,20 +4212,37 @@ function prescanImportedReactiveFactories(
4177
4212
  }
4178
4213
  }
4179
4214
  }
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
- ) {
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`.
4189
4234
  for (const el of stmt.exportClause.elements) {
4190
4235
  if (el.isTypeOnly) continue
4191
- const fn = localFns.get((el.propertyName ?? el.name).text)
4192
- 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
+ })
4193
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
4194
4246
  }
4195
4247
  }
4196
4248
 
@@ -4198,17 +4250,77 @@ function prescanImportedReactiveFactories(
4198
4250
  // body must not reference any of these (#2325 §4h / BF112), since
4199
4251
  // inlining moves the body into the component file where they don't
4200
4252
  // exist.
4201
- 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)
4202
4308
 
4203
4309
  for (const spec of specs) {
4204
4310
  if (alreadyKnown(spec.local)) continue
4205
4311
 
4206
- const fn = exportedFns.get(spec.exported)
4207
- if (!fn) {
4312
+ const found = lookupExportedFactory(resolved, spec.exported, new Set<string>(), MAX_REEXPORT_HOPS)
4313
+ if (found.kind === 'clean') {
4208
4314
  result.cleanFactoryImports.add(spec.local)
4209
4315
  continue
4210
4316
  }
4211
- 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)
4212
4324
  if (!det) {
4213
4325
  result.cleanFactoryImports.add(spec.local)
4214
4326
  continue
@@ -4221,6 +4333,9 @@ function prescanImportedReactiveFactories(
4221
4333
  result.declined.set(spec.local, det.declined)
4222
4334
  break
4223
4335
  case 'factory': {
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.
4224
4339
  const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4225
4340
  if (capture.captured.length > 0) {
4226
4341
  result.declined.set(spec.local, {
@@ -4247,10 +4362,12 @@ function prescanImportedReactiveFactories(
4247
4362
  let specifier: string
4248
4363
  let targetKey: string
4249
4364
  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
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
4252
4369
  // capture: nothing importable to re-provision (BF112).
4253
- const abs = resolveRelativeImportToFile(ref.source, resolved)
4370
+ const abs = resolveRelativeImportToFile(ref.source, definingPath)
4254
4371
  if (!abs) {
4255
4372
  declinedEntry = {
4256
4373
  code: 'BF112',
@@ -4292,7 +4409,10 @@ function prescanImportedReactiveFactories(
4292
4409
  break
4293
4410
  }
4294
4411
  for (const [name, id] of pending) plannedInjections.set(name, id)
4295
- det.info.sourceFilePath = resolved
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
4296
4416
  if (required.length > 0) det.info.requiredImports = required
4297
4417
  result.factories.set(spec.local, det.info)
4298
4418
  break
@@ -4478,6 +4598,27 @@ function detectReactiveFactory(
4478
4598
 
4479
4599
  const loc = getSourceLocation(node, sourceFile, filePath)
4480
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
+
4481
4622
  // Require exactly one top-level `return`, whose argument (after unwrapping
4482
4623
  // parens / `as const` / type-assertion) is a tuple (array literal) or a
4483
4624
  // shorthand-object literal.
@@ -4494,7 +4635,12 @@ function detectReactiveFactory(
4494
4635
  if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
4495
4636
  returnExpr = expr
4496
4637
  }
4497
- 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' }
4498
4644
 
4499
4645
  const returnTupleIdentifiers: string[] = []
4500
4646
  let returnKind: 'tuple' | 'object'
@@ -4530,6 +4676,22 @@ function detectReactiveFactory(
4530
4676
  return { kind: 'reactive-shaped' }
4531
4677
  }
4532
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
+
4533
4695
  // Collect local bindings in the factory body for identifier hygiene at
4534
4696
  // inlining time. Only direct-child declarations of the block are
4535
4697
  // considered — good enough for the typical helper shape.
@@ -4544,24 +4706,146 @@ function detectReactiveFactory(
4544
4706
  }
4545
4707
  }
4546
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
+
4547
4794
  // Serialize the body without the outer braces and without the return
4548
4795
  // 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')
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
+ }
4554
4824
 
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
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
+ }
4560
4848
  }
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
4849
  }
4566
4850
 
4567
4851
  return {
@@ -4573,6 +4857,7 @@ function detectReactiveFactory(
4573
4857
  returnKind,
4574
4858
  localBindings,
4575
4859
  loc,
4860
+ renameSites,
4576
4861
  },
4577
4862
  }
4578
4863
  }
@@ -4741,30 +5026,49 @@ function rewriteFactoryCallsInSource(
4741
5026
  const thisCallIndex = callSiteIndex++
4742
5027
  const suffix = `_bf${thisCallIndex}`
4743
5028
 
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)
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
+ }
4751
5045
  }
4752
- // 2. Parameters → argument expressions. Atomic arguments (bare
5046
+ // 3. Parameters → argument expressions. Atomic arguments (bare
4753
5047
  // identifiers, numeric literals, string literals) are spliced
4754
5048
  // directly; anything more complex is wrapped in parens to preserve
4755
- // 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.
4756
5051
  const atomicArg = /^(?:[\w$.]+|'[^'\\]*'|"[^"\\]*"|-?\d+(?:\.\d+)?)$/
4757
5052
  for (let i = 0; i < factory.params.length; i++) {
4758
5053
  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)
5054
+ const a = (argTexts[i] ?? 'undefined').trim()
5055
+ renames.set(p, atomicArg.test(a) ? a : `(${a})`)
4762
5056
  }
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
- }
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)
4768
5072
  }
4769
5073
 
4770
5074
  edits.push({
@@ -4853,10 +5157,6 @@ function isPascalCaseComponentFn(node: ts.Node): boolean {
4853
5157
  return false
4854
5158
  }
4855
5159
 
4856
- function escapeRegex(s: string): string {
4857
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
4858
- }
4859
-
4860
5160
  // =============================================================================
4861
5161
  // BF110 diagnostic (#931)
4862
5162
  // =============================================================================
@@ -4895,6 +5195,7 @@ function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorC
4895
5195
  switch (code) {
4896
5196
  case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4897
5197
  case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
5198
+ case 'BF114': return ErrorCodes.REACTIVE_FACTORY_PARAM_SHADOWED
4898
5199
  default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
4899
5200
  }
4900
5201
  }
package/src/errors.ts CHANGED
@@ -90,6 +90,7 @@ export const ErrorCodes = {
90
90
  REACTIVE_FACTORY_RENAME_UNSUPPORTED: 'BF111',
91
91
  REACTIVE_FACTORY_MODULE_CAPTURE: 'BF112',
92
92
  REACTIVE_FACTORY_IMPORT_COLLISION: 'BF113',
93
+ REACTIVE_FACTORY_PARAM_SHADOWED: 'BF114',
93
94
  } as const
94
95
 
95
96
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
@@ -184,6 +185,9 @@ const errorMessages: Record<ErrorCode, string> = {
184
185
  'Inlining an imported reactive factory requires re-importing one of its helper ' +
185
186
  'imports into this file, but that name is already bound here to something else. ' +
186
187
  "Rename the conflicting binding in this file, or alias the import in the factory's own file.",
188
+
189
+ [ErrorCodes.REACTIVE_FACTORY_PARAM_SHADOWED]:
190
+ 'Reactive factory parameter is shadowed by a nested declaration inside the factory body, so argument substitution at the inline site would be ambiguous. Rename the inner binding so it does not collide with the parameter.',
187
191
  }
188
192
 
189
193
  // =============================================================================