@barefootjs/jsx 0.21.3 → 0.23.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/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +2 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +27 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +706 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +72 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +23 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +109 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +239 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +181 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +712 -91
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +13 -0
  38. package/src/format-date-lowering.ts +51 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +80 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +79 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +175 -0
  46. package/src/types.ts +24 -1
package/src/analyzer.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import ts from 'typescript'
10
- import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo } from './types.ts'
10
+ import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, 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'
@@ -232,6 +232,15 @@ export function analyzeComponent(
232
232
  const ctx = createAnalyzerContext(sourceFile, filePath)
233
233
  ctx.checker = checker
234
234
 
235
+ // Reactive-factory prescan results (#931, #2325 cross-file + object-
236
+ // return round 2) — same-file and relative-imported factories, plus the
237
+ // declined / reactive-shaped buckets `validateReactiveFactoryCalls` uses
238
+ // to emit a specific diagnostic instead of the generic BF110.
239
+ ctx.reactiveFactories = prescan.factories
240
+ ctx.declinedReactiveFactories = prescan.declined
241
+ ctx.reactiveShapedHelpers = prescan.reactiveShaped
242
+ ctx.cleanFactoryImports = prescan.cleanFactoryImports
243
+
235
244
  // BF050 — surface "no shared Program supplied for type-based reactivity
236
245
  // classification" as a diagnostic so callers can fail strict builds
237
246
  // rather than silently depending on the per-file Program fallback
@@ -1865,6 +1874,10 @@ const CLIENT_EXPORTS = new Set([
1865
1874
  // `searchParams`. Runs natively on the client; SSR adapters lower a
1866
1875
  // `queryHref(base, { … })` call to their query helper (go-template: `bf_query`).
1867
1876
  'queryHref',
1877
+ // Pure date formatter (#2324). Runs natively on the client; SSR adapters
1878
+ // lower a `formatDate(date, pattern, tz)` call to their `format_date`
1879
+ // helper (spec/template-helpers.md).
1880
+ 'formatDate',
1868
1881
  // Compile-away JSX built-ins (#1915) — importing them is what scopes the
1869
1882
  // compiler's `<Async>` / `<Region>` recognition; the import is elided on emit.
1870
1883
  'Async', 'Region',
@@ -3867,14 +3880,27 @@ export const REACTIVE_PRIMITIVES = new Set([
3867
3880
 
3868
3881
  interface PrescanResult {
3869
3882
  factories: Map<string, ReactiveFactoryInfo>
3883
+ /** Factories recognized but declined for inlining (#2325). */
3884
+ declined: Map<string, DeclinedReactiveFactory>
3885
+ /** Module-scope helpers whose body wraps a reactive primitive but whose
3886
+ * shape is not an inlinable factory (#2325). */
3887
+ reactiveShaped: Set<string>
3888
+ /** Imported destructured-callee names whose helper file was resolved,
3889
+ * read, and found reactive-free / factory-free (#2325, filled by
3890
+ * `prescanImportedReactiveFactories`). */
3891
+ cleanFactoryImports: Set<string>
3870
3892
  sourceFile: ts.SourceFile
3871
3893
  }
3872
3894
 
3873
3895
  /**
3874
3896
  * Scan a source string for module-level function declarations that match
3875
- * the reactive-factory shape (single `return [a, b, ...]` exit + at least
3876
- * one reactive primitive call in the body). Returns a map from factory
3877
- * name to its metadata, and the parsed source file for call-site rewriting.
3897
+ * the reactive-factory shape (single `return [a, b, ...]` / `return { a, b
3898
+ * }` exit + at least one reactive primitive call in the body). Returns a
3899
+ * map from factory name to its metadata, and the parsed source file for
3900
+ * call-site rewriting. Also resolves factories defined in a relative-
3901
+ * imported helper file (#2325, see `prescanImportedReactiveFactories`) —
3902
+ * every `analyzeComponent` caller gets cross-file resolution with no call-
3903
+ * site changes elsewhere.
3878
3904
  */
3879
3905
  function prescanReactiveFactoriesInSource(
3880
3906
  source: string,
@@ -3888,11 +3914,25 @@ function prescanReactiveFactoriesInSource(
3888
3914
  ts.ScriptKind.TSX
3889
3915
  )
3890
3916
  const factories = new Map<string, ReactiveFactoryInfo>()
3917
+ const declined = new Map<string, DeclinedReactiveFactory>()
3918
+ const reactiveShaped = new Set<string>()
3919
+ const cleanFactoryImports = new Set<string>()
3891
3920
 
3892
3921
  function visitTop(node: ts.Node): void {
3893
3922
  if (ts.isFunctionDeclaration(node) && node.name && node.body) {
3894
- const info = detectReactiveFactory(node, sourceFile, filePath)
3895
- if (info) factories.set(node.name.text, info)
3923
+ const det = detectReactiveFactory(node, sourceFile, filePath)
3924
+ if (!det) return
3925
+ switch (det.kind) {
3926
+ case 'factory':
3927
+ factories.set(node.name.text, det.info)
3928
+ break
3929
+ case 'declined':
3930
+ declined.set(node.name.text, det.declined)
3931
+ break
3932
+ case 'reactive-shaped':
3933
+ reactiveShaped.add(node.name.text)
3934
+ break
3935
+ }
3896
3936
  }
3897
3937
  // Not recursing into function bodies: factory helpers are at module
3898
3938
  // scope only for this round (issue #931 "In scope" §1).
@@ -3900,58 +3940,373 @@ function prescanReactiveFactoriesInSource(
3900
3940
 
3901
3941
  ts.forEachChild(sourceFile, visitTop)
3902
3942
 
3903
- return { factories, sourceFile }
3943
+ const result: PrescanResult = { factories, declined, reactiveShaped, cleanFactoryImports, sourceFile }
3944
+ prescanImportedReactiveFactories(sourceFile, filePath, result)
3945
+ return result
3904
3946
  }
3905
3947
 
3948
+ /**
3949
+ * Cross-file half of the factory prescan (#2325 round 2): resolve factories
3950
+ * defined in a relative-imported helper file so `const { count } =
3951
+ * createCounter(0)` inlines the same way whether `createCounter` lives in
3952
+ * this file or in `./hooks`. Mutates `result`'s maps in place.
3953
+ *
3954
+ * Perf: gated on a candidate-callee set collected from the ALREADY-parsed
3955
+ * entry AST (no regex over source text, per CONTRIBUTING.md's "never parse
3956
+ * imports with regex" rule) — files with no tuple/object-destructured call
3957
+ * at all skip every filesystem access below. A second cheap gate (does the
3958
+ * helper file's raw text contain any `REACTIVE_PRIMITIVES` substring) skips
3959
+ * the AST parse of helper files that plainly aren't reactive; this is a
3960
+ * skip-gate over content, not an import parse, so it doesn't run afoul of
3961
+ * that same rule.
3962
+ *
3963
+ * Name-collision precedence: a same-file factory/declined/reactive-shaped
3964
+ * entry always wins over a same-named cross-file import — every write
3965
+ * below is guarded on the name being unclaimed in all three buckets.
3966
+ */
3967
+ function prescanImportedReactiveFactories(
3968
+ entrySourceFile: ts.SourceFile,
3969
+ filePath: string,
3970
+ result: PrescanResult
3971
+ ): void {
3972
+ // 1. Candidate gate: names destructured (tuple or object) from a direct
3973
+ // call-expression initializer, anywhere in the file. Cheap AST walk,
3974
+ // no filesystem access.
3975
+ const candidateCallees = new Set<string>()
3976
+ function collectCandidates(node: ts.Node): void {
3977
+ if (
3978
+ ts.isVariableDeclaration(node) &&
3979
+ (ts.isArrayBindingPattern(node.name) || ts.isObjectBindingPattern(node.name)) &&
3980
+ node.initializer &&
3981
+ ts.isCallExpression(node.initializer) &&
3982
+ ts.isIdentifier(node.initializer.expression)
3983
+ ) {
3984
+ candidateCallees.add(node.initializer.expression.text)
3985
+ }
3986
+ ts.forEachChild(node, collectCandidates)
3987
+ }
3988
+ collectCandidates(entrySourceFile)
3989
+ if (candidateCallees.size === 0) return
3990
+
3991
+ // 2. Relative imports whose named specifiers overlap the candidate set.
3992
+ interface CandidateSpec {
3993
+ /** Name exported from the helper file. */
3994
+ exported: string
3995
+ /** Local (call-site) binding name — the key every result map uses. */
3996
+ local: string
3997
+ }
3998
+ const importsToCheck: { src: string; specs: CandidateSpec[] }[] = []
3999
+ for (const stmt of entrySourceFile.statements) {
4000
+ if (!ts.isImportDeclaration(stmt)) continue
4001
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4002
+ const src = stmt.moduleSpecifier.text
4003
+ // Mirrors `scanImportedClientSignals`'s restriction to relative
4004
+ // specifiers — non-relative (bare/aliased) imports resolve through
4005
+ // bundler / tsconfig-paths configuration this layer doesn't consume.
4006
+ if (!src.startsWith('./') && !src.startsWith('../')) continue
4007
+ if (stmt.importClause?.isTypeOnly) continue
4008
+ const namedBindings = stmt.importClause?.namedBindings
4009
+ if (!namedBindings || !ts.isNamedImports(namedBindings)) continue
4010
+
4011
+ const specs: CandidateSpec[] = []
4012
+ for (const el of namedBindings.elements) {
4013
+ if (el.isTypeOnly) continue
4014
+ const local = el.name.text
4015
+ if (!candidateCallees.has(local)) continue
4016
+ specs.push({ exported: (el.propertyName ?? el.name).text, local })
4017
+ }
4018
+ if (specs.length === 0) continue
4019
+ importsToCheck.push({ src, specs })
4020
+ }
4021
+ if (importsToCheck.length === 0) return
4022
+
4023
+ for (const { src, specs } of importsToCheck) {
4024
+ const resolved = resolveRelativeImportToFile(src, filePath)
4025
+ // Unresolvable — left alone here; the name-heuristic BF110 branch in
4026
+ // validateReactiveFactoryCalls handles it at validation time.
4027
+ if (!resolved) continue
4028
+
4029
+ let content: string
4030
+ try {
4031
+ content = fs.readFileSync(resolved, 'utf8')
4032
+ } catch {
4033
+ continue
4034
+ }
4035
+
4036
+ const alreadyKnown = (name: string): boolean =>
4037
+ result.factories.has(name) || result.declined.has(name) || result.reactiveShaped.has(name)
4038
+
4039
+ // Cheap text-level skip-gate (not an import/JS parse — see docstring):
4040
+ // a helper file with no reactive-primitive substring anywhere cannot
4041
+ // define a reactive factory, so skip parsing it entirely.
4042
+ const hasAnyPrimitiveText = [...REACTIVE_PRIMITIVES].some(p => content.includes(p))
4043
+ if (!hasAnyPrimitiveText) {
4044
+ for (const spec of specs) {
4045
+ if (!alreadyKnown(spec.local)) result.cleanFactoryImports.add(spec.local)
4046
+ }
4047
+ continue
4048
+ }
4049
+
4050
+ const helperSf = ts.createSourceFile(
4051
+ resolved + '.prescan',
4052
+ content,
4053
+ ts.ScriptTarget.Latest,
4054
+ true,
4055
+ ts.ScriptKind.TSX
4056
+ )
4057
+
4058
+ // Map exported name -> module-scope FunctionDeclaration via the AST.
4059
+ const localFns = new Map<string, ts.FunctionDeclaration>()
4060
+ const exportedFns = new Map<string, ts.FunctionDeclaration>()
4061
+ for (const stmt of helperSf.statements) {
4062
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
4063
+ localFns.set(stmt.name.text, stmt)
4064
+ const hasExportModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
4065
+ const hasDefaultModifier = stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false
4066
+ if (hasExportModifier && !hasDefaultModifier) {
4067
+ exportedFns.set(stmt.name.text, stmt)
4068
+ }
4069
+ }
4070
+ }
4071
+ // `export { f }` / `export { f as g }` — keyed by the EXTERNAL export name.
4072
+ for (const stmt of helperSf.statements) {
4073
+ if (
4074
+ ts.isExportDeclaration(stmt) &&
4075
+ stmt.exportClause &&
4076
+ ts.isNamedExports(stmt.exportClause) &&
4077
+ !stmt.moduleSpecifier &&
4078
+ !stmt.isTypeOnly
4079
+ ) {
4080
+ for (const el of stmt.exportClause.elements) {
4081
+ if (el.isTypeOnly) continue
4082
+ const fn = localFns.get((el.propertyName ?? el.name).text)
4083
+ if (fn) exportedFns.set(el.name.text, fn)
4084
+ }
4085
+ }
4086
+ }
4087
+
4088
+ // Module-scope bindings the helper file declares — an inlined factory
4089
+ // body must not reference any of these (#2325 §4h / BF112), since
4090
+ // inlining moves the body into the component file where they don't
4091
+ // exist.
4092
+ const moduleBindings = collectHelperModuleValueBindings(helperSf)
4093
+
4094
+ for (const spec of specs) {
4095
+ if (alreadyKnown(spec.local)) continue
4096
+
4097
+ const fn = exportedFns.get(spec.exported)
4098
+ if (!fn) {
4099
+ result.cleanFactoryImports.add(spec.local)
4100
+ continue
4101
+ }
4102
+ const det = detectReactiveFactory(fn, helperSf, resolved)
4103
+ if (!det) {
4104
+ result.cleanFactoryImports.add(spec.local)
4105
+ continue
4106
+ }
4107
+ switch (det.kind) {
4108
+ case 'reactive-shaped':
4109
+ result.reactiveShaped.add(spec.local)
4110
+ break
4111
+ case 'declined':
4112
+ result.declined.set(spec.local, det.declined)
4113
+ break
4114
+ case 'factory': {
4115
+ const offending = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
4116
+ if (offending.length > 0) {
4117
+ result.declined.set(spec.local, {
4118
+ code: 'BF112',
4119
+ detail: `'${offending.join("', '")}'`,
4120
+ loc: det.info.loc,
4121
+ })
4122
+ } else {
4123
+ det.info.sourceFilePath = resolved
4124
+ result.factories.set(spec.local, det.info)
4125
+ }
4126
+ break
4127
+ }
4128
+ }
4129
+ }
4130
+ }
4131
+ }
4132
+
4133
+ /**
4134
+ * Value bindings at the helper file's module scope that an inlined factory
4135
+ * body must not capture (#2325 §4h / BF112): top-level const/let/var,
4136
+ * function/class/enum names, and value import specifiers — EXCEPT imports
4137
+ * from '@barefootjs/client' / '@barefootjs/client/runtime'. Those are
4138
+ * re-provisioned from usage by the client-JS emitter regardless of where
4139
+ * the call that used them textually came from (`resolveFinalImports` /
4140
+ * `detectUsedImports` regex-scan the *generated* code, not the consumer's
4141
+ * source imports — see #2325 spec C1), so an inlined body calling
4142
+ * `createSignal` is never a capture even though the helper file itself
4143
+ * imports it. Type-only imports/declarations carry no runtime binding and
4144
+ * are excluded.
4145
+ */
4146
+ function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
4147
+ const names = new Set<string>()
4148
+ for (const stmt of sf.statements) {
4149
+ if (ts.isVariableStatement(stmt)) {
4150
+ const out: string[] = []
4151
+ for (const decl of stmt.declarationList.declarations) {
4152
+ addBindingNames(decl.name, out)
4153
+ }
4154
+ for (const n of out) names.add(n)
4155
+ continue
4156
+ }
4157
+ if (
4158
+ (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
4159
+ stmt.name
4160
+ ) {
4161
+ names.add(stmt.name.text)
4162
+ continue
4163
+ }
4164
+ if (ts.isImportDeclaration(stmt)) {
4165
+ if (stmt.importClause?.isTypeOnly) continue
4166
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
4167
+ const src = stmt.moduleSpecifier.text
4168
+ if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
4169
+ if (stmt.importClause?.name) names.add(stmt.importClause.name.text)
4170
+ const namedBindings = stmt.importClause?.namedBindings
4171
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
4172
+ for (const el of namedBindings.elements) {
4173
+ if (el.isTypeOnly) continue
4174
+ names.add(el.name.text)
4175
+ }
4176
+ }
4177
+ if (namedBindings && ts.isNamespaceImport(namedBindings)) {
4178
+ names.add(namedBindings.name.text)
4179
+ }
4180
+ }
4181
+ }
4182
+ return names
4183
+ }
4184
+
4185
+ /**
4186
+ * Free identifiers of a reactive-factory body that resolve to bindings at
4187
+ * its own module scope (#2325 §4h / BF112) — references the inlined body
4188
+ * would silently lose once spliced into the component file. Returns the
4189
+ * offending names sorted, for stable diagnostic text.
4190
+ *
4191
+ * Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
4192
+ * tracks arrow-function parameters, not nested `function` declarations'
4193
+ * parameters or nested-block declarations — a body-nested binding that
4194
+ * happens to shadow a helper-module binding could false-positive into
4195
+ * BF112. Acceptable: the failure direction is a loud build error on a rare
4196
+ * shape, never a silent runtime break.
4197
+ */
4198
+ function moduleCaptureCheck(
4199
+ fn: ts.FunctionDeclaration,
4200
+ info: ReactiveFactoryInfo,
4201
+ moduleBindings: Set<string>,
4202
+ selfName: string
4203
+ ): string[] {
4204
+ if (!fn.body) return []
4205
+ const free = extractFreeIdentifiersFromNode(fn.body)
4206
+ const exclude = new Set<string>(info.params)
4207
+ for (const b of info.localBindings) exclude.add(b)
4208
+ for (const r of info.returnTupleIdentifiers) exclude.add(r)
4209
+ for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
4210
+ exclude.add(selfName)
4211
+
4212
+ const offending: string[] = []
4213
+ for (const id of free) {
4214
+ if (exclude.has(id)) continue
4215
+ if (moduleBindings.has(id)) offending.push(id)
4216
+ }
4217
+ return offending.sort()
4218
+ }
4219
+
4220
+ /**
4221
+ * Classification result for a module-level function that might be a
4222
+ * reactive-factory helper (#2325). `null` means the function has no
4223
+ * reactive-primitive call anywhere in its body, so it isn't reactive-
4224
+ * related at all — every other case implies at least one such call.
4225
+ */
4226
+ type FactoryDetection =
4227
+ | { kind: 'factory'; info: ReactiveFactoryInfo }
4228
+ | { kind: 'declined'; declined: DeclinedReactiveFactory }
4229
+ | { kind: 'reactive-shaped' } // wraps a reactive primitive but shape unsupported
4230
+ | null
4231
+
3906
4232
  function detectReactiveFactory(
3907
4233
  node: ts.FunctionDeclaration,
3908
4234
  sourceFile: ts.SourceFile,
3909
4235
  filePath: string
3910
- ): ReactiveFactoryInfo | null {
4236
+ ): FactoryDetection {
3911
4237
  if (!node.body || !node.name) return null
3912
4238
 
3913
- // Require: exactly one top-level `return` whose argument is an array
3914
- // literal of plain identifiers (optionally wrapped in `as const` / parens).
3915
- let tupleReturn: ts.ArrayLiteralExpression | null = null
4239
+ // Body must contain at least one reactive primitive call so that the
4240
+ // factory is actually the right thing to inline (not just a helper that
4241
+ // happens to look similar). Checked FIRST: a function with zero reactive
4242
+ // calls is not reactive-related in any way, so it is out of scope for
4243
+ // every diagnostic below, not just "not a factory".
4244
+ let hasReactiveCall = false
4245
+ function checkForReactive(n: ts.Node): void {
4246
+ if (hasReactiveCall) return
4247
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) &&
4248
+ REACTIVE_PRIMITIVES.has(n.expression.text)) {
4249
+ hasReactiveCall = true
4250
+ return
4251
+ }
4252
+ ts.forEachChild(n, checkForReactive)
4253
+ }
4254
+ checkForReactive(node.body)
4255
+ if (!hasReactiveCall) return null
4256
+
4257
+ const loc = getSourceLocation(node, sourceFile, filePath)
4258
+
4259
+ // Require exactly one top-level `return`, whose argument (after unwrapping
4260
+ // parens / `as const` / type-assertion) is a tuple (array literal) or a
4261
+ // shorthand-object literal.
4262
+ let returnExpr: ts.Expression | null = null
3916
4263
  let returnCount = 0
3917
4264
  for (const stmt of node.body.statements) {
3918
4265
  if (!ts.isReturnStatement(stmt)) continue
3919
4266
  returnCount++
3920
- if (!stmt.expression) return null
4267
+ if (!stmt.expression) return { kind: 'reactive-shaped' }
3921
4268
  let expr: ts.Expression = stmt.expression
3922
4269
  while (ts.isParenthesizedExpression(expr)) expr = expr.expression
3923
4270
  // Accept `... as const` / `<const>...`
3924
4271
  if (ts.isAsExpression(expr)) expr = expr.expression
3925
4272
  if (ts.isTypeAssertionExpression(expr)) expr = expr.expression
3926
- if (!ts.isArrayLiteralExpression(expr)) return null
3927
- tupleReturn = expr
4273
+ returnExpr = expr
3928
4274
  }
3929
- if (returnCount !== 1 || !tupleReturn) return null
4275
+ if (returnCount !== 1 || !returnExpr) return { kind: 'reactive-shaped' }
3930
4276
 
3931
- // Every element must be a plain identifier (no spreads, computed,
3932
- // call expressions). Otherwise the caller-side rename would not be sound.
3933
4277
  const returnTupleIdentifiers: string[] = []
3934
- for (const el of tupleReturn.elements) {
3935
- if (!ts.isIdentifier(el)) return null
3936
- returnTupleIdentifiers.push(el.text)
3937
- }
3938
- if (returnTupleIdentifiers.length === 0) return null
3939
-
3940
- // Body must contain at least one reactive primitive call so that the
3941
- // factory is actually the right thing to inline (not just a tuple-returning
3942
- // helper that happens to look similar).
3943
- let hasReactiveCall = false
3944
- function checkForReactive(n: ts.Node): void {
3945
- if (hasReactiveCall) return
3946
- if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) &&
3947
- REACTIVE_PRIMITIVES.has(n.expression.text)) {
3948
- hasReactiveCall = true
3949
- return
4278
+ let returnKind: 'tuple' | 'object'
4279
+
4280
+ if (ts.isArrayLiteralExpression(returnExpr)) {
4281
+ returnKind = 'tuple'
4282
+ // Every element must be a plain identifier (no spreads, computed,
4283
+ // call expressions). Otherwise the caller-side rename would not be sound.
4284
+ for (const el of returnExpr.elements) {
4285
+ if (!ts.isIdentifier(el)) return { kind: 'reactive-shaped' }
4286
+ returnTupleIdentifiers.push(el.text)
3950
4287
  }
3951
- ts.forEachChild(n, checkForReactive)
4288
+ if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
4289
+ } else if (ts.isObjectLiteralExpression(returnExpr)) {
4290
+ returnKind = 'object'
4291
+ const hasNonShorthand = returnExpr.properties.some(p => !ts.isShorthandPropertyAssignment(p))
4292
+ if (hasNonShorthand) {
4293
+ return {
4294
+ kind: 'declined',
4295
+ declined: {
4296
+ code: 'BF111',
4297
+ detail: `return object of '${node.name.text}' uses non-shorthand properties`,
4298
+ loc,
4299
+ },
4300
+ }
4301
+ }
4302
+ for (const p of returnExpr.properties) {
4303
+ // Every property already proven ts.isShorthandPropertyAssignment above.
4304
+ returnTupleIdentifiers.push((p as ts.ShorthandPropertyAssignment).name.text)
4305
+ }
4306
+ if (returnTupleIdentifiers.length === 0) return { kind: 'reactive-shaped' }
4307
+ } else {
4308
+ return { kind: 'reactive-shaped' }
3952
4309
  }
3953
- checkForReactive(node.body)
3954
- if (!hasReactiveCall) return null
3955
4310
 
3956
4311
  // Collect local bindings in the factory body for identifier hygiene at
3957
4312
  // inlining time. Only direct-child declarations of the block are
@@ -3968,26 +4323,35 @@ function detectReactiveFactory(
3968
4323
  }
3969
4324
 
3970
4325
  // Serialize the body without the outer braces and without the return
3971
- // statement — the return tuple is dissolved into caller-named identifiers.
4326
+ // statement — the return tuple/object is dissolved into caller-named
4327
+ // identifiers.
3972
4328
  const bodyStatements = node.body.statements
3973
4329
  .filter(s => !ts.isReturnStatement(s))
3974
4330
  .map(s => s.getText(sourceFile))
3975
4331
  .join('\n')
3976
4332
 
3977
- const params = node.parameters.map(p => {
3978
- if (ts.isIdentifier(p.name)) return p.name.text
4333
+ const params: string[] = []
4334
+ for (const p of node.parameters) {
4335
+ if (ts.isIdentifier(p.name)) {
4336
+ params.push(p.name.text)
4337
+ continue
4338
+ }
3979
4339
  // Destructured params are uncommon for this helper shape and out of
3980
- // initial scope; return a placeholder so we can skip the factory.
3981
- return ''
3982
- })
3983
- if (params.some(p => p === '')) return null
4340
+ // initial scope; the factory still wraps a reactive primitive, so
4341
+ // classify it as reactive-shaped rather than silently ignoring it.
4342
+ return { kind: 'reactive-shaped' }
4343
+ }
3984
4344
 
3985
4345
  return {
3986
- params,
3987
- bodySource: bodyStatements,
3988
- returnTupleIdentifiers,
3989
- localBindings,
3990
- loc: getSourceLocation(node, sourceFile, filePath),
4346
+ kind: 'factory',
4347
+ info: {
4348
+ params,
4349
+ bodySource: bodyStatements,
4350
+ returnTupleIdentifiers,
4351
+ returnKind,
4352
+ localBindings,
4353
+ loc,
4354
+ },
3991
4355
  }
3992
4356
  }
3993
4357
 
@@ -4048,16 +4412,33 @@ function rewriteFactoryCallsInSource(
4048
4412
  }
4049
4413
 
4050
4414
  function maybeRewriteDecl(stmt: ts.VariableStatement, decl: ts.VariableDeclaration): void {
4051
- if (!ts.isArrayBindingPattern(decl.name)) return
4052
4415
  if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return
4053
4416
  if (!ts.isIdentifier(decl.initializer.expression)) return
4054
4417
  const factoryName = decl.initializer.expression.text
4055
4418
  const factory = factories.get(factoryName)
4056
4419
  if (!factory) return
4057
4420
 
4421
+ if (ts.isArrayBindingPattern(decl.name)) {
4422
+ if (factory.returnKind !== 'tuple') return
4423
+ rewriteTupleDecl(stmt, decl.name, decl.initializer, factory)
4424
+ return
4425
+ }
4426
+ if (ts.isObjectBindingPattern(decl.name)) {
4427
+ if (factory.returnKind !== 'object') return
4428
+ rewriteObjectDecl(stmt, decl.name, decl.initializer, factory)
4429
+ return
4430
+ }
4431
+ }
4432
+
4433
+ function rewriteTupleDecl(
4434
+ stmt: ts.VariableStatement,
4435
+ pattern: ts.ArrayBindingPattern,
4436
+ call: ts.CallExpression,
4437
+ factory: ReactiveFactoryInfo
4438
+ ): void {
4058
4439
  // Arity check — bail out on mismatch so the analyzer can report BF110
4059
4440
  // on the untouched source.
4060
- const elements = decl.name.elements
4441
+ const elements = pattern.elements
4061
4442
  if (elements.length !== factory.returnTupleIdentifiers.length) return
4062
4443
 
4063
4444
  // Caller-side identifier names (one per tuple slot). Omitted slots
@@ -4069,17 +4450,74 @@ function rewriteFactoryCallsInSource(
4069
4450
  callerNames.push(el.name.text)
4070
4451
  }
4071
4452
 
4072
- const argTexts = decl.initializer.arguments.map(a => a.getText(sourceFile))
4453
+ // Exclude params + every return-tuple identifier from suffix-renaming
4454
+ // (they're renamed to caller names below instead).
4455
+ const excludeFromSuffixRename = new Set<string>(factory.params)
4456
+ for (const r of factory.returnTupleIdentifiers) excludeFromSuffixRename.add(r)
4457
+
4458
+ const renameReturnToCallerNames = new Map<string, string>()
4459
+ for (let i = 0; i < factory.returnTupleIdentifiers.length; i++) {
4460
+ renameReturnToCallerNames.set(factory.returnTupleIdentifiers[i], callerNames[i])
4461
+ }
4462
+
4463
+ inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, renameReturnToCallerNames)
4464
+ }
4465
+
4466
+ function rewriteObjectDecl(
4467
+ stmt: ts.VariableStatement,
4468
+ pattern: ts.ObjectBindingPattern,
4469
+ call: ts.CallExpression,
4470
+ factory: ReactiveFactoryInfo
4471
+ ): void {
4472
+ // Shorthand destructuring only (no renames/defaults/rest) of names the
4473
+ // factory actually returns. Anything else bails so the analyzer can
4474
+ // report BF110/BF111 on the untouched source. Subset destructures are
4475
+ // allowed — a caller may destructure fewer than all returned names.
4476
+ const destructured = new Set<string>()
4477
+ for (const el of pattern.elements) {
4478
+ if (el.dotDotDotToken) return
4479
+ if (el.propertyName) return
4480
+ if (el.initializer) return
4481
+ if (!ts.isIdentifier(el.name)) return
4482
+ if (!factory.returnTupleIdentifiers.includes(el.name.text)) return
4483
+ destructured.add(el.name.text)
4484
+ }
4485
+
4486
+ // Exclude params + the destructured names from suffix-renaming (caller
4487
+ // name already equals the property name under shorthand, C4). Returned
4488
+ // names the caller did NOT destructure are ordinary internal locals and
4489
+ // DO get suffix-renamed — otherwise two subset calls of the same
4490
+ // factory collide on the undestructured name.
4491
+ const excludeFromSuffixRename = new Set<string>(factory.params)
4492
+ for (const d of destructured) excludeFromSuffixRename.add(d)
4493
+
4494
+ // No return-name→caller-name rename step: identity under shorthand.
4495
+ inlineFactoryCallAtSite(stmt, factory, call.arguments, excludeFromSuffixRename, null)
4496
+ }
4497
+
4498
+ /**
4499
+ * Shared inlining tail for both return shapes: suffix-rename internal
4500
+ * bindings not in `excludeFromSuffixRename`, splice argument expressions
4501
+ * in for parameters, optionally rename return identifiers to caller
4502
+ * names (tuple path only — see C4 for why the object path passes null),
4503
+ * and push the resulting edit for this call site.
4504
+ */
4505
+ function inlineFactoryCallAtSite(
4506
+ stmt: ts.VariableStatement,
4507
+ factory: ReactiveFactoryInfo,
4508
+ args: ts.NodeArray<ts.Expression>,
4509
+ excludeFromSuffixRename: Set<string>,
4510
+ renameReturnToCallerNames: Map<string, string> | null
4511
+ ): void {
4512
+ const argTexts = args.map(a => a.getText(sourceFile))
4073
4513
  const thisCallIndex = callSiteIndex++
4074
4514
  const suffix = `_bf${thisCallIndex}`
4075
4515
 
4076
4516
  // Apply renames to the factory body source.
4077
4517
  let body = factory.bodySource
4078
- // 1. Suffix-rename internal bindings (exclude params + return tuple
4079
- // + caller names to avoid collisions).
4518
+ // 1. Suffix-rename internal bindings.
4080
4519
  const internalRenames = new Set<string>(factory.localBindings)
4081
- for (const p of factory.params) internalRenames.delete(p)
4082
- for (const r of factory.returnTupleIdentifiers) internalRenames.delete(r)
4520
+ for (const ex of excludeFromSuffixRename) internalRenames.delete(ex)
4083
4521
  for (const name of internalRenames) {
4084
4522
  body = body.replace(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), name + suffix)
4085
4523
  }
@@ -4094,11 +4532,11 @@ function rewriteFactoryCallsInSource(
4094
4532
  const wrapped = atomicArg.test(a.trim()) ? a.trim() : `(${a})`
4095
4533
  body = body.replace(new RegExp(`\\b${escapeRegex(p)}\\b`, 'g'), wrapped)
4096
4534
  }
4097
- // 3. Return tuple identifiers → caller destructure names.
4098
- for (let i = 0; i < factory.returnTupleIdentifiers.length; i++) {
4099
- const n = factory.returnTupleIdentifiers[i]
4100
- const caller = callerNames[i]
4101
- body = body.replace(new RegExp(`\\b${escapeRegex(n)}\\b`, 'g'), caller)
4535
+ // 3. Return identifiers → caller destructure names (tuple path only).
4536
+ if (renameReturnToCallerNames) {
4537
+ for (const [n, caller] of renameReturnToCallerNames) {
4538
+ body = body.replace(new RegExp(`\\b${escapeRegex(n)}\\b`, 'g'), caller)
4539
+ }
4102
4540
  }
4103
4541
 
4104
4542
  edits.push({
@@ -4140,10 +4578,36 @@ function escapeRegex(s: string): string {
4140
4578
  // =============================================================================
4141
4579
 
4142
4580
  /**
4143
- * Scan a compiled component context for tuple-destructures whose callee is
4144
- * neither `createSignal` / `createMemo` nor an inlinable reactive factory.
4145
- * These are the silent-failure shapes that produced broken client JS prior
4146
- * to factory inlining emit BF110 so users get a clear message instead.
4581
+ * Build the diagnostic for a call site whose callee was recognised but
4582
+ * declined for inlining (#2325 cross-file factories that rename their
4583
+ * return properties, or capture their own module scope). BF112's wording is
4584
+ * specific to module-scope capture; every other declined reason (currently
4585
+ * only BF111 — non-shorthand return properties) shares BF111's generic
4586
+ * "cannot be inlined: <detail>" phrasing, matching the wording used for the
4587
+ * tuple call-site path.
4588
+ */
4589
+ function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
4590
+ if (d.code === 'BF112') {
4591
+ return (
4592
+ `Reactive factory '${callee}' references ${d.detail} from its own module ` +
4593
+ `scope and cannot be inlined. Move the referenced helper(s) into this file, ` +
4594
+ `pass them as factory arguments, or inline the factory here.`
4595
+ )
4596
+ }
4597
+ return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
4598
+ }
4599
+
4600
+ /**
4601
+ * Scan a compiled component context for destructures (tuple or object)
4602
+ * whose callee is neither `createSignal` / `createMemo` nor an inlinable
4603
+ * reactive factory. These are the silent-failure shapes that produced
4604
+ * broken client JS prior to factory inlining — emit BF110 (unrecognised
4605
+ * shape), BF111 (unsupported rename), or BF112 (module-scope capture) so
4606
+ * users get a clear message instead.
4607
+ *
4608
+ * Only walks top-level component-body statements, matching the scope the
4609
+ * inliner itself operates on (#931) — a factory call inside a nested block
4610
+ * is out of scope for both inlining and this diagnostic.
4147
4611
  */
4148
4612
  export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4149
4613
  if (!ctx.componentNode) return
@@ -4155,42 +4619,199 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
4155
4619
  for (const stmt of body.statements) {
4156
4620
  if (!ts.isVariableStatement(stmt)) continue
4157
4621
  for (const decl of stmt.declarationList.declarations) {
4158
- if (!ts.isArrayBindingPattern(decl.name)) continue
4159
4622
  if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue
4160
4623
  if (!ts.isIdentifier(decl.initializer.expression)) continue
4161
4624
  const callee = decl.initializer.expression.text
4162
- if (callee === 'createSignal' || callee === 'createMemo') continue
4163
- // Env-signal factories (`createSearchParams`, #2057) are `createSignal`-
4164
- // shaped and recognised structurally — a valid tuple destructure. Resolve
4165
- // via the same path as recognition (`resolveEnvSignalKey`) so an aliased
4166
- // import (`import { createSearchParams as csp }`) is accepted here too,
4167
- // rather than falling through to a spurious BF110.
4168
- if (resolveEnvSignalKey(decl.initializer, ctx)) continue
4169
- // Inlined factories were rewritten away before this analysis, so
4170
- // anything still matching the shape is a destructure of an
4171
- // unrecognised callee (imported helper, ad-hoc tuple fn, factory
4172
- // with arity mismatch).
4173
- ctx.errors.push(
4174
- createError(
4175
- ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY,
4176
- getSourceLocation(stmt, ctx.sourceFile, ctx.filePath),
4177
- {
4625
+ const loc = getSourceLocation(stmt, ctx.sourceFile, ctx.filePath)
4626
+
4627
+ if (ts.isArrayBindingPattern(decl.name)) {
4628
+ if (callee === 'createSignal' || callee === 'createMemo') continue
4629
+ // Env-signal factories (`createSearchParams`, #2057) are `createSignal`-
4630
+ // shaped and recognised structurally a valid tuple destructure. Resolve
4631
+ // via the same path as recognition (`resolveEnvSignalKey`) so an aliased
4632
+ // import (`import { createSearchParams as csp }`) is accepted here too,
4633
+ // rather than falling through to a spurious BF110.
4634
+ if (resolveEnvSignalKey(decl.initializer, ctx)) continue
4635
+
4636
+ const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4637
+ if (declinedEntry) {
4638
+ ctx.errors.push(createError(
4639
+ declinedEntry.code === 'BF112'
4640
+ ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4641
+ : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
4642
+ loc,
4643
+ { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4644
+ ))
4645
+ continue
4646
+ }
4647
+
4648
+ const objectFactory = ctx.reactiveFactories.get(callee)
4649
+ if (objectFactory && objectFactory.returnKind === 'object') {
4650
+ ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
4178
4651
  severity: 'error',
4179
4652
  message:
4180
- `Tuple destructuring of '${callee}(...)': this helper is not a ` +
4181
- `recognised reactive factory (createSignal / createMemo / a ` +
4182
- `same-file helper that wraps them with a single \`return [a, b, ...]\`).`,
4183
- suggestion: {
4653
+ `'${callee}' is a reactive factory that returns an object — destructure ` +
4654
+ `it with a matching object pattern: const { ${objectFactory.returnTupleIdentifiers.join(', ')} } = ${callee}(...)`,
4655
+ }))
4656
+ continue
4657
+ }
4658
+
4659
+ // Inlined factories were rewritten away before this analysis, so
4660
+ // anything still matching the shape is a destructure of an
4661
+ // unrecognised callee (imported helper, ad-hoc tuple fn, factory
4662
+ // with arity mismatch).
4663
+ ctx.errors.push(
4664
+ createError(
4665
+ ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY,
4666
+ loc,
4667
+ {
4668
+ severity: 'error',
4184
4669
  message:
4185
- `Inline the createSignal call at the call site, or move the ` +
4186
- `helper into this file as a function that returns a tuple of ` +
4187
- `identifiers at its single exit point.`,
4188
- },
4189
- }
4670
+ `Tuple destructuring of '${callee}(...)': this helper is not a ` +
4671
+ `recognised reactive factory (createSignal / createMemo / a ` +
4672
+ `same-file helper that wraps them with a single \`return [a, b, ...]\`).`,
4673
+ suggestion: {
4674
+ message:
4675
+ `Inline the createSignal call at the call site, or move the ` +
4676
+ `helper into this file as a function that returns a tuple of ` +
4677
+ `identifiers at its single exit point.`,
4678
+ },
4679
+ }
4680
+ )
4190
4681
  )
4191
- )
4682
+ continue
4683
+ }
4684
+
4685
+ if (ts.isObjectBindingPattern(decl.name)) {
4686
+ validateObjectFactoryDestructure(ctx, decl.name, callee, loc)
4687
+ }
4688
+ }
4689
+ }
4690
+ }
4691
+
4692
+ /**
4693
+ * Object-destructure half of `validateReactiveFactoryCalls` (#2325). Split
4694
+ * out so the tuple path above stays a straight read of the pre-#2325 logic
4695
+ * (the tuple diagnostics are pinned by pre-existing tests) while this path
4696
+ * covers the previously-silent object-destructure failure modes: an
4697
+ * unrecognised callee, a tuple factory destructured as an object, a rename/
4698
+ * default/rest destructure of a shorthand-only factory, an unknown
4699
+ * property, a declined (BF111/BF112) factory, or an uninspectable import
4700
+ * that looks reactive-factory-shaped by name.
4701
+ */
4702
+ function validateObjectFactoryDestructure(
4703
+ ctx: AnalyzerContext,
4704
+ pattern: ts.ObjectBindingPattern,
4705
+ callee: string,
4706
+ loc: SourceLocation
4707
+ ): void {
4708
+ const factory = ctx.reactiveFactories.get(callee)
4709
+ if (factory) {
4710
+ // Return-shape mismatch takes priority over element-form validation: a
4711
+ // tuple-return factory destructured as an object is *never* valid,
4712
+ // regardless of whether the object pattern happens to use shorthand or
4713
+ // a rename/default/rest element — always point the caller at positional
4714
+ // destructuring (BF110) instead of the shorthand-only guidance below
4715
+ // (BF111), which only makes sense for genuinely object-return factories.
4716
+ if (factory.returnKind === 'tuple') {
4717
+ ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
4718
+ severity: 'error',
4719
+ message:
4720
+ `'${callee}' is a reactive factory that returns a tuple — destructure ` +
4721
+ `it positionally: const [${factory.returnTupleIdentifiers.join(', ')}] = ${callee}(...)`,
4722
+ }))
4723
+ return
4724
+ }
4725
+
4726
+ const hasUnsupportedElement = pattern.elements.some(
4727
+ el => !!el.propertyName || !!el.initializer || !!el.dotDotDotToken || !ts.isIdentifier(el.name)
4728
+ )
4729
+ if (hasUnsupportedElement) {
4730
+ ctx.errors.push(createError(ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED, loc, {
4731
+ severity: 'error',
4732
+ message:
4733
+ `Object destructure of reactive factory '${callee}' uses a property ` +
4734
+ `rename, default, or rest element; only shorthand destructuring of ` +
4735
+ `{ ${factory.returnTupleIdentifiers.join(', ')} } is supported.`,
4736
+ }))
4737
+ return
4738
+ }
4739
+
4740
+ const unknown = pattern.elements
4741
+ .map(el => (ts.isIdentifier(el.name) ? el.name.text : ''))
4742
+ .filter(name => name && !factory.returnTupleIdentifiers.includes(name))
4743
+ if (unknown.length > 0) {
4744
+ const label = unknown.length === 1 ? 'property' : 'properties'
4745
+ ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
4746
+ severity: 'error',
4747
+ message:
4748
+ `Object destructure of reactive factory '${callee}' references ${label} ` +
4749
+ `'${unknown.join("', '")}' not present in its return { ${factory.returnTupleIdentifiers.join(', ')} }.`,
4750
+ }))
4751
+ return
4752
+ }
4753
+
4754
+ // Shorthand object pattern that matches the factory's return shape —
4755
+ // already inlined; nothing to report.
4756
+ return
4757
+ }
4758
+
4759
+ const declinedEntry = ctx.declinedReactiveFactories.get(callee)
4760
+ if (declinedEntry) {
4761
+ ctx.errors.push(createError(
4762
+ declinedEntry.code === 'BF112'
4763
+ ? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
4764
+ : ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
4765
+ loc,
4766
+ { severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
4767
+ ))
4768
+ return
4769
+ }
4770
+
4771
+ if (ctx.reactiveShapedHelpers.has(callee)) {
4772
+ ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
4773
+ severity: 'error',
4774
+ message:
4775
+ `Object destructure of '${callee}(...)': this helper wraps a reactive ` +
4776
+ `primitive but does not match the inlinable factory shape (single ` +
4777
+ '`return { a, b }` of shorthand identifiers at its one exit point).',
4778
+ }))
4779
+ return
4780
+ }
4781
+
4782
+ // Proven non-reactive import (helper file resolved, read, and found to
4783
+ // export nothing reactive-shaped under this name) — silent, correctly
4784
+ // (C2: an ordinary object destructure must not become a false positive).
4785
+ if (ctx.cleanFactoryImports.has(callee)) return
4786
+
4787
+ // Last resort: an import the compiler cannot inspect (non-relative or
4788
+ // unresolvable path) whose name looks like a hook/factory. Name-based
4789
+ // heuristic only — false negatives here fall through to silence, which
4790
+ // matches this file's ordinary-object-destructure default (C2).
4791
+ let matchedImportSource: string | null = null
4792
+ for (const imp of ctx.imports) {
4793
+ if (imp.isTypeOnly) continue
4794
+ const spec = imp.specifiers.find(s => !s.isTypeOnly && (s.alias ?? s.name) === callee)
4795
+ if (spec) {
4796
+ matchedImportSource = imp.source
4797
+ break
4192
4798
  }
4193
4799
  }
4800
+ if (
4801
+ matchedImportSource !== null &&
4802
+ !matchedImportSource.startsWith('@barefootjs/') &&
4803
+ /^(use|create)[A-Z]/.test(callee)
4804
+ ) {
4805
+ ctx.errors.push(createError(ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY, loc, {
4806
+ severity: 'error',
4807
+ message:
4808
+ `Object destructure of imported '${callee}(...)': the compiler cannot ` +
4809
+ `inspect this import (non-relative or unresolvable path), so if it wraps ` +
4810
+ `createSignal/createMemo the destructured bindings will not be reactive. Move ` +
4811
+ `the helper to a relative-imported file or inline its body.`,
4812
+ }))
4813
+ }
4814
+ // Otherwise: ordinary object destructure of unrelated code — leave untouched (C2).
4194
4815
  }
4195
4816
 
4196
4817
  // =============================================================================