@barefootjs/jsx 0.23.0 → 0.24.1
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/format-date-lowering.d.ts +6 -3
- package/dist/format-date-lowering.d.ts.map +1 -1
- package/dist/index.js +475 -57
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +62 -23
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +27 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +22 -6
- package/src/__tests__/reactive-factory-cross-file.test.ts +263 -0
- package/src/__tests__/to-locale-date-lowering.test.ts +203 -2
- package/src/analyzer.ts +350 -52
- package/src/errors.ts +6 -0
- package/src/format-date-lowering.ts +9 -5
- package/src/ir-to-client-js/emit-reactive.ts +14 -4
- package/src/jsx-to-ir.ts +15 -4
- package/src/to-locale-date-lowering.ts +437 -49
- package/src/types.ts +28 -3
package/src/analyzer.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import ts from 'typescript'
|
|
10
|
-
import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, SourceLocation } from './types.ts'
|
|
10
|
+
import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, SourceLocation } from './types.ts'
|
|
11
11
|
import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
|
|
12
12
|
import { rewriteBarePropRefs } from './prop-rewrite.ts'
|
|
13
13
|
import { incrementCounter } from './instrumentation.ts'
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
isArrowComponentFunction,
|
|
24
24
|
collectReactiveGetterNames,
|
|
25
25
|
} from './analyzer-context.ts'
|
|
26
|
-
import { createError, createWarning, ErrorCodes } from './errors.ts'
|
|
26
|
+
import { createError, createWarning, ErrorCodes, type ErrorCode } from './errors.ts'
|
|
27
27
|
import { baseTypeName } from './rich-type-evidence.ts'
|
|
28
28
|
import { CATALOGUED_RICH_TYPE_NAMES } from './date-lowering.ts'
|
|
29
29
|
import path from 'node:path'
|
|
@@ -3945,6 +3945,108 @@ function prescanReactiveFactoriesInSource(
|
|
|
3945
3945
|
return result
|
|
3946
3946
|
}
|
|
3947
3947
|
|
|
3948
|
+
/**
|
|
3949
|
+
* #2332 — portable component-relative import specifier for a resolved
|
|
3950
|
+
* absolute helper-import target. Same `'.'`/`'./'` prefix convention as the
|
|
3951
|
+
* CLI's buildRelativeImportRewriter (packages/cli/src/lib/build.ts); strips
|
|
3952
|
+
* the resolved extension to match the codebase's extensionless-import
|
|
3953
|
+
* style. Unlike `buildRelativeImportRewriter`, this normalizes
|
|
3954
|
+
* `path.relative`'s separators to POSIX (`/`) — a backslash-separated
|
|
3955
|
+
* specifier is not valid ESM syntax, so on win32 `path.relative`'s native
|
|
3956
|
+
* output would inject a broken import rather than merely an unconventional
|
|
3957
|
+
* one (Copilot review, PR #2338).
|
|
3958
|
+
*/
|
|
3959
|
+
function toComponentRelativeSpecifier(resolvedAbs: string, componentFilePath: string): string {
|
|
3960
|
+
let rel = path.relative(path.dirname(componentFilePath), resolvedAbs).split(path.sep).join('/')
|
|
3961
|
+
rel = rel.replace(/\.(tsx|ts|jsx|js)$/, '')
|
|
3962
|
+
if (rel === '') rel = '.'
|
|
3963
|
+
if (!rel.startsWith('.')) rel = './' + rel
|
|
3964
|
+
return rel
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
/**
|
|
3968
|
+
* localName -> identity of what the entry file's own top-level named value
|
|
3969
|
+
* imports bind, for the satisfied-import dedupe check (#2332): if the
|
|
3970
|
+
* component file already imports the exact binding a factory needs to
|
|
3971
|
+
* re-provision, injecting it again would be a duplicate declaration rather
|
|
3972
|
+
* than a shadow, so that case is skipped instead of injected. `targetKey` is
|
|
3973
|
+
* the resolved absolute path for relative sources (or `unresolved:<source>`
|
|
3974
|
+
* when probing fails) and the raw specifier for bare sources.
|
|
3975
|
+
*/
|
|
3976
|
+
function buildEntryImportIndex(
|
|
3977
|
+
sf: ts.SourceFile,
|
|
3978
|
+
filePath: string
|
|
3979
|
+
): Map<string, { targetKey: string; exportedName: string }> {
|
|
3980
|
+
const index = new Map<string, { targetKey: string; exportedName: string }>()
|
|
3981
|
+
for (const stmt of sf.statements) {
|
|
3982
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
3983
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
3984
|
+
if (stmt.importClause?.isTypeOnly) continue
|
|
3985
|
+
const src = stmt.moduleSpecifier.text
|
|
3986
|
+
const targetKey = src.startsWith('./') || src.startsWith('../')
|
|
3987
|
+
? (resolveRelativeImportToFile(src, filePath) ?? 'unresolved:' + src)
|
|
3988
|
+
: src
|
|
3989
|
+
const namedBindings = stmt.importClause?.namedBindings
|
|
3990
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
3991
|
+
for (const el of namedBindings.elements) {
|
|
3992
|
+
if (el.isTypeOnly) continue
|
|
3993
|
+
index.set(el.name.text, { targetKey, exportedName: (el.propertyName ?? el.name).text })
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
return index
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
/**
|
|
4001
|
+
* Every value-binding name anywhere in the entry file (imports, variable
|
|
4002
|
+
* declarations at any depth, function/class/enum names, function
|
|
4003
|
+
* parameters) — used by the #2332 re-provisioned-import collision check
|
|
4004
|
+
* (BF113). Deliberately over-broad: the inlined factory body lands INSIDE a
|
|
4005
|
+
* component function, where any nested binding (params, destructures,
|
|
4006
|
+
* locals) would silently shadow a top-level import injected by this round.
|
|
4007
|
+
* A scan limited to top-level bindings would miss that, so any hit anywhere
|
|
4008
|
+
* in the file declines re-provisioning with a loud BF113 rather than
|
|
4009
|
+
* risking a silent shadow — matching `moduleCaptureCheck`'s stated failure-
|
|
4010
|
+
* direction philosophy (loud build error over silent runtime break).
|
|
4011
|
+
*/
|
|
4012
|
+
function collectEntryBindingNames(sf: ts.SourceFile): Set<string> {
|
|
4013
|
+
const names = new Set<string>()
|
|
4014
|
+
function visit(node: ts.Node): void {
|
|
4015
|
+
if (ts.isImportDeclaration(node) && node.importClause) {
|
|
4016
|
+
if (node.importClause.name) names.add(node.importClause.name.text)
|
|
4017
|
+
const namedBindings = node.importClause.namedBindings
|
|
4018
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
4019
|
+
// Type-only specifiers still occupy the identifier at the TS level.
|
|
4020
|
+
for (const el of namedBindings.elements) names.add(el.name.text)
|
|
4021
|
+
}
|
|
4022
|
+
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
4023
|
+
names.add(namedBindings.name.text)
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
if (ts.isVariableDeclaration(node)) {
|
|
4027
|
+
const out: string[] = []
|
|
4028
|
+
addBindingNames(node.name, out)
|
|
4029
|
+
for (const n of out) names.add(n)
|
|
4030
|
+
}
|
|
4031
|
+
if (
|
|
4032
|
+
(ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
|
|
4033
|
+
node.name
|
|
4034
|
+
) {
|
|
4035
|
+
names.add(node.name.text)
|
|
4036
|
+
}
|
|
4037
|
+
if (ts.isFunctionLike(node)) {
|
|
4038
|
+
for (const p of node.parameters) {
|
|
4039
|
+
const out: string[] = []
|
|
4040
|
+
addBindingNames(p.name, out)
|
|
4041
|
+
for (const n of out) names.add(n)
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
ts.forEachChild(node, visit)
|
|
4045
|
+
}
|
|
4046
|
+
visit(sf)
|
|
4047
|
+
return names
|
|
4048
|
+
}
|
|
4049
|
+
|
|
3948
4050
|
/**
|
|
3949
4051
|
* Cross-file half of the factory prescan (#2325 round 2): resolve factories
|
|
3950
4052
|
* defined in a relative-imported helper file so `const { count } =
|
|
@@ -4020,6 +4122,13 @@ function prescanImportedReactiveFactories(
|
|
|
4020
4122
|
}
|
|
4021
4123
|
if (importsToCheck.length === 0) return
|
|
4022
4124
|
|
|
4125
|
+
// #2332 — computed once per entry file, shared across all helper files.
|
|
4126
|
+
const entryBindingNames = collectEntryBindingNames(entrySourceFile)
|
|
4127
|
+
const entryImportIndex = buildEntryImportIndex(entrySourceFile, filePath)
|
|
4128
|
+
// localName -> planned injection identity; a later factory requiring the
|
|
4129
|
+
// same name from a DIFFERENT (targetKey, exportedName) is a collision.
|
|
4130
|
+
const plannedInjections = new Map<string, { targetKey: string; exportedName: string }>()
|
|
4131
|
+
|
|
4023
4132
|
for (const { src, specs } of importsToCheck) {
|
|
4024
4133
|
const resolved = resolveRelativeImportToFile(src, filePath)
|
|
4025
4134
|
// Unresolvable — left alone here; the name-heuristic BF110 branch in
|
|
@@ -4112,17 +4221,80 @@ function prescanImportedReactiveFactories(
|
|
|
4112
4221
|
result.declined.set(spec.local, det.declined)
|
|
4113
4222
|
break
|
|
4114
4223
|
case 'factory': {
|
|
4115
|
-
const
|
|
4116
|
-
if (
|
|
4224
|
+
const capture = moduleCaptureCheck(fn, det.info, moduleBindings, fn.name!.text)
|
|
4225
|
+
if (capture.captured.length > 0) {
|
|
4117
4226
|
result.declined.set(spec.local, {
|
|
4118
4227
|
code: 'BF112',
|
|
4119
|
-
detail: `'${
|
|
4228
|
+
detail: `'${capture.captured.join("', '")}'`,
|
|
4120
4229
|
loc: det.info.loc,
|
|
4121
4230
|
})
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4231
|
+
break
|
|
4232
|
+
}
|
|
4233
|
+
// #2332 — re-provision the helper file's own named value imports
|
|
4234
|
+
// that the factory body references, instead of declining. Each
|
|
4235
|
+
// ref resolves to a component-relative specifier (or passes
|
|
4236
|
+
// through unchanged for bare/npm specifiers); a ref already
|
|
4237
|
+
// satisfied by an identical top-level import in the component
|
|
4238
|
+
// file is dropped rather than injected (would redeclare it). A
|
|
4239
|
+
// ref whose local name collides with a DIFFERENT existing/planned
|
|
4240
|
+
// binding declines with BF113 — `pending` is only merged into
|
|
4241
|
+
// `plannedInjections` on full factory success (§3.4), so a
|
|
4242
|
+
// factory that declines mid-loop reserves nothing.
|
|
4243
|
+
const required: RequiredFactoryImport[] = []
|
|
4244
|
+
const pending: Array<[string, { targetKey: string; exportedName: string }]> = []
|
|
4245
|
+
let declinedEntry: DeclinedReactiveFactory | null = null
|
|
4246
|
+
for (const ref of capture.importedRefs) {
|
|
4247
|
+
let specifier: string
|
|
4248
|
+
let targetKey: string
|
|
4249
|
+
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
|
|
4252
|
+
// capture: nothing importable to re-provision (BF112).
|
|
4253
|
+
const abs = resolveRelativeImportToFile(ref.source, resolved)
|
|
4254
|
+
if (!abs) {
|
|
4255
|
+
declinedEntry = {
|
|
4256
|
+
code: 'BF112',
|
|
4257
|
+
detail: `'${ref.localName}' (import '${ref.source}' did not resolve from the helper file)`,
|
|
4258
|
+
loc: det.info.loc,
|
|
4259
|
+
}
|
|
4260
|
+
break
|
|
4261
|
+
}
|
|
4262
|
+
specifier = toComponentRelativeSpecifier(abs, filePath)
|
|
4263
|
+
targetKey = abs
|
|
4264
|
+
} else {
|
|
4265
|
+
specifier = ref.source // bare/npm specifier — unchanged (#2332 test 2)
|
|
4266
|
+
targetKey = ref.source
|
|
4267
|
+
}
|
|
4268
|
+
// Already satisfied by an identical top-level import in the
|
|
4269
|
+
// component file — injecting again would redeclare the binding.
|
|
4270
|
+
const existing = entryImportIndex.get(ref.localName)
|
|
4271
|
+
if (existing && existing.targetKey === targetKey && existing.exportedName === ref.exportedName) {
|
|
4272
|
+
continue
|
|
4273
|
+
}
|
|
4274
|
+
const planned = plannedInjections.get(ref.localName)
|
|
4275
|
+
const collides =
|
|
4276
|
+
(existing !== undefined) ||
|
|
4277
|
+
(planned !== undefined && (planned.targetKey !== targetKey || planned.exportedName !== ref.exportedName)) ||
|
|
4278
|
+
(planned === undefined && entryBindingNames.has(ref.localName))
|
|
4279
|
+
if (collides) {
|
|
4280
|
+
declinedEntry = {
|
|
4281
|
+
code: 'BF113',
|
|
4282
|
+
detail: `'${ref.localName}' from '${specifier}'`,
|
|
4283
|
+
loc: det.info.loc,
|
|
4284
|
+
}
|
|
4285
|
+
break
|
|
4286
|
+
}
|
|
4287
|
+
pending.push([ref.localName, { targetKey, exportedName: ref.exportedName }])
|
|
4288
|
+
required.push({ localName: ref.localName, exportedName: ref.exportedName, specifier })
|
|
4289
|
+
}
|
|
4290
|
+
if (declinedEntry) {
|
|
4291
|
+
result.declined.set(spec.local, declinedEntry)
|
|
4292
|
+
break
|
|
4125
4293
|
}
|
|
4294
|
+
for (const [name, id] of pending) plannedInjections.set(name, id)
|
|
4295
|
+
det.info.sourceFilePath = resolved
|
|
4296
|
+
if (required.length > 0) det.info.requiredImports = required
|
|
4297
|
+
result.factories.set(spec.local, det.info)
|
|
4126
4298
|
break
|
|
4127
4299
|
}
|
|
4128
4300
|
}
|
|
@@ -4131,34 +4303,56 @@ function prescanImportedReactiveFactories(
|
|
|
4131
4303
|
}
|
|
4132
4304
|
|
|
4133
4305
|
/**
|
|
4134
|
-
* Value bindings at the helper file's module scope
|
|
4135
|
-
*
|
|
4136
|
-
*
|
|
4137
|
-
*
|
|
4138
|
-
*
|
|
4139
|
-
*
|
|
4140
|
-
*
|
|
4141
|
-
*
|
|
4142
|
-
*
|
|
4143
|
-
*
|
|
4144
|
-
*
|
|
4306
|
+
* Value bindings at the helper file's module scope, split by whether they
|
|
4307
|
+
* have a re-importable module of their own (#2332).
|
|
4308
|
+
*
|
|
4309
|
+
* `local` bindings — top-level const/let/var, function/class/enum names,
|
|
4310
|
+
* plus default-import and namespace-import names — have no module a
|
|
4311
|
+
* component file could re-import them from, so an inlined factory body
|
|
4312
|
+
* referencing one unconditionally declines with BF112 (#2325 §4h): moving
|
|
4313
|
+
* the body into the component file would leave a dangling reference.
|
|
4314
|
+
*
|
|
4315
|
+
* `imported` bindings — the helper file's own named value imports — CAN be
|
|
4316
|
+
* re-provisioned: the component file can import the same binding under the
|
|
4317
|
+
* same specifier (#2332). These are collected here (keyed by the helper
|
|
4318
|
+
* file's local name) but are NOT captures; `moduleCaptureCheck` below
|
|
4319
|
+
* reports them separately from `local` hits so the caller can decide
|
|
4320
|
+
* whether to re-import rather than unconditionally decline.
|
|
4321
|
+
*
|
|
4322
|
+
* EXCEPT in both cases: imports from '@barefootjs/client' /
|
|
4323
|
+
* '@barefootjs/client/runtime'. Those are re-provisioned from usage by the
|
|
4324
|
+
* client-JS emitter regardless of where the call that used them textually
|
|
4325
|
+
* came from (`resolveFinalImports` / `detectUsedImports` regex-scan the
|
|
4326
|
+
* *generated* code, not the consumer's source imports — see #2325 spec C1),
|
|
4327
|
+
* so an inlined body calling `createSignal` is never a capture even though
|
|
4328
|
+
* the helper file itself imports it. Type-only imports/declarations carry
|
|
4329
|
+
* no runtime binding and are excluded.
|
|
4145
4330
|
*/
|
|
4146
|
-
|
|
4147
|
-
|
|
4331
|
+
interface HelperModuleBindings {
|
|
4332
|
+
/** Declared directly in the helper file — unconditional BF112 capture. */
|
|
4333
|
+
local: Set<string>
|
|
4334
|
+
/** Named value-import specifiers, keyed by helper-file local name —
|
|
4335
|
+
* re-provisionable into the component file (#2332). */
|
|
4336
|
+
imported: Map<string, { source: string; exportedName: string }>
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
function collectHelperModuleValueBindings(sf: ts.SourceFile): HelperModuleBindings {
|
|
4340
|
+
const local = new Set<string>()
|
|
4341
|
+
const imported = new Map<string, { source: string; exportedName: string }>()
|
|
4148
4342
|
for (const stmt of sf.statements) {
|
|
4149
4343
|
if (ts.isVariableStatement(stmt)) {
|
|
4150
4344
|
const out: string[] = []
|
|
4151
4345
|
for (const decl of stmt.declarationList.declarations) {
|
|
4152
4346
|
addBindingNames(decl.name, out)
|
|
4153
4347
|
}
|
|
4154
|
-
for (const n of out)
|
|
4348
|
+
for (const n of out) local.add(n)
|
|
4155
4349
|
continue
|
|
4156
4350
|
}
|
|
4157
4351
|
if (
|
|
4158
4352
|
(ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) &&
|
|
4159
4353
|
stmt.name
|
|
4160
4354
|
) {
|
|
4161
|
-
|
|
4355
|
+
local.add(stmt.name.text)
|
|
4162
4356
|
continue
|
|
4163
4357
|
}
|
|
4164
4358
|
if (ts.isImportDeclaration(stmt)) {
|
|
@@ -4166,42 +4360,62 @@ function collectHelperModuleValueBindings(sf: ts.SourceFile): Set<string> {
|
|
|
4166
4360
|
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
|
4167
4361
|
const src = stmt.moduleSpecifier.text
|
|
4168
4362
|
if (src === '@barefootjs/client' || src === '@barefootjs/client/runtime') continue
|
|
4169
|
-
|
|
4363
|
+
// Default/namespace imports stay hard BF112 (#2332 scope decision):
|
|
4364
|
+
// no single named export to re-provision under one local name.
|
|
4365
|
+
if (stmt.importClause?.name) local.add(stmt.importClause.name.text)
|
|
4170
4366
|
const namedBindings = stmt.importClause?.namedBindings
|
|
4171
4367
|
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
4172
4368
|
for (const el of namedBindings.elements) {
|
|
4173
4369
|
if (el.isTypeOnly) continue
|
|
4174
|
-
|
|
4370
|
+
imported.set(el.name.text, { source: src, exportedName: (el.propertyName ?? el.name).text })
|
|
4175
4371
|
}
|
|
4176
4372
|
}
|
|
4177
4373
|
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
4178
|
-
|
|
4374
|
+
local.add(namedBindings.name.text)
|
|
4179
4375
|
}
|
|
4180
4376
|
}
|
|
4181
4377
|
}
|
|
4182
|
-
return
|
|
4378
|
+
return { local, imported }
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
/**
|
|
4382
|
+
* Categorized free-identifier references of a reactive-factory body into
|
|
4383
|
+
* its own module scope (#2332): `captured` are unconditional BF112 hits
|
|
4384
|
+
* (helper-local bindings — the body would dangle if inlined verbatim);
|
|
4385
|
+
* `importedRefs` are references to the helper file's own named value
|
|
4386
|
+
* imports, which the caller may re-provision into the component file
|
|
4387
|
+
* instead of declining (§3.4 in the #2332 spec).
|
|
4388
|
+
*/
|
|
4389
|
+
interface ModuleCaptureResult {
|
|
4390
|
+
/** Free refs resolving to helper-local bindings (BF112), sorted. */
|
|
4391
|
+
captured: string[]
|
|
4392
|
+
/** Free refs resolving to the helper's own named value imports, sorted by localName. */
|
|
4393
|
+
importedRefs: Array<{ localName: string; source: string; exportedName: string }>
|
|
4183
4394
|
}
|
|
4184
4395
|
|
|
4185
4396
|
/**
|
|
4186
4397
|
* Free identifiers of a reactive-factory body that resolve to bindings at
|
|
4187
|
-
* its own module scope (#2325 §4h / BF112) — references the inlined
|
|
4188
|
-
* would silently lose once spliced into the component file
|
|
4189
|
-
*
|
|
4398
|
+
* its own module scope (#2325 §4h / BF112, #2332) — references the inlined
|
|
4399
|
+
* body would silently lose once spliced into the component file, unless
|
|
4400
|
+
* re-provisioned as an import. Returns `captured` (unconditional BF112) and
|
|
4401
|
+
* `importedRefs` (re-provisionable) separately, each sorted for stable
|
|
4402
|
+
* diagnostic/injection text.
|
|
4190
4403
|
*
|
|
4191
4404
|
* Known accepted limitation: `extractFreeIdentifiersFromNode` only scope-
|
|
4192
4405
|
* tracks arrow-function parameters, not nested `function` declarations'
|
|
4193
4406
|
* parameters or nested-block declarations — a body-nested binding that
|
|
4194
4407
|
* happens to shadow a helper-module binding could false-positive into
|
|
4195
|
-
* BF112
|
|
4196
|
-
*
|
|
4408
|
+
* BF112 or `importedRefs`. Acceptable: the failure direction is always a
|
|
4409
|
+
* loud build error (BF112/BF113) or a redundant-but-harmless injected
|
|
4410
|
+
* import, never a silent dangling reference.
|
|
4197
4411
|
*/
|
|
4198
4412
|
function moduleCaptureCheck(
|
|
4199
4413
|
fn: ts.FunctionDeclaration,
|
|
4200
4414
|
info: ReactiveFactoryInfo,
|
|
4201
|
-
moduleBindings:
|
|
4415
|
+
moduleBindings: HelperModuleBindings,
|
|
4202
4416
|
selfName: string
|
|
4203
|
-
):
|
|
4204
|
-
if (!fn.body) return []
|
|
4417
|
+
): ModuleCaptureResult {
|
|
4418
|
+
if (!fn.body) return { captured: [], importedRefs: [] }
|
|
4205
4419
|
const free = extractFreeIdentifiersFromNode(fn.body)
|
|
4206
4420
|
const exclude = new Set<string>(info.params)
|
|
4207
4421
|
for (const b of info.localBindings) exclude.add(b)
|
|
@@ -4209,12 +4423,20 @@ function moduleCaptureCheck(
|
|
|
4209
4423
|
for (const p of REACTIVE_PRIMITIVES) exclude.add(p)
|
|
4210
4424
|
exclude.add(selfName)
|
|
4211
4425
|
|
|
4212
|
-
const
|
|
4426
|
+
const captured: string[] = []
|
|
4427
|
+
const importedRefs: ModuleCaptureResult['importedRefs'] = []
|
|
4213
4428
|
for (const id of free) {
|
|
4214
4429
|
if (exclude.has(id)) continue
|
|
4215
|
-
if (moduleBindings.has(id))
|
|
4430
|
+
if (moduleBindings.local.has(id)) {
|
|
4431
|
+
captured.push(id)
|
|
4432
|
+
continue
|
|
4433
|
+
}
|
|
4434
|
+
const imp = moduleBindings.imported.get(id)
|
|
4435
|
+
if (imp) importedRefs.push({ localName: id, source: imp.source, exportedName: imp.exportedName })
|
|
4216
4436
|
}
|
|
4217
|
-
|
|
4437
|
+
captured.sort()
|
|
4438
|
+
importedRefs.sort((a, b) => (a.localName < b.localName ? -1 : 1))
|
|
4439
|
+
return { captured, importedRefs }
|
|
4218
4440
|
}
|
|
4219
4441
|
|
|
4220
4442
|
/**
|
|
@@ -4393,6 +4615,12 @@ function rewriteFactoryCallsInSource(
|
|
|
4393
4615
|
type Edit = { start: number; end: number; replacement: string }
|
|
4394
4616
|
const edits: Edit[] = []
|
|
4395
4617
|
let callSiteIndex = 0
|
|
4618
|
+
// #2332 — factories actually inlined in this walk (not merely present in
|
|
4619
|
+
// `prescan.factories`; `maybeRewriteDecl` bails on arity mismatch/omitted
|
|
4620
|
+
// elements/rename destructures without inlining), so their
|
|
4621
|
+
// `requiredImports` can be re-provisioned without adding a dead import
|
|
4622
|
+
// for a factory that was never actually spliced in.
|
|
4623
|
+
const inlinedFactories = new Set<ReactiveFactoryInfo>()
|
|
4396
4624
|
|
|
4397
4625
|
function visitStmt(node: ts.Node, inComponent: boolean): void {
|
|
4398
4626
|
if (ts.isVariableStatement(node) && inComponent) {
|
|
@@ -4544,13 +4772,51 @@ function rewriteFactoryCallsInSource(
|
|
|
4544
4772
|
end: stmt.getEnd(),
|
|
4545
4773
|
replacement: body,
|
|
4546
4774
|
})
|
|
4775
|
+
inlinedFactories.add(factory)
|
|
4547
4776
|
}
|
|
4548
4777
|
|
|
4549
4778
|
visitStmt(sourceFile, false)
|
|
4550
4779
|
|
|
4551
4780
|
if (edits.length === 0) return source
|
|
4552
4781
|
|
|
4553
|
-
//
|
|
4782
|
+
// #2332 — one deduped import statement per specifier for every inlined
|
|
4783
|
+
// cross-file factory's re-provisioned imports. Injected as a zero-width
|
|
4784
|
+
// edit so the ordinary bottom-to-top splice below applies it; the result
|
|
4785
|
+
// is indistinguishable from a hand-written import for every downstream
|
|
4786
|
+
// consumer (ctx.imports → SSR templateImports AND client
|
|
4787
|
+
// collectExternalImports both parse this same rewritten string).
|
|
4788
|
+
const importsBySpecifier = new Map<string, Map<string, string>>() // specifier -> localName -> exportedName
|
|
4789
|
+
for (const f of inlinedFactories) {
|
|
4790
|
+
for (const r of f.requiredImports ?? []) {
|
|
4791
|
+
let names = importsBySpecifier.get(r.specifier)
|
|
4792
|
+
if (!names) { names = new Map(); importsBySpecifier.set(r.specifier, names) }
|
|
4793
|
+
names.set(r.localName, r.exportedName) // same-key duplicates are identical by prescan construction
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
if (importsBySpecifier.size > 0) {
|
|
4797
|
+
// Sort specifiers and, within each, named-import entries by local name —
|
|
4798
|
+
// `importsBySpecifier`/`inlinedFactories` iterate in incidental AST-
|
|
4799
|
+
// traversal/insertion order, which would otherwise make this generated
|
|
4800
|
+
// text order-unstable across unrelated refactors (Copilot review, PR
|
|
4801
|
+
// #2338).
|
|
4802
|
+
const lines = [...importsBySpecifier]
|
|
4803
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
4804
|
+
.map(([spec, names]) => {
|
|
4805
|
+
const specifiers = [...names]
|
|
4806
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
4807
|
+
.map(([local, exported]) => (exported === local ? local : `${exported} as ${local}`))
|
|
4808
|
+
return `import { ${specifiers.join(', ')} } from '${spec}'`
|
|
4809
|
+
})
|
|
4810
|
+
const at = factoryImportInsertionOffset(sourceFile)
|
|
4811
|
+
edits.push({ start: at, end: at, replacement: at === 0 ? lines.join('\n') + '\n' : '\n' + lines.join('\n') })
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
// Apply edits from bottom to top so earlier offsets stay valid. A factory
|
|
4815
|
+
// with `requiredImports` is by definition imported, so the entry file has
|
|
4816
|
+
// at least one import statement and `at` above lands strictly inside the
|
|
4817
|
+
// import prologue — every call-site edit's start is strictly greater
|
|
4818
|
+
// (separated at minimum by the statement break after the last import), so
|
|
4819
|
+
// starts never tie and no sort tiebreak is needed.
|
|
4554
4820
|
edits.sort((a, b) => b.start - a.start)
|
|
4555
4821
|
let out = source
|
|
4556
4822
|
for (const e of edits) {
|
|
@@ -4559,6 +4825,24 @@ function rewriteFactoryCallsInSource(
|
|
|
4559
4825
|
return out
|
|
4560
4826
|
}
|
|
4561
4827
|
|
|
4828
|
+
/**
|
|
4829
|
+
* Offset just after the last top-level import (else after the 'use client'
|
|
4830
|
+
* directive, else 0) in the prescan source file — where re-provisioned
|
|
4831
|
+
* factory imports are injected (#2332).
|
|
4832
|
+
*/
|
|
4833
|
+
function factoryImportInsertionOffset(sf: ts.SourceFile): number {
|
|
4834
|
+
let lastImportEnd = -1
|
|
4835
|
+
let directiveEnd = -1
|
|
4836
|
+
for (const stmt of sf.statements) {
|
|
4837
|
+
if (ts.isImportDeclaration(stmt)) { lastImportEnd = stmt.getEnd(); continue }
|
|
4838
|
+
if (directiveEnd === -1 && ts.isExpressionStatement(stmt) &&
|
|
4839
|
+
ts.isStringLiteral(stmt.expression) && stmt.expression.text === 'use client') {
|
|
4840
|
+
directiveEnd = stmt.getEnd()
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
return lastImportEnd >= 0 ? lastImportEnd : directiveEnd >= 0 ? directiveEnd : 0
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4562
4846
|
function isPascalCaseComponentFn(node: ts.Node): boolean {
|
|
4563
4847
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
4564
4848
|
return /^[A-Z]/.test(node.name.text)
|
|
@@ -4580,11 +4864,12 @@ function escapeRegex(s: string): string {
|
|
|
4580
4864
|
/**
|
|
4581
4865
|
* Build the diagnostic for a call site whose callee was recognised but
|
|
4582
4866
|
* declined for inlining (#2325 — cross-file factories that rename their
|
|
4583
|
-
* return properties
|
|
4584
|
-
*
|
|
4585
|
-
*
|
|
4586
|
-
*
|
|
4587
|
-
*
|
|
4867
|
+
* return properties or capture their own module scope; #2332 — a
|
|
4868
|
+
* re-provisioned helper import collides with an existing binding). BF112
|
|
4869
|
+
* and BF113's wording is specific to their respective failure; every other
|
|
4870
|
+
* declined reason (currently only BF111 — non-shorthand return properties)
|
|
4871
|
+
* shares BF111's generic "cannot be inlined: <detail>" phrasing, matching
|
|
4872
|
+
* the wording used for the tuple call-site path.
|
|
4588
4873
|
*/
|
|
4589
4874
|
function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): string {
|
|
4590
4875
|
if (d.code === 'BF112') {
|
|
@@ -4594,9 +4879,26 @@ function declinedFactoryMessage(callee: string, d: DeclinedReactiveFactory): str
|
|
|
4594
4879
|
`pass them as factory arguments, or inline the factory here.`
|
|
4595
4880
|
)
|
|
4596
4881
|
}
|
|
4882
|
+
if (d.code === 'BF113') {
|
|
4883
|
+
return (
|
|
4884
|
+
`Reactive factory '${callee}' cannot be inlined: it needs ${d.detail} ` +
|
|
4885
|
+
`imported into this file, but that name is already bound here to something ` +
|
|
4886
|
+
`else. Rename the conflicting binding in this file, or alias the import in ` +
|
|
4887
|
+
`the factory's own file (import { x as y }).`
|
|
4888
|
+
)
|
|
4889
|
+
}
|
|
4597
4890
|
return `Reactive factory '${callee}' cannot be inlined: ${d.detail}.`
|
|
4598
4891
|
}
|
|
4599
4892
|
|
|
4893
|
+
/** Diagnostic code for a declined reactive-factory call site (#2325 / #2332). */
|
|
4894
|
+
function declinedFactoryErrorCode(code: DeclinedReactiveFactory['code']): ErrorCode {
|
|
4895
|
+
switch (code) {
|
|
4896
|
+
case 'BF112': return ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4897
|
+
case 'BF113': return ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION
|
|
4898
|
+
default: return ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4600
4902
|
/**
|
|
4601
4903
|
* Scan a compiled component context for destructures (tuple or object)
|
|
4602
4904
|
* whose callee is neither `createSignal` / `createMemo` nor an inlinable
|
|
@@ -4636,9 +4938,7 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
|
4636
4938
|
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
4637
4939
|
if (declinedEntry) {
|
|
4638
4940
|
ctx.errors.push(createError(
|
|
4639
|
-
declinedEntry.code
|
|
4640
|
-
? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4641
|
-
: ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
|
|
4941
|
+
declinedFactoryErrorCode(declinedEntry.code),
|
|
4642
4942
|
loc,
|
|
4643
4943
|
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
4644
4944
|
))
|
|
@@ -4696,8 +4996,8 @@ export function validateReactiveFactoryCalls(ctx: AnalyzerContext): void {
|
|
|
4696
4996
|
* covers the previously-silent object-destructure failure modes: an
|
|
4697
4997
|
* unrecognised callee, a tuple factory destructured as an object, a rename/
|
|
4698
4998
|
* default/rest destructure of a shorthand-only factory, an unknown
|
|
4699
|
-
* property, a declined (BF111/BF112) factory, or an uninspectable
|
|
4700
|
-
* that looks reactive-factory-shaped by name.
|
|
4999
|
+
* property, a declined (BF111/BF112/BF113) factory, or an uninspectable
|
|
5000
|
+
* import that looks reactive-factory-shaped by name.
|
|
4701
5001
|
*/
|
|
4702
5002
|
function validateObjectFactoryDestructure(
|
|
4703
5003
|
ctx: AnalyzerContext,
|
|
@@ -4759,9 +5059,7 @@ function validateObjectFactoryDestructure(
|
|
|
4759
5059
|
const declinedEntry = ctx.declinedReactiveFactories.get(callee)
|
|
4760
5060
|
if (declinedEntry) {
|
|
4761
5061
|
ctx.errors.push(createError(
|
|
4762
|
-
declinedEntry.code
|
|
4763
|
-
? ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE
|
|
4764
|
-
: ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED,
|
|
5062
|
+
declinedFactoryErrorCode(declinedEntry.code),
|
|
4765
5063
|
loc,
|
|
4766
5064
|
{ severity: 'error', message: declinedFactoryMessage(callee, declinedEntry) }
|
|
4767
5065
|
))
|
package/src/errors.ts
CHANGED
|
@@ -89,6 +89,7 @@ export const ErrorCodes = {
|
|
|
89
89
|
UNRECOGNIZED_REACTIVE_FACTORY: 'BF110',
|
|
90
90
|
REACTIVE_FACTORY_RENAME_UNSUPPORTED: 'BF111',
|
|
91
91
|
REACTIVE_FACTORY_MODULE_CAPTURE: 'BF112',
|
|
92
|
+
REACTIVE_FACTORY_IMPORT_COLLISION: 'BF113',
|
|
92
93
|
} as const
|
|
93
94
|
|
|
94
95
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
|
|
@@ -178,6 +179,11 @@ const errorMessages: Record<ErrorCode, string> = {
|
|
|
178
179
|
'body cannot be inlined into the component file. Move those helpers into the ' +
|
|
179
180
|
'component file, pass them to the factory as parameters, or define the factory ' +
|
|
180
181
|
'in the component file.',
|
|
182
|
+
|
|
183
|
+
[ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION]:
|
|
184
|
+
'Inlining an imported reactive factory requires re-importing one of its helper ' +
|
|
185
|
+
'imports into this file, but that name is already bound here to something else. ' +
|
|
186
|
+
"Rename the conflicting binding in this file, or alias the import in the factory's own file.",
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
// =============================================================================
|
|
@@ -21,11 +21,15 @@ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
|
|
|
21
21
|
import { formatDateLocalNames } from './adapters/env-signal.ts'
|
|
22
22
|
|
|
23
23
|
const UTC_LITERAL: ParsedExpr = { kind: 'literal', value: 'UTC', literalType: 'string' }
|
|
24
|
+
const EMPTY_NAMES: ParsedExpr = { kind: 'array-literal', elements: [], raw: '[]' } as ParsedExpr
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* Recognise `formatDate(date, pattern[, timeZone])` against the
|
|
27
|
-
* local import bindings, or decline (null): a non-identifier
|
|
28
|
-
* not bound to the `@barefootjs/client` import, or an arity
|
|
27
|
+
* Recognise `formatDate(date, pattern[, timeZone[, names]])` against the
|
|
28
|
+
* component's local import bindings, or decline (null): a non-identifier
|
|
29
|
+
* callee, a name not bound to the `@barefootjs/client` import, or an arity
|
|
30
|
+
* outside 2–4. The canonical helper arity is 4 (#2334): omitted `timeZone` /
|
|
31
|
+
* `names` normalize to the `'UTC'` literal and the empty table the client
|
|
32
|
+
* function defaults to, so backend helpers stay fixed-arity.
|
|
29
33
|
*/
|
|
30
34
|
export function matchFormatDateCall(
|
|
31
35
|
callee: ParsedExpr,
|
|
@@ -33,11 +37,11 @@ export function matchFormatDateCall(
|
|
|
33
37
|
locals: ReadonlySet<string>,
|
|
34
38
|
): LoweringNode | null {
|
|
35
39
|
if (callee.kind !== 'identifier' || !locals.has(callee.name)) return null
|
|
36
|
-
if (args.length < 2 || args.length >
|
|
40
|
+
if (args.length < 2 || args.length > 4) return null
|
|
37
41
|
return {
|
|
38
42
|
kind: 'helper-call',
|
|
39
43
|
helper: 'format_date',
|
|
40
|
-
args: [args[0], args[1], args[2] ?? UTC_LITERAL],
|
|
44
|
+
args: [args[0], args[1], args[2] ?? UTC_LITERAL, args[3] ?? EMPTY_NAMES],
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -11,7 +11,7 @@ import type { ClientJsContext } from './types.ts'
|
|
|
11
11
|
import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
|
|
12
12
|
import { createTemplateAwareStringProtector } from './html-template.ts'
|
|
13
13
|
import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
|
|
14
|
-
import { toLocaleDatePlugin } from '../to-locale-date-lowering.ts'
|
|
14
|
+
import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
|
|
15
15
|
import { tsNodeToParsedExpr } from '../expression-parser.ts'
|
|
16
16
|
import type { LoweringMatcher } from '../lowering-registry.ts'
|
|
17
17
|
|
|
@@ -181,8 +181,17 @@ function lowerToLocaleCallsInReactiveExpr(expr: string, matcher: LoweringMatcher
|
|
|
181
181
|
call.arguments.map((a) => tsNodeToParsedExpr(a)),
|
|
182
182
|
)
|
|
183
183
|
if (!node || node.kind !== 'helper-call' || node.helper !== 'format_date') continue
|
|
184
|
-
const [, patternArg, tzArg] = node.args
|
|
185
|
-
if (patternArg
|
|
184
|
+
const [, patternArg, tzArg, namesArg] = node.args
|
|
185
|
+
if (!patternArg || tzArg?.kind !== 'literal') continue
|
|
186
|
+
const localeText = call.arguments[0].getText(sourceFile)
|
|
187
|
+
const patternJs = foldedArgToClientJs(patternArg, localeText)
|
|
188
|
+
if (patternJs === null) continue
|
|
189
|
+
// The names table (#2334) — omitted when empty, same as the static path.
|
|
190
|
+
let namesJs: string | null = null
|
|
191
|
+
if (namesArg && !(namesArg.kind === 'array-literal' && namesArg.elements.length === 0)) {
|
|
192
|
+
namesJs = foldedArgToClientJs(namesArg, localeText)
|
|
193
|
+
if (namesJs === null) continue
|
|
194
|
+
}
|
|
186
195
|
const receiverText = propAccess.expression.getText(sourceFile)
|
|
187
196
|
const matchText = call.getText(sourceFile)
|
|
188
197
|
// The call text contains string literals (placeholders in the protected
|
|
@@ -191,7 +200,8 @@ function lowerToLocaleCallsInReactiveExpr(expr: string, matcher: LoweringMatcher
|
|
|
191
200
|
result = replaceProtectedCall(
|
|
192
201
|
result,
|
|
193
202
|
matchText,
|
|
194
|
-
() =>
|
|
203
|
+
() =>
|
|
204
|
+
`formatDate(${receiverText}, ${patternJs}, ${JSON.stringify(tzArg.value)}${namesJs !== null ? `, ${namesJs}` : ''})`,
|
|
195
205
|
)
|
|
196
206
|
}
|
|
197
207
|
return restore(result)
|