@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/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +225 -55
- package/dist/types.d.ts +21 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/reactive-factory-cross-file.test.ts +582 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
- package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
- package/src/analyzer.ts +385 -84
- package/src/errors.ts +4 -0
- package/src/types.ts +22 -1
package/src/analyzer.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import ts from 'typescript'
|
|
10
|
-
import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, 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`.
|
|
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
|
|
4061
|
-
*
|
|
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
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
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(
|
|
4181
|
+
content = fs.readFileSync(abs, 'utf8')
|
|
4141
4182
|
} catch {
|
|
4142
|
-
|
|
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
|
|
4150
|
-
// define a reactive factory
|
|
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
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
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
|
-
|
|
4192
|
-
|
|
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(
|
|
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
|
|
4207
|
-
if (
|
|
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
|
-
|
|
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
|
|
4251
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
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
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
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
|
-
//
|
|
4745
|
-
|
|
4746
|
-
//
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
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
|
// =============================================================================
|