fix(js,ts): code outside tests resolves as if Jest manual mocks were not there (#2790)

Issue 2455: when production code imports a workspace package the resolver
cannot follow (`import { formatLabel } from 'acme-shared'`), its names fall
back to name matching, and a Jest manual mock beside the code
(`__mocks__/acme-shared.ts`) is the nearest namesake. So the call, the
import's names, type references, constructions and `extends` all linked to
the mock, and the real function lost its callers. The visibility gate keeps
test suites from production code, but `__mocks__/` is not one of them.
jsx-render, Vue templates and the event-emitter pass pick by name with
their own lookups, which no gate covers.

A manual mock stands in for a module only while Jest or Vitest runs, and it
re-declares that module's names by design. Rejecting it after the ranking
picked it (what the gate does for `__tests__/`) leaves the call unlinked even
where the real function is the only other candidate. It also leaves sync
behind a fresh index: deleting the mock re-opens nothing, while a fresh
index then links the real function (main has this today for `__tests__/`).

Fix:
- ReferenceResolver.resolveOne resolves a reference from code that is not a
  test (isTestPath) on a view of the graph without manual mocks, when the
  project has any. Every lookup by name, lower-case name, qualified name,
  kind and method answers as if no `__mocks__/` file were indexed. Only
  names a mock declares are filtered, and those answers are cached. The
  view is swapped in as this.context for the call, so every strategy
  (frameworks, imports, name matching, fuzzy, function refs) reads it, and
  clearCaches drops it with the memos keyed by it. Test code keeps the
  whole graph.
- jsxChild, the Vue template resolver and the event-emitter handler lookup
  skip a mock's component or function for a non-test file
  (isManualMockHiddenFrom).
- The synthesized-edge merge drops any edge from non-test code into a node a
  manual mock declares, whatever pass made it.
- An import that names the mock's own path (`import x from
  './__mocks__/uuid'` in a vitest setup file) still links. Jest's
  `moduleNameMapper` and automock are not read: they act only while Jest
  runs, so they never change what production code calls.

Validation:
- __tests__/jest-manual-mocks.test.ts: 5 of 6 tests fail with main's
  sources. Each of five rules fails a test when removed: the view (three
  tests, the in-process sync case among them, which adds and deletes a
  mock), the JSX, Vue and event lookups, and the merge guard.
- Sync equals a fresh index (CLI) when the mock or the real module arrives
  later, when the production file is edited, and when the mock is deleted.
- A/B on 8 TS/JS repos plus 2 controls, both arms with the kernel. Edges
  from production code into mocks: webdriverio 23 to 0, outline 21 to 0,
  social-app 3 to 0, insomnia 19 to 13 (the 13 left are setup-vitest.ts
  importing mocks by path). webdriverio gains 23 right edges and 142
  confidence raises that the mocks had blocked. jest, nuxt, flask and gin
  are byte-identical. On webdriverio, production code's edges equal main's
  on a copy without the mocks, 20,885 of 20,885 with metadata. insomnia
  matches the same way, apart from the 13 path imports.
- Cost: fresh index, median of 3 interleaved: webdriverio 12.67 s main vs
  12.71 s, insomnia 12.84 s vs 12.60 s.

Fixes #2455

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 08:10:10 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 214ef50efe
commit e69546e4a3
5 changed files with 368 additions and 5 deletions
+27 -4
View File
@@ -41,10 +41,11 @@ import { createYielder, type MaybeYield } from './cooperative-yield';
import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer';
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { cppSpecializationArguments, crossesCodeBoundary, goRefQualification, jsCodeBindsName } from './name-matcher';
import { cppSpecializationArguments, crossesCodeBoundary, goRefQualification, isManualMockHiddenFrom, isManualMockPath, jsCodeBindsName } from './name-matcher';
import { goStdlibMethods } from './go-stdlib-methods';
import { declaredComponent, exportedComponent, lazyValueModules, wrappedComponentName } from './frameworks/react';
import { jsxCode } from './jsx-code';
import { isTestPath } from '../search/query-utils';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -350,7 +351,9 @@ async function eventEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
while ((m = ON_RE.exec(content))) {
const handlerName = m[2] || m[3];
if (!handlerName) continue;
const handler = ctx.getNodesByName(handlerName).find((n) => n.kind === 'function' || n.kind === 'method');
// A manual mock's function is a handler only test code registers (#2455).
const handler = ctx.getNodesByName(handlerName).find((n) =>
(n.kind === 'function' || n.kind === 'method') && !isManualMockHiddenFrom(n.filePath, file));
if (!handler) continue;
const map = handlersByEvent.get(m[1]!) ?? new Map<string, string>();
map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map);
@@ -1984,7 +1987,9 @@ function jsxChild(
): Node | undefined {
const own = ownValueOf(name);
if (own) return own.component ?? own.declaration;
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
// A manual mock's component renders only in a test (#2455): production
// code's `<Chart />` never means `__mocks__/…`'s `Chart`.
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind) && !isManualMockHiddenFrom(n.filePath, file));
const local = candidates.find((n) => n.filePath === file);
if (local) return local;
if (importedFromPackage(ctx, name, file)) return undefined;
@@ -2668,7 +2673,7 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
// the package's: no other project symbol, nor a Nuxt auto-imported
// component (`fallback`), is what it renders or calls.
const resolve = (name: string, kinds: Set<string>, fallback?: Node): Node | undefined => {
const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind) && !isManualMockHiddenFrom(n.filePath, file));
const local = matches.find((n) => n.filePath === file);
if (local || importedFromPackage(ctx, name, file)) return local;
return matches[0] ?? fallback;
@@ -5173,6 +5178,16 @@ function synthPassCostHint(name: string): number {
/** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */
const FIXED_SYNTH_STEPS = 4;
export const SYNTH_PROGRESS_STEPS = SYNTH_PASSES.length + FIXED_SYNTH_STEPS;
/** The ids of every node a Jest or Vitest manual mock (`__mocks__/`) declares. */
function manualMockNodeIds(ctx: ResolutionContext): Set<string> {
const ids = new Set<string>();
for (const file of ctx.getAllFiles()) {
if (!isManualMockPath(file)) continue;
for (const n of ctx.getNodesInFile(file)) ids.add(n.id);
}
return ids;
}
export async function synthesizeCallbackEdges(
queries: QueryBuilder,
ctx: ResolutionContext,
@@ -5390,12 +5405,20 @@ export async function synthesizeCallbackEdges(
}
}
// A Jest or Vitest manual mock answers only for test code (#2455): whatever
// pass paired them, production code never links into a `__mocks__/` file.
const mockNodes = manualMockNodeIds(ctx);
const fromTestCode = (id: string): boolean => {
const source = ctx.getNodeById?.(id);
return !source || isTestPath(source.filePath);
};
const merged: Edge[] = [];
const seen = new Set<string>();
for (const e of passEdges.flat()) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
seen.add(key);
if (mockNodes.size > 0 && mockNodes.has(e.target) && !fromTestCode(e.source)) continue;
merged.push(e);
}
__mark('dedupe-merge');
+104 -1
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding, isManualMockPath } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
@@ -51,6 +51,7 @@ import { lexicalPathWithinRoot } from '../utils';
import type { CrossFileNodes, ReExport } from './types';
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
import { LRUCache } from './lru-cache';
import { isTestPath } from '../search/query-utils';
import { JS_BUILT_INS } from './js-builtins';
import { builtinModules } from 'module';
import { parse as parseJsonc } from 'jsonc-parser';
@@ -551,6 +552,9 @@ export class ReferenceResolver {
* Clear internal caches
*/
clearCaches(): void {
this.mocklessContext = null;
this.mocklessBase = null;
this.manualMocks = undefined;
this.nodeCache.clear();
this.fileCache.clear();
this.importMappingCache.clear();
@@ -1335,6 +1339,22 @@ export class ReferenceResolver {
}
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
// Production code is resolved as if the project's manual mocks were not
// indexed (see mocklessContextFor); every strategy below reads the graph
// through this.context, so it answers for that view.
const outer = this.context;
const base = outer === this.mocklessContext && this.mocklessBase ? this.mocklessBase : outer;
const context = this.mocklessContextFor(ref, base) ?? base;
if (context === outer) return this.resolveOneIn(ref);
this.context = context;
try {
return this.resolveOneIn(ref);
} finally {
this.context = outer;
}
}
private resolveOneIn(ref: UnresolvedRef): ResolvedRef | null {
// A C/C++ "call" whose name is a function-like macro visible in this
// translation unit is a macro expansion, not a call — it must never bind
// to a same-named function in another file (#1838).
@@ -3730,6 +3750,89 @@ export class ReferenceResolver {
return ref.referenceKind === 'calls' && !isRustForeignImplCallable(target, ref, this.context) ? null : result;
}
/**
* The context production code is resolved in when the project has Jest or
* Vitest manual mocks (`__mocks__/`): the graph without them. A mock
* re-declares its module's names by design and stands in for the module
* only while a test runs, so production code means what it would mean were
* the mocks not there (#2455): `formatLabel(name)`, whose import of a
* workspace package was not followed, is the package's `formatLabel`, never
* the nearer mock's. Adding or deleting a mock then changes nothing for
* production code, as a fresh index would have it. Test code, a mock
* included, keeps the whole graph: a mock is what Jest hands it. Null when
* the whole graph applies.
*/
private mocklessContextFor(ref: UnresolvedRef, base: ResolutionContext): ResolutionContext | null {
if (isTestPath(ref.filePath)) return null;
const mocks = this.manualMockNames();
if (!mocks) return null;
if (this.mocklessContext && this.mocklessBase === base) return this.mocklessContext;
const isMock = (n: Node): boolean => isManualMockPath(n.filePath);
const real = (nodes: Node[]): Node[] => (nodes.some(isMock) ? nodes.filter((n) => !isMock(n)) : nodes);
// Only a name some mock declares can lose a node: other lookups pass
// straight through, and the filtered answers are kept.
const byName = new Map<string, Node[]>();
const byLowerName = new Map<string, Node[]>();
const byKind = new Map<Node['kind'], Node[]>();
const getMethodMatches = base.getMethodMatches;
const iterateNodesByKind = base.iterateNodesByKind;
this.mocklessBase = base;
this.mocklessContext = {
...base,
getNodesByName: (name) => {
if (!mocks.names.has(name)) return base.getNodesByName(name);
let nodes = byName.get(name);
if (!nodes) byName.set(name, (nodes = real(base.getNodesByName(name))));
return nodes;
},
getNodesByLowerName: (lowerName) => {
if (!mocks.lowerNames.has(lowerName)) return base.getNodesByLowerName(lowerName);
let nodes = byLowerName.get(lowerName);
if (!nodes) byLowerName.set(lowerName, (nodes = real(base.getNodesByLowerName(lowerName))));
return nodes;
},
getNodesByQualifiedName: (qualifiedName) => real(base.getNodesByQualifiedName(qualifiedName)),
getNodesByKind: (kind) => {
let nodes = byKind.get(kind);
if (!nodes) byKind.set(kind, (nodes = real(base.getNodesByKind(kind))));
return nodes;
},
getMethodMatches: getMethodMatches
? (typeName, methodName, language) => {
const nodes = getMethodMatches(typeName, methodName, language);
return mocks.names.has(methodName) ? real(nodes) : nodes;
}
: undefined,
iterateNodesByKind: iterateNodesByKind
? function* (kind) {
for (const n of iterateNodesByKind(kind)) if (!isMock(n)) yield n;
}
: undefined,
};
return this.mocklessContext;
}
/** Built on first use from `mocklessBase`; clearCaches drops it, and the memos keyed by it go with it. */
private mocklessContext: ResolutionContext | null = null;
private mocklessBase: ResolutionContext | null = null;
/** The names the project's manual mocks declare, null when it has none; read once per cache window. */
private manualMocks: { names: Set<string>; lowerNames: Set<string> } | null | undefined;
private manualMockNames(): { names: Set<string>; lowerNames: Set<string> } | null {
if (this.manualMocks !== undefined) return this.manualMocks;
const files = [...(this.knownFiles ?? this.queries.getAllFilePaths())].filter(isManualMockPath);
const names = new Set<string>();
const lowerNames = new Set<string>();
if (files.length > 0) {
for (const n of this.queries.getNodesByFiles(files)) {
names.add(n.name);
lowerNames.add(n.name.toLowerCase());
}
}
this.manualMocks = names.size > 0 ? { names, lowerNames } : null;
return this.manualMocks;
}
/** Is `filePath` (project-relative) one of the files the index holds? */
private isIndexedFile(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
+16
View File
@@ -1736,6 +1736,22 @@ function isTestSuitePath(filePath: string): boolean {
return /(?:^|\/)(?:tests?|__tests__|specs?|e2e|unittests|[\w.]+[-_]unittests?)\//.test(lower) || /(?:^|\/)[A-Za-z0-9]*(?:Test|Tests|Spec)\//.test(filePath);
}
/** A Jest or Vitest manual mock: a file under a `__mocks__/` directory. */
export function isManualMockPath(filePath: string): boolean {
return /(?:^|\/)__mocks__\//.test(filePath.replace(/\\/g, '/').toLowerCase());
}
/**
* Whether a definition in `candidatePath` is a manual mock that a reference
* from `fromPath`, which is no test code, cannot mean (#2455): the rule the
* resolver's mock-free view applies to name matching, for the passes that
* pick a component or a handler by name. A production `<Chart />` never
* renders a mock's `Chart`.
*/
export function isManualMockHiddenFrom(candidatePath: string, fromPath: string): boolean {
return isManualMockPath(candidatePath) && !isTestPath(fromPath);
}
const fileStem = (filePath: string): string => {
const name = filePath.slice(filePath.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');