fix(js,ts): link calls through an import whose name contains $ (#2378)

The ES import regex in extractJSImports captured default and namespace
bindings with \w+, which stops at a `$`. So `import items$ from './store'`,
`import $ from './dom'` and `import * as ns$ from './ns'` failed the whole
statement and produced no import mapping, and `items$.getState().inc()`
never reached the store action. The `as` split of named imports, the
`a: b` split of a CommonJS destructuring and the re-export checks had the
same \w-only shape. Some bound a cut-off name instead (`{ a as b$ }` bound
`b`, `{ default as items$ }` bound `items`), and `export { a$ } from` was
dropped from barrel chasing.

Capture every binding with the ASCII identifier class [A-Za-z_$][\w$]*,
keeping the `type`-modifier lookahead: `import type from './x'` still
binds `type`, and `import type $T` / `import type from$` now bind. A
default binding is never followed by `{`, so the `${` of a code
generator's template literal (`import ${x} from '${src}'`, as in qwik's
optimizer) is not taken for a binding.

importShadowedAt and hasParameterBinding bounded the name with \b, which
never fires beside a `$`, so a local `const items$ = ...` or an `items$`
parameter did not shadow the imported store. Named `$` imports already
got those wrong edges on main, and the mapping fix would have extended
them to default and namespace imports. Bound the name with (?<![\w$]) and
(?![\w$]) instead.

Resolution-only: the kernel has no import-mapping mirror. A store
exported by `export { items$ }` (the \b in isExportedLater's clause
branch, left out of #2366) is a separate extraction fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 14:41:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5a11cd7ab7
commit 0571286907
4 changed files with 104 additions and 10 deletions
+10 -6
View File
@@ -964,7 +964,11 @@ function extractJSImports(content: string): ImportMapping[] {
// type-only form, not a default import named `type` — which every such
// line used to add, making `type.innerType()` a call on an import.
// (`import type from './x'` still binds `type`: backtracking gives it back.)
const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from\b)\w))?(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]/g;
// A binding is any JS identifier, `$` included: `\w` stops at the `$` of
// `import $store from './store'`, failing the whole statement. A default
// binding is never followed by `{`, so the `${` of a code generator's
// `` `import ${name} from '${src}'` `` is no binding.
const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from(?![\w$]))[A-Za-z_$]))?(?:([A-Za-z_$][\w$]*)(?![\w${])\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
@@ -984,9 +988,9 @@ function extractJSImports(content: string): ImportMapping[] {
// Named imports
if (namedImports) {
// `{ util, type objectUtil }`: an inline `type` modifier is not part of the name.
const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=\w)/, ''));
const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=[A-Za-z_$])/, ''));
for (const name of names) {
const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
if (aliasMatch) {
mappings.push({
localName: aliasMatch[2]!,
@@ -1038,7 +1042,7 @@ function extractJSImports(content: string): ImportMapping[] {
if (destructured) {
const names = destructured.split(',').map((s) => s.trim());
for (const name of names) {
const aliasMatch = name.match(/(\w+)\s*:\s*(\w+)/);
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)/);
if (aliasMatch) {
mappings.push({
localName: aliasMatch[2]!,
@@ -1373,7 +1377,7 @@ export function extractReExports(content: string, language: Language): ReExport[
for (const raw of inner.split(',')) {
const item = raw.trim();
if (!item) continue;
const aliasMatch = item.match(/^(\w+)\s+as\s+(\w+)$/);
const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
if (aliasMatch) {
out.push({
kind: 'named',
@@ -1381,7 +1385,7 @@ export function extractReExports(content: string, language: Language): ReExport[
originalName: aliasMatch[1]!,
source,
});
} else if (/^\w+$/.test(item)) {
} else if (/^[A-Za-z_$][\w$]*$/.test(item)) {
out.push({
kind: 'named',
exportedName: item,
+4 -3
View File
@@ -11171,7 +11171,8 @@ function importShadowedAt(name: string, ref: UnresolvedRef, context: ResolutionC
return stack;
};
const scope = stackAt(code.length);
const declarations = new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${escaped}\\b|\\{[^}]*\\b${escaped}\\b)`, 'g');
// Not `\b`: there is no word boundary beside the `$` of `items$` or `$store`.
const declarations = new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${escaped}(?![\\w$])|\\{[^}]*(?<![\\w$])${escaped}(?![\\w$]))`, 'g');
return [...code.matchAll(declarations)].some(m => stackAt(m.index!).every((p, i) => scope[i] === p));
}
@@ -11179,8 +11180,8 @@ function importShadowedAt(name: string, ref: UnresolvedRef, context: ResolutionC
* parentheses must not make the outer shadow invisible. Conservative when a
* parameter's type mentions the same name: leave that call unresolved. */
function hasParameterBinding(code: string, escapedName: string): boolean {
const name = new RegExp(`\\b${escapedName}\\b`);
if (new RegExp(`\\b${escapedName}\\s*=>`).test(code)) return true;
const name = new RegExp(`(?<![\\w$])${escapedName}(?![\\w$])`);
if (new RegExp(`(?<![\\w$])${escapedName}\\s*=>`).test(code)) return true;
for (let i = 0; i < code.length; i++) {
if (code[i] !== '(' || /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(0, i))) continue;
let depth = 1, j = i + 1;