fix(resolution): a binding in a module that exports nothing is not a cross-file candidate (#1719) (#1746)

* fix(resolution): a binding in a module that exports nothing is not a cross-file candidate

On vitejs/vite, 157 cross-file `imports` refs — every `import { defineConfig }
from 'vite'` in the playground and the create-vite templates — resolved onto
`playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await
createServer(...)` at module scope in a file with zero exports.

Neither existing guard can see it. `isLexicallyReachable` returns early for any
candidate that is not a `function`, and the bare-import guard correctly declines
because `vite` IS a workspace member, so the specifier really is project-local.
What is wrong is only which node the name lands on.

A JS/TS file that contains an `import` statement and no export of any form
offers nothing to any other file, so none of its bindings is a candidate for a
cross-file name match. Applied in both name-based strategies: declining in
matchByExactName alone just hands the same target to matchFuzzy, which resolves
a unique candidate on its own.

Narrow on three axes, each a class this would otherwise get wrong in the
opposite direction: a classic script is exempt (a top-level binding really is a
reachable global), CommonJS is exempt (`module.exports` and `exports.x` count as
exports), and every non-JS/TS language is exempt. The export test reads source
rather than the node's `isExported` flag, because that flag is set only from an
`export_statement` ancestor and so reads false for `const x = ...; export { x }`.

* fix(resolution): count bracket CommonJS exports and `declare global` as exports

A file writing `exports["x"] = …` exports x, and a file with a `declare
global` block contributes every name in it to every other file whether or not
it exports anything of its own — the extractor emits nodes for the ambient
`var` and `interface` members, so sealing such a file would hide names that
really are reachable everywhere. Neither shape occurs on the vite corpus, so
this changes no measured count; both are now covered by the test.

* test(resolution): bind the #1719 fixture without a bare import

The consumer bound every name from 'some-external-pkg'. A bare specifier
names a package that is not in the graph, so no project node is the right
target for such a reference and #1715 declines it -- which made four of the
five positive assertions depend on a resolution that should not happen, and
they failed the moment this branch was stacked on #1715. Free references
reach the same exact-match path without asserting that.

`strayVar` was not testable at all: a bare identifier read emits no edge, so
that assertion only ever passed through the bare-import binding. The
`declare global` coverage moves to an interface reached through a type
annotation, paired with an identical file whose interface is not in a
`declare global` -- so the assertion turns on that clause rather than
passing whichever way the guard goes.

* docs(changelog): record the sealed-module guard under Unreleased

* fix(resolution): the sealed test rejects fuzzy's survivor, never filters its set

matchFuzzy declines an ambiguous name outright, so filtering sealed
candidates out of its set can leave a lone survivor and manufacture a 0.5
edge from an ambiguity that would have been declined. Testing the single
survivor instead closes that path; matchByExactName keeps the filter,
because it ranks a crowd rather than declining one.

No instance on vitejs/vite either way (row-identical, LOST 0 / GAINED 0
per #1720 review). It also declines one shape the filter form resolved: a
sealed same-language survivor no longer yields to a cross-language
candidate at 0.3.

* fix(resolution): reject invalid fallback targets without retargeting

---------

Co-authored-by: Aaron Queen <bompus@users.noreply.github.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 00:13:49 -05:00
committed by GitHub
co-authored by Aaron Queen Colby McHenry
parent 2c251e2c61
commit bffd50e4f1
7 changed files with 501 additions and 43 deletions
+2 -37
View File
@@ -21,7 +21,8 @@
* inside a string is a false positive, so {@link blankStringContents} blanks
* them too, quotes preserved.)
*/
import { stripCommentsForRegex, type CommentLang } from '../resolution/strip-comments';
import { blankStringContents, stripCommentsForRegex, type CommentLang } from '../resolution/strip-comments';
export { blankStringContents } from '../resolution/strip-comments';
export interface BoundaryMatch {
/** Stable form id, e.g. 'computed-call' — used for per-form dedupe. */
@@ -222,42 +223,6 @@ function commentLang(language: string): CommentLang | null {
const MAX_MATCHES_PER_BODY = 3;
const MAX_BODY_CHARS = 60_000; // a god-function tail is still scannable; beyond this, truncate
/**
* Blank the CONTENTS of string literals (quotes preserved, offsets preserved)
* so dispatch-shaped prose — docs, error messages, template text — can't fire
* a matcher. Run AFTER comment stripping (comments are already spaces).
* Backslash escapes are honored; `'`/`"` strings end at a newline (treated as
* unterminated, matching the comment stripper); backticks span lines, and
* `${...}` interpolations inside them are blanked too — missing a dispatch
* inside a template literal is acceptable, false-firing on prose is not.
*/
export function blankStringContents(text: string): string {
const out = text.split('');
let i = 0;
const n = text.length;
while (i < n) {
const c = text[i]!;
if (c === '"' || c === "'" || c === '`') {
const quote = c;
i++;
while (i < n && text[i] !== quote) {
if (text[i] === '\\' && i + 1 < n) {
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
continue;
}
if (quote !== '`' && text[i] === '\n') break; // unterminated — stop blanking
if (text[i] !== '\n') out[i] = ' '; // keep newlines for line math
i++;
}
if (i < n && text[i] === quote) i++;
continue;
}
i++;
}
return out.join('');
}
/**
* Scan one symbol's body for dynamic-dispatch sites.
+2
View File
@@ -2458,6 +2458,8 @@ export class ReferenceResolver {
if (!result) return result;
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
// Package imports cannot target prose found by a framework's name lookup.
if (ref.referenceKind === 'imports' && (tgt as string) === 'markdown' && (ref.language as string) !== 'markdown') return null;
if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
return result;
}
+132 -5
View File
@@ -7,6 +7,7 @@
import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
@@ -389,6 +390,108 @@ function isLexicallyReachable(
);
}
/** Languages whose module boundary is `import`/`export` (or CommonJS). */
const ESM_FAMILY = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
/**
* A line-initial `import` statement — the marker that a JS/TS file is a MODULE
* rather than a classic script. Line-anchored and followed by a name, brace,
* star or quote, so a dynamic `import(` and the word inside a comment or string
* do not match.
*/
const HAS_IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m;
/**
* Anything the file could offer another file, in every form the extractor's own
* `isExported` flag misses. `^export` covers the declaration and later forms
* (`export const`, `export { x }`, `export default x`, `export *`); the
* CommonJS shapes cover files that never use ESM syntax at all, in both the dot
* and the bracket form; and `declare global` contributes names to every file
* whether or not the module exports anything of its own. Kept as a source test
* rather than a node scan precisely because `isExported` is set only from an
* `export_statement` ancestor, so `const x = …; export { x }` and
* `module.exports = { x }` both read as unexported on the node.
*/
const HAS_ESM_EXPORT = /^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b/m;
const HAS_CJS_EXPORT = /\bmodule\.exports\b|\bexports\s*[.[]/;
/**
* Per-context memo of "this file is a module that exports nothing", asked once
* per candidate FILE rather than once per reference. Derived from file source,
* so it drops with the context's file caches — clearNameMatcherMemos deletes it
* alongside INFER_SCAN_STATES.
*/
const SEALED_MODULES = new WeakMap<ResolutionContext, Map<string, boolean>>();
/**
* Whether `filePath` is a JS/TS module that exports NOTHING — an import
* statement present, no export of any form. No reference from another file can
* reach any binding in such a file, so every one of its symbols is a false
* candidate for a cross-file name match.
*
* This is the general case behind a package name capturing a same-named local:
* on `vitejs/vite`, 157 cross-file `imports` refs — every `import { defineConfig
* } from 'vite'` in the playground and the create-vite templates — resolved onto
* `playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await
* createServer(…)` at module scope in a file with zero exports. The existing
* guards cannot see it: `isLexicallyReachable` returns early for any candidate
* that is not a `function`, and the bare-import guard correctly declines because
* `vite` IS a workspace member, so the specifier really is project-local. What
* is wrong is only which node the name lands on.
*
* Deliberately narrow on three axes, because each is a class this would
* otherwise resolve wrongly in the opposite direction:
*
* - **A classic script is exempt.** Requiring an `import` statement means a
* non-module `.js` file — concatenated globals, a browser `<script>` — keeps
* its cross-file matches, where a top-level binding genuinely is reachable.
* - **CommonJS is exempt.** `module.exports` and `exports.x` are matched as
* exports, so a CJS file is never sealed.
* - **Other languages are exempt.** Go, Python, Java and the rest have no
* equivalent boundary, and several extractors hardcode `isExported`.
*/
function isSealedModule(filePath: string, context: ResolutionContext): boolean {
let memo = SEALED_MODULES.get(context);
if (!memo) {
memo = new Map();
SEALED_MODULES.set(context, memo);
}
const hit = memo.get(filePath);
if (hit !== undefined) return hit;
const source = context.readFile(filePath);
const code = source === null ? '' : blankStringContents(stripCommentsForRegex(source, 'typescript'));
// CommonJS assignments can execute inside template interpolations, which the
// masker blanks. Keep the conservative raw-source exemption for those forms.
const sealed =
source !== null && HAS_IMPORT_STATEMENT.test(code) &&
!context.getNodesInFile(filePath).some((n) => n.isExported) &&
!HAS_ESM_EXPORT.test(code) && !HAS_CJS_EXPORT.test(source);
memo.set(filePath, sealed);
return sealed;
}
/**
* Whether `candidate` can be named by a reference in `ref`'s file at all.
* Both name-based strategies validate their chosen candidate. Removing an
* unreachable candidate before ranking can promote an unrelated runner-up;
* rejecting the chosen target must leave the reference unresolved instead.
*/
function isCrossFileReachable(
candidate: Node,
ref: UnresolvedRef,
context: ResolutionContext
): boolean {
if ((ref.language as string) !== 'markdown' && (candidate.language as string) === 'markdown') return false;
if (ref.referenceKind === 'calls' && ESM_FAMILY.has(candidate.language) &&
(candidate.kind === 'constant' || candidate.kind === 'variable') &&
/^=\s*require\s*\(\s*(['"])[^'"]+\.json\1\s*\)\s*;?\s*$/.test(candidate.signature ?? '')) return false;
return (
candidate.filePath === ref.filePath ||
!ESM_FAMILY.has(candidate.language) ||
!isSealedModule(candidate.filePath, context)
);
}
/**
* Languages in which `visibility: 'private'` on a definition means no other
* FILE can name it: a Kotlin `private fun` is file- or class-local, and the
@@ -498,6 +601,11 @@ function rustModuleDir(filePath: string): string {
* descendants, never to a sibling module or another crate — `.count()` on
* an iterator resolved onto a `fn count` in a different crate. A method in
* an `impl Trait for Type` block has the trait's visibility, not `private`.
* - **JS / TS / ArkTS**: a binding in a module that exports nothing (an
* `import` present, no `export` / CommonJS / `declare global`) is sealed —
* the vite playground's `const vite = await createServer(…)` took 157
* `import { defineConfig } from 'vite'` edges (#1719). Classic scripts,
* CommonJS, later `export { … }`, and ambient globals stay visible.
*
* Same-file candidates are always visible. Applied by ReferenceResolver to
* the target the whole name-matching pipeline settled on, so a rejection ends
@@ -529,7 +637,10 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
return ref.filePath.startsWith(owner + '/');
}
if (PRIVATE_IS_FILE_LOCAL.has(lang)) return candidate.visibility !== 'private';
return true;
// JS/TS/ArkTS sealed modules + markdown/JSON call-target guards (#1719).
// Same predicate matchByExactName / matchFuzzy apply to their survivors so a
// rejection here cannot fall through to a promoted runner-up.
return isCrossFileReachable(candidate, ref, context);
}
/**
@@ -551,7 +662,10 @@ export function matchByExactName(
const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
.filter((n) => n.kind !== 'import')
// Nested locals are only reachable from inside their container (#1230).
.filter((n) => isLexicallyReachable(n, ref, context));
.filter((n) => isLexicallyReachable(n, ref, context))
// Preserve import ranking; calls reject the winner without promoting another.
.filter((n) => ref.referenceKind !== 'imports' || n.filePath === ref.filePath ||
!ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context));
if (candidates.length === 0) {
return null;
@@ -559,6 +673,7 @@ export function matchByExactName(
// If only one match, use it — but penalize cross-language matches
if (candidates.length === 1) {
if (!isCrossFileReachable(candidates[0]!, ref, context)) return null;
const isCrossLanguage = candidates[0]!.language !== ref.language;
return {
original: ref,
@@ -579,7 +694,7 @@ export function matchByExactName(
// Multiple matches - try to narrow down
const bestMatch = findBestMatch(ref, candidates, context);
if (bestMatch) {
if (bestMatch && isCrossFileReachable(bestMatch, ref, context)) {
// Lower confidence when the match is from a distant/unrelated module
const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
const confidence = proximity >= 30 ? 0.7 : 0.4;
@@ -1445,6 +1560,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
INFER_SCAN_STATES.delete(context);
C_STATIC_MEMO.delete(context);
RUST_TRAIT_IMPL_MEMO.delete(context);
SEALED_MODULES.delete(context);
}
function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
@@ -2558,13 +2674,24 @@ export function matchFuzzy(
// Filter to callable kinds only (function, method, class)
const callableKinds = new Set(['function', 'method', 'class']);
const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
const callableCandidates = applyLanguageGate(
candidates.filter((n) => callableKinds.has(n.kind)),
ref
);
// Prefer same-language matches
const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
if (finalCandidates.length === 1 && isVisibleAcrossFiles(finalCandidates[0]!, ref, context)) {
// Both post-pipeline visibility guards (#1745 language-local + #1719 sealed
// module). The sealed-module test rejects the survivor and never filters the
// set that produced it: removing a sealed candidate from a crowd would leave
// a lone one and manufacture a 0.5 guess out of an ambiguity fuzzy declines.
if (
finalCandidates.length === 1 &&
isVisibleAcrossFiles(finalCandidates[0]!, ref, context) &&
isCrossFileReachable(finalCandidates[0]!, ref, context)
) {
const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
return {
original: ref,
+46
View File
@@ -23,6 +23,52 @@
* framework extractors scan for.
*/
/**
* Blank string contents while preserving quotes and offsets. Template
* interpolations are blanked too; callers checking executable expressions
* must conservatively inspect those expressions in the original source.
*/
export function blankStringContents(text: string): string {
const out = text.split('');
let i = 0;
const n = text.length;
while (i < n) {
const c = text[i]!;
// A quote inside a JS regex is data, not the beginning of a string.
// Expression-start punctuation and keywords distinguish these from division.
if (c === '/' && /(?:^|[=(:,)!&|?;{}\[\]+*%~^<>-]|\b(?:return|throw|case|yield|await|else|do|typeof|void|delete|new|in|of|instanceof))\s*$/.test(text.slice(Math.max(0, i - 32), i))) {
let end = i + 1;
let inClass = false;
for (; end < n && text[end] !== '\n'; end++) {
if (text[end] === '\\') { end++; continue; }
if (text[end] === '[') inClass = true;
if (text[end] === ']') inClass = false;
if (text[end] === '/' && !inClass) break;
}
if (end < n && text[end] === '/') { i = end + 1; continue; }
}
if (c === '"' || c === "'" || c === '`') {
const quote = c;
i++;
while (i < n && text[i] !== quote) {
if (text[i] === '\\' && i + 1 < n) {
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
continue;
}
if (quote !== '`' && text[i] === '\n') break;
if (text[i] !== '\n') out[i] = ' ';
i++;
}
if (i < n && text[i] === quote) i++;
continue;
}
i++;
}
return out.join('');
}
export type CommentLang =
| 'python'
| 'javascript'