1
0
Эх сурвалжийг харах

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>
Colby Mchenry 8 цаг өмнө
parent
commit
bffd50e4f1

+ 1 - 0
CHANGELOG.md

@@ -208,6 +208,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 #### Symbols, tests and the viewer
 #### Symbols, tests and the viewer
 
 
 - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
 - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
+- **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719)
 
 
 - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
 - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
 
 

+ 52 - 0
__tests__/frameworks-integration.test.ts

@@ -3,6 +3,10 @@ import * as fs from 'fs';
 import * as path from 'path';
 import * as path from 'path';
 import * as os from 'os';
 import * as os from 'os';
 import { CodeGraph } from '../src';
 import { CodeGraph } from '../src';
+import { DatabaseConnection, getDatabasePath } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { createResolver } from '../src/resolution';
+import type { Node } from '../src/types';
 import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
 import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
 
 
 beforeAll(async () => {
 beforeAll(async () => {
@@ -10,6 +14,54 @@ beforeAll(async () => {
   await loadAllGrammars();
   await loadAllGrammars();
 });
 });
 
 
+describe('Express middleware imports', () => {
+  it('does not resolve package imports into license headings', async () => {
+    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-express-doc-import-'));
+    let cg: CodeGraph | undefined;
+    try {
+      fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { express: '*', cors: '*' } }));
+      fs.writeFileSync(path.join(tmpDir, 'LICENSE.md'), '# cors\n\n# host-validation-middleware\n');
+      fs.writeFileSync(path.join(tmpDir, 'local.js'), 'export function localMiddleware() {}\n');
+      fs.writeFileSync(path.join(tmpDir, 'server.js'), [
+        "import corsMiddleware from 'cors'",
+        "import { hostValidationMiddleware as originalHostValidationMiddleware } from 'host-validation-middleware'",
+        "import { localMiddleware } from './local.js'",
+        'localMiddleware()',
+      ].join('\n'));
+      cg = await CodeGraph.init(tmpDir, { index: true });
+      const local = cg.getNodesByKind('function').find((n) => n.name === 'localMiddleware');
+      expect(local).toBeDefined();
+      expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'imports')).toBe(true);
+      expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'calls')).toBe(true);
+      cg.close();
+      cg = undefined;
+      const db = DatabaseConnection.open(getDatabasePath(tmpDir));
+      try {
+        const queries = new QueryBuilder(db.getDb());
+        for (const name of ['cors', 'host-validation-middleware']) {
+          queries.insertNode({
+            id: `heading:${name}`, name, qualifiedName: `LICENSE.md#${name}`,
+            kind: 'module', language: 'markdown' as Node['language'], filePath: 'LICENSE.md',
+            startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
+          });
+        }
+        const resolver = createResolver(tmpDir, queries);
+        for (const referenceName of ['cors', 'corsMiddleware', 'host-validation-middleware']) {
+          expect(resolver.resolveOne({
+            fromNodeId: 'file:server.js', referenceName, referenceKind: 'imports',
+            filePath: 'server.js', language: 'javascript', line: 1, column: 0,
+          })).toBeNull();
+        }
+      } finally {
+        db.close();
+      }
+    } finally {
+      cg?.close();
+      fs.rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+});
+
 describe('Django end-to-end framework extraction', () => {
 describe('Django end-to-end framework extraction', () => {
   let tmpDir: string | undefined;
   let tmpDir: string | undefined;
   afterEach(() => {
   afterEach(() => {

+ 266 - 1
__tests__/resolution.test.ts

@@ -11,7 +11,7 @@ import * as os from 'os';
 import { CodeGraph } from '../src';
 import { CodeGraph } from '../src';
 import { Node, UnresolvedReference } from '../src/types';
 import { Node, UnresolvedReference } from '../src/types';
 import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
 import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
-import { matchReference, resolveMethodOnType, matchByQualifiedName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
+import { matchReference, resolveMethodOnType, matchByQualifiedName, matchByExactName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
 import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver';
 import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver';
 import type { UnresolvedRef } from '../src/resolution/types';
 import type { UnresolvedRef } from '../src/resolution/types';
 import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
 import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
@@ -5386,4 +5386,269 @@ in
       expect(importedFilePaths('main.nix')).toEqual([]);
       expect(importedFilePaths('main.nix')).toEqual([]);
     });
     });
   });
   });
+
+  describe('Bindings in a module that exports nothing (#1719)', () => {
+    it('does not treat documentation headings as package imports', () => {
+      // Inject the planned Markdown node shape without depending on its extractor.
+      const heading: Node = {
+        id: 'heading:vite', name: 'vite', qualifiedName: 'guide.md#vite',
+        kind: 'module', language: 'markdown' as Node['language'], filePath: 'guide.md',
+        startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
+      };
+      const context = {
+        getNodesByName: () => [heading], getNodesInFile: () => [],
+        getNodesByQualifiedName: () => [], getNodesByKind: () => [],
+        fileExists: () => false, readFile: () => null,
+        getProjectRoot: () => tempDir, getAllFiles: () => [],
+      } as ResolutionContext;
+      const ref: UnresolvedRef = {
+        fromNodeId: 'file:consumer.ts', referenceName: 'vite', referenceKind: 'imports',
+        filePath: 'consumer.ts', language: 'typescript', line: 1, column: 0,
+      };
+      expect(matchByExactName(ref, context)).toBeNull();
+      expect(matchByExactName({ ...ref, language: 'markdown' as Node['language'] }, context)?.targetNodeId).toBe(heading.id);
+      context.getNodesByName = () => [{ ...heading, id: 'fn:vite', kind: 'function', language: 'typescript', filePath: 'vite.ts' }];
+      expect(matchByExactName(ref, context)?.targetNodeId).toBe('fn:vite');
+    });
+
+    it('ignores export examples in strings and comments when checking module visibility', async () => {
+      fs.mkdirSync(path.join(tempDir, 'src'));
+      fs.writeFileSync(path.join(tempDir, 'src/private.js'), [
+        "import fs from 'node:fs'",
+        'const example = `',
+        'export const example = 1',
+        '`',
+        '/*',
+        'export { hidden }',
+        '*/',
+        'function hidden() { return fs }',
+        'hidden()',
+      ].join('\n'));
+      fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), 'hidden()');
+      fs.mkdirSync(path.join(tempDir, 'legacy'));
+      fs.writeFileSync(path.join(tempDir, 'legacy/global.js'), 'function hidden() { return 1 }');
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+      const hidden = cg.getNodesByKind('function').find((n) => n.name === 'hidden' && n.filePath === 'src/private.js');
+      expect(hidden).toBeDefined();
+      const callers = cg.getIncomingEdges(hidden!.id).filter((e) => e.kind === 'calls');
+      expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/consumer.js')).toBe(false);
+      expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/private.js')).toBe(true);
+      const consumer = cg.getNodesByKind('file').find((n) => n.filePath === 'src/consumer.js');
+      expect(cg.getOutgoingEdges(consumer!.id).filter((e) => e.kind === 'calls')).toEqual([]);
+    });
+
+    it('does not name-match a method call to another file\'s JSON value', async () => {
+      fs.writeFileSync(path.join(tempDir, 'data.json'), '{"content": "hello"}');
+      fs.writeFileSync(path.join(tempDir, 'data.js'), "const content = require('./data.json')\nmodule.exports = { content }\n");
+      fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'export async function read(page) { return page.frame("main").content() }');
+      fs.writeFileSync(path.join(tempDir, 'use-data.js'), "import { content } from './data'\nconsole.log(content)\n");
+      fs.writeFileSync(path.join(tempDir, 'callback.js'), "const callback = require('./handler.js')\nmodule.exports = { callback }\n");
+      fs.writeFileSync(path.join(tempDir, 'call.js'), 'callback()');
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+      const content = cg.getNodesByKind('constant').find((n) => n.name === 'content');
+      expect(content).toBeDefined();
+      expect(cg.getIncomingEdges(content!.id).filter((e) => e.kind === 'calls')).toEqual([]);
+      expect(cg.getIncomingEdges(content!.id).some((e) => e.kind === 'imports')).toBe(true);
+      const callback = cg.getNodesByKind('constant').find((n) => n.name === 'callback');
+      expect(callback).toBeDefined();
+      expect(cg.getIncomingEdges(callback!.id).some((e) => e.kind === 'calls')).toBe(true);
+    });
+
+    it('keeps a local file dependency import when a closer private name collides', async () => {
+      fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ dependencies: { 'local-dep': 'file:./dep' } }));
+      fs.mkdirSync(path.join(tempDir, 'dep'));
+      fs.mkdirSync(path.join(tempDir, 'src'));
+      fs.writeFileSync(path.join(tempDir, 'dep/package.json'), JSON.stringify({ name: 'local-dep', main: 'index.js' }));
+      fs.writeFileSync(path.join(tempDir, 'dep/index.js'), "export const msg = 'local'\n");
+      fs.writeFileSync(path.join(tempDir, 'src/private.js'), "import fs from 'node:fs'\nconst msg = 'private'\n");
+      fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), "import { msg } from 'local-dep'\nconsole.log(msg)\n");
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+      const msg = cg.getNodesByKind('constant').find((n) => n.name === 'msg' && n.filePath === 'dep/index.js');
+      expect(msg).toBeDefined();
+      expect(cg.getIncomingEdges(msg!.id).some((e) => e.kind === 'imports')).toBe(true);
+    });
+
+    it('preserves executable CommonJS exports inside nested template interpolations', async () => {
+      fs.writeFileSync(path.join(tempDir, 'cjs.js'), [
+        "import fs from 'node:fs'",
+        'function helper() { return fs }',
+        'const text = `outer ${`inner ${module.exports = { helper }}`}`',
+      ].join('\n'));
+      fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'helper()');
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+      const helper = cg.getNodesByKind('function').find((n) => n.name === 'helper');
+      expect(helper).toBeDefined();
+      expect(cg.getIncomingEdges(helper!.id).some((e) =>
+        e.kind === 'calls' && cg.getNode(e.source)?.filePath === 'consumer.js')).toBe(true);
+    });
+
+    it.each(['export function visible() { return fs }', 'function visible() { return fs }\nexport { visible }'])('preserves real exports after a regex containing a backtick: %s', async (declaration) => {
+      fs.writeFileSync(path.join(tempDir, 'exported.js'), "import fs from 'node:fs'\nconst re = /`/\nif (fs) /`/.test('text')\nelse /`/.test('other')\nconst make = () => /`/\n" + declaration + '\n');
+      fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'visible()');
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+      const visible = cg.getNodesByKind('function').find((n) => n.name === 'visible');
+      expect(visible).toBeDefined();
+      expect(cg.getIncomingEdges(visible!.id).some((e) => e.kind === 'calls')).toBe(true);
+    });
+
+    // On vitejs/vite, every `import { defineConfig } from 'vite'` across the
+    // playground resolved onto `playground/ssr-html/test-stacktrace.js::vite`
+    // — `const vite = await createServer(…)` at module scope in a file with
+    // zero exports — because exact-match commits whenever one candidate
+    // survives, and nothing asked whether an import could reach it. Only
+    // `sealed.js` may be filtered; every other file here is a class that must
+    // NOT be — a classic script (a top-level binding really is a reachable
+    // global), a CommonJS module, one exporting through `exports["x"]`, an ESM
+    // file whose export is a later `export { … }` statement (which leaves
+    // `isExported` false on the declaration's node), and one contributing a
+    // name through `declare global` while exporting nothing of its own.
+    let tmpDir: string;
+    let cg: CodeGraph;
+
+    afterEach(() => {
+      cg?.close();
+      if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    });
+
+    it('drops them as cross-file candidates, and keeps scripts, CJS and later exports', async () => {
+      tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1719-'));
+      fs.writeFileSync(
+        path.join(tmpDir, 'sealed.js'),
+        `import fsp from 'node:fs/promises'
+
+function widget() {
+  return fsp
+}
+
+widget()
+`
+      );
+      fs.writeFileSync(
+        path.join(tmpDir, 'script.js'),
+        `function gadget() {
+  return 1
+}
+`
+      );
+      fs.writeFileSync(
+        path.join(tmpDir, 'cjs.js'),
+        `import osp from 'node:os'
+
+function helper() {
+  return osp
+}
+
+module.exports = { helper }
+`
+      );
+      fs.writeFileSync(
+        path.join(tmpDir, 'later.js'),
+        `import pathp from 'node:path'
+
+function parser() {
+  return pathp
+}
+
+export { parser }
+`
+      );
+      // `exports["x"]` is a CommonJS export too, and a file declaring globals
+      // offers them to every other file whether or not it exports anything of
+      // its own. Both would read as sealed on a test that looked only for
+      // `export …`, `module.exports` and `exports.x`.
+      fs.writeFileSync(
+        path.join(tmpDir, 'bracket.js'),
+        `import urlp from 'node:url'
+
+function bracketed() {
+  return urlp
+}
+
+exports["bracketed"] = bracketed
+`
+      );
+      // A module with imports and no export of its own still contributes every
+      // name in `declare global` to every other file. `plain.ts` is the control
+      // that makes the assertion mean something: it is the same "import, no
+      // export" shape holding the same kind of declaration, so the pair differs
+      // only by the `declare global`, and an assertion on StrayFace alone would
+      // pass whatever the guard did.
+      fs.writeFileSync(
+        path.join(tmpDir, 'ambient.ts'),
+        `import './later'
+
+declare global {
+  interface StrayFace {
+    a: number
+  }
+}
+`
+      );
+      fs.writeFileSync(
+        path.join(tmpDir, 'plain.ts'),
+        `import './later'
+
+interface HiddenFace {
+  a: number
+}
+
+const unused: HiddenFace = { a: 1 }
+`
+      );
+      // A type annotation is the reference here, so this consumer must be .ts.
+      fs.writeFileSync(
+        path.join(tmpDir, 'consumer.ts'),
+        `const face: StrayFace = { a: 1 }
+const hidden: HiddenFace = { a: 2 }
+
+export function use(): number {
+  return face.a + hidden.a
+}
+`
+      );
+      // Nothing here is bound by an import, so every name is a free reference
+      // that falls through to exact name matching — the path this rule sits on.
+      // A bare import would reach that path too, but a bare specifier names a
+      // package outside the graph, so no project node is the right target for
+      // it and such a fixture would assert a resolution nothing should make.
+      fs.writeFileSync(
+        path.join(tmpDir, 'consumer.js'),
+        `widget()
+gadget()
+helper()
+parser()
+bracketed()
+`
+      );
+
+      cg = await CodeGraph.init(tmpDir, { index: true });
+      cg.resolveReferences();
+
+      // Incoming edges rather than callers, so the interfaces are asked the
+      // same question as the functions: a type annotation is a reference, not
+      // a call.
+      const reachedFrom = (consumer: string, name: string): boolean => {
+        const target = cg
+          .searchNodes(name, { limit: 10 })
+          .find((r) => r.node.name === name && r.node.filePath !== consumer);
+        expect(target, `no node named ${name}`).toBeDefined();
+        return cg
+          .getIncomingEdges(target!.node.id)
+          .some((e) => cg.getNode(e.source)?.filePath === consumer);
+      };
+
+      expect(reachedFrom('consumer.js', 'widget')).toBe(false);
+      expect(reachedFrom('consumer.js', 'gadget')).toBe(true);
+      expect(reachedFrom('consumer.js', 'helper')).toBe(true);
+      expect(reachedFrom('consumer.js', 'parser')).toBe(true);
+      expect(reachedFrom('consumer.js', 'bracketed')).toBe(true);
+      expect(reachedFrom('consumer.ts', 'StrayFace')).toBe(true);
+      expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false);
+    }, 30000);
+  });
 });
 });

+ 2 - 37
src/mcp/dynamic-boundaries.ts

@@ -21,7 +21,8 @@
  * inside a string is a false positive, so {@link blankStringContents} blanks
  * inside a string is a false positive, so {@link blankStringContents} blanks
  * them too, quotes preserved.)
  * 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 {
 export interface BoundaryMatch {
   /** Stable form id, e.g. 'computed-call' — used for per-form dedupe. */
   /** 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_MATCHES_PER_BODY = 3;
 const MAX_BODY_CHARS = 60_000; // a god-function tail is still scannable; beyond this, truncate
 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.
  * Scan one symbol's body for dynamic-dispatch sites.

+ 2 - 0
src/resolution/index.ts

@@ -2458,6 +2458,8 @@ export class ReferenceResolver {
     if (!result) return result;
     if (!result) return result;
     if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
     if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
     const tgt = this.getLanguageFromNodeId(result.targetNodeId);
     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;
     if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
     return result;
     return result;
   }
   }

+ 132 - 5
src/resolution/name-matcher.ts

@@ -7,6 +7,7 @@
 import * as path from 'path';
 import * as path from 'path';
 import { Language, Node } from '../types';
 import { Language, Node } from '../types';
 import { UnresolvedRef, ResolvedRef, ResolutionContext } 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
  * 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
  * 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
  * 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
  *   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 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`.
  *   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
  * Same-file candidates are always visible. Applied by ReferenceResolver to
  * the target the whole name-matching pipeline settled on, so a rejection ends
  * 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 + '/');
     return ref.filePath.startsWith(owner + '/');
   }
   }
   if (PRIVATE_IS_FILE_LOCAL.has(lang)) return candidate.visibility !== 'private';
   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)
   const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
     .filter((n) => n.kind !== 'import')
     .filter((n) => n.kind !== 'import')
     // Nested locals are only reachable from inside their container (#1230).
     // 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) {
   if (candidates.length === 0) {
     return null;
     return null;
@@ -559,6 +673,7 @@ export function matchByExactName(
 
 
   // If only one match, use it — but penalize cross-language matches
   // If only one match, use it — but penalize cross-language matches
   if (candidates.length === 1) {
   if (candidates.length === 1) {
+    if (!isCrossFileReachable(candidates[0]!, ref, context)) return null;
     const isCrossLanguage = candidates[0]!.language !== ref.language;
     const isCrossLanguage = candidates[0]!.language !== ref.language;
     return {
     return {
       original: ref,
       original: ref,
@@ -579,7 +694,7 @@ export function matchByExactName(
 
 
   // Multiple matches - try to narrow down
   // Multiple matches - try to narrow down
   const bestMatch = findBestMatch(ref, candidates, context);
   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
     // Lower confidence when the match is from a distant/unrelated module
     const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
     const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
     const confidence = proximity >= 30 ? 0.7 : 0.4;
     const confidence = proximity >= 30 ? 0.7 : 0.4;
@@ -1445,6 +1560,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
   INFER_SCAN_STATES.delete(context);
   INFER_SCAN_STATES.delete(context);
   C_STATIC_MEMO.delete(context);
   C_STATIC_MEMO.delete(context);
   RUST_TRAIT_IMPL_MEMO.delete(context);
   RUST_TRAIT_IMPL_MEMO.delete(context);
+  SEALED_MODULES.delete(context);
 }
 }
 
 
 function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
 function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
@@ -2558,13 +2674,24 @@ export function matchFuzzy(
 
 
   // Filter to callable kinds only (function, method, class)
   // Filter to callable kinds only (function, method, class)
   const callableKinds = new Set(['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
   // Prefer same-language matches
   const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
   const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
   const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
   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;
     const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
     return {
     return {
       original: ref,
       original: ref,

+ 46 - 0
src/resolution/strip-comments.ts

@@ -23,6 +23,52 @@
  * framework extractors scan for.
  * 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 =
 export type CommentLang =
   | 'python'
   | 'python'
   | 'javascript'
   | 'javascript'