diff --git a/CHANGELOG.md b/CHANGELOG.md index 879d07a53..25296bd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -216,6 +216,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. - In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, a function a file loads with a dynamic import from Node or a package, like `const { createServer } = await import('node:http')`, `const { default: chalk } = await import('chalk')` or `import('node:fs/promises').then(({ readFile }) => …)`, no longer links to a project function that only shares its name. Before, vite's call to Node's `createServer` showed up among the callers of vite's own `createServer`. The import counts only where it is in scope, so the rest of the file links as before, and a dynamic import of your own project's module still leads to that module. Thanks @Ancienttwo for the report. (#2491) +- In JavaScript and TypeScript, code outside your tests no longer links to a Jest or Vitest manual mock under `__mocks__/`. Before, when an import of a workspace package couldn't be followed, a call like `formatLabel(name)` linked by name to the mock beside it instead of the package's own function, so the real function lost its callers, and the same happened to rendered components and event handlers. Production code is now matched as if the mocks weren't there, so it reaches the real function, and adding or deleting a mock leaves it alone. Tests, other mocks, and a file that imports a mock by its path, like a `jest.setup.ts`, still reach the mock. Thanks @Y-Osawa2022 for the report and @danusha2345. (#2455) - In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `` and `` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading. - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. diff --git a/__tests__/jest-manual-mocks.test.ts b/__tests__/jest-manual-mocks.test.ts new file mode 100644 index 000000000..ad3bdbc96 --- /dev/null +++ b/__tests__/jest-manual-mocks.test.ts @@ -0,0 +1,220 @@ +/** + * Issue #2455: a Jest (or Vitest) manual mock under `__mocks__/` stands in for + * a module only while a test runs. Production code never means it: when + * `import { formatLabel } from 'acme-shared'` could not be followed, the + * production call fell back to name matching and bound to + * `__mocks__/acme-shared.ts`, the nearer namesake, so the real function lost + * its caller. The same happened to the import's names, type references, + * constructions, `extends`, and a rendered ``. + * + * Test code may still reach a mock (that is when Jest substitutes it), and so + * may a file that imports the mock by its own path, like a `jest.setup.ts`. + * Jest's `moduleNameMapper` and automock only act while Jest runs, so a + * config that maps the package to the mock changes nothing for production + * code. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +const MOCK = 'packages/api/__mocks__/acme-shared.tsx'; +const REAL = 'packages/common/utils.tsx'; + +const FILES: Record = { + 'package.json': JSON.stringify({ name: 'acme-monorepo', private: true, workspaces: ['packages/*'] }), + 'jest.config.js': `module.exports = { moduleNameMapper: { '^acme-shared$': '/packages/api/__mocks__/acme-shared.tsx' }, automock: true }; +`, + 'packages/common/package.json': JSON.stringify({ name: 'acme-shared', version: '1.0.0' }), + [REAL]: `export function formatLabel(name: string): string { return name.trim(); } +export function isAdmin(role: string): boolean { return role === 'admin'; } +export interface Label { text: string } +export class Client { send(): number { return 1; } } +export class BaseStore { load(): number { return 1; } } +export function Chart() { return null; } +`, + 'packages/api/package.json': JSON.stringify({ name: 'acme-api', dependencies: { 'acme-shared': 'workspace:*', react: '^18.0.0' } }), + [MOCK]: `export function formatLabel(name: string): string { return 'mock'; } +export function isAdmin(role: string): boolean { return true; } +export interface Label { text: string; mocked: true } +export class Client { send(): number { return 0; } } +export class BaseStore { load(): number { return 0; } } +export function Chart() { return null; } +export function resetMocks(): void {} +`, + 'packages/api/__mocks__/helpers.ts': `export function mockHelper() { return resetMocks(); } +`, + 'packages/api/src/handler.tsx': `import { formatLabel, isAdmin, Client, BaseStore, Chart } from 'acme-shared'; +import type { Label } from 'acme-shared'; + +export function handleRequest(name: string, role: string): string { + if (!isAdmin(role)) return 'forbidden'; + return formatLabel(name); +} +export function label(l: Label): string { return l.text; } +export function send() { return new Client().send(); } +export class Store extends BaseStore {} +export function View() { return ; } +`, + 'packages/api/src/handler.test.ts': `export function setUp(): void { resetMocks(); } +`, + 'packages/api/jest.setup.ts': `import { resetMocks } from './__mocks__/acme-shared'; +resetMocks(); +`, + // An event bus: production code emits; production code registers `handleReady`, which a mock + // declares too, and a test registers the mock's `onSavedMock`. + 'packages/api/src/bus.ts': `export function start(bus: { emit(e: string): void }) { + bus.emit('ready'); + bus.emit('saved'); +} +`, + 'packages/api/src/wire.ts': `export function wire(bus: { on(e: string, f: () => void): void }) { + bus.on('ready', handleReady); +} +`, + 'packages/api/src/handlers.ts': `export function handleReady(): void {} +`, + 'packages/api/__mocks__/handlers.ts': `export function handleReady(): void {} +export function onSavedMock(): void {} +`, + 'packages/api/src/bus.test.ts': `export function listen(bus: { on(e: string, f: () => void): void }) { + bus.on('saved', onSavedMock); +} +`, + // A mock beside one of the app's own modules: an unimported call never means the mock. + 'packages/web/package.json': JSON.stringify({ name: 'web', dependencies: { vue: '^3.0.0' } }), + 'packages/web/src/api.ts': `export function fetchUser(id: string) { return id; } +`, + 'packages/web/src/__mocks__/api.ts': `export function fetchUser(id: string) { return 'mock-' + id; } +export function onlyMock() { return 2; } +`, + 'packages/web/src/page.ts': `import { fetchUser } from './api'; +export function page() { return fetchUser('1'); } +export function guess() { return onlyMock(); } +`, + 'packages/web/src/components/Badge.vue': ` +`, + 'packages/web/src/components/__mocks__/Badge.vue': ` +`, + // Only a mock declares `Pill`: the template's `` is a component from elsewhere. + 'packages/web/src/components/__mocks__/Pill.vue': ` +`, + // `__mocks__/` sorts before `avatar/`: a pick by order alone would take the mock. + 'packages/web/src/components/__mocks__/Avatar.vue': ` +`, + 'packages/web/src/components/avatar/Avatar.vue': ` +`, + 'packages/web/src/components/Card.vue': ` +`, +}; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jest-manual-mocks-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `kind name@file` of every edge but `contains` leaving the nodes of `file`. */ +function edgesFrom(file: string): string[] { + const out: string[] = []; + for (const node of cg.getNodesInFile(file)) { + for (const e of cg.getOutgoingEdges(node.id)) { + if (e.kind === 'contains') continue; + const target = cg.getNode(e.target)!; + out.push(`${e.kind} ${target.name}@${target.filePath}`); + } + } + return out; +} + +describe('a Jest manual mock under __mocks__/ (#2455)', () => { + it("is never what production code means by name: the issue's calls and imports reach the package", () => { + const edges = edgesFrom('packages/api/src/handler.tsx'); + expect(edges.filter((e) => e.endsWith(`@${MOCK}`))).toEqual([]); + // What the name means without the mock: the workspace package's own function. + expect(edges).toEqual(expect.arrayContaining([ + `calls formatLabel@${REAL}`, `calls isAdmin@${REAL}`, `imports formatLabel@${REAL}`, `imports isAdmin@${REAL}`, + ])); + }); + + it('nor a type it names, a class it constructs or extends, or a component it renders', () => { + const edges = edgesFrom('packages/api/src/handler.tsx'); + for (const kind of ['references Label', 'instantiates Client', 'extends BaseStore', 'calls Chart']) { + expect(edges).not.toContain(`${kind}@${MOCK}`); + expect(edges).toContain(`${kind}@${REAL}`); + } + }); + + it("nor a mock beside the app's own module, from a call or a Vue template", () => { + expect(edgesFrom('packages/web/src/page.ts')).not.toContain('calls onlyMock@packages/web/src/__mocks__/api.ts'); + expect(edgesFrom('packages/web/src/page.ts')).toContain('calls fetchUser@packages/web/src/api.ts'); + const card = edgesFrom('packages/web/src/components/Card.vue'); + expect(card).not.toContain('calls Badge@packages/web/src/components/__mocks__/Badge.vue'); + expect(card).not.toContain('calls Pill@packages/web/src/components/__mocks__/Pill.vue'); + expect(card).toContain('calls Badge@packages/web/src/components/Badge.vue'); + expect(card).toContain('calls Avatar@packages/web/src/components/avatar/Avatar.vue'); + }); + + it('nor a handler an event bus pairs with what production code emits', () => { + const edges = edgesFrom('packages/api/src/bus.ts'); + // Registered by production code: the handler it can mean. + expect(edges).toContain('calls handleReady@packages/api/src/handlers.ts'); + expect(edges).not.toContain('calls handleReady@packages/api/__mocks__/handlers.ts'); + // Registered by a test: what the test hands the bus, never what production code calls. + expect(edges).not.toContain('calls onSavedMock@packages/api/__mocks__/handlers.ts'); + }); + + it('is still reached from test code, from another mock, and through an import of its own path', () => { + expect(edgesFrom('packages/api/src/handler.test.ts')).toContain(`calls resetMocks@${MOCK}`); + expect(edgesFrom('packages/api/__mocks__/helpers.ts')).toContain(`calls resetMocks@${MOCK}`); + expect(edgesFrom('packages/api/jest.setup.ts')).toContain(`calls resetMocks@${MOCK}`); + }); +}); + +describe('sync: adding or deleting a manual mock leaves production code as it was (#2455)', () => { + it('matches the graph before the mock came and after it went', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jest-manual-mocks-sync-')); + const write = (files: Record): void => { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + } + }; + write({ + 'package.json': FILES['package.json']!, + 'packages/common/package.json': FILES['packages/common/package.json']!, + [REAL]: FILES[REAL]!, + 'packages/api/package.json': FILES['packages/api/package.json']!, + 'packages/api/src/handler.tsx': FILES['packages/api/src/handler.tsx']!, + }); + const graph = await CodeGraph.init(dir, { index: true }); + const production = (): string[] => + graph.getNodesInFile('packages/api/src/handler.tsx').flatMap((n) => + graph.getOutgoingEdges(n.id).filter((e) => e.kind !== 'contains').map((e) => `${e.kind} ${graph.getNode(e.target)!.name}@${graph.getNode(e.target)!.filePath}`)).sort(); + try { + const before = production(); + expect(before).toContain(`calls formatLabel@${REAL}`); + write({ [MOCK]: FILES[MOCK]! }); + await graph.sync(); + expect(production()).toEqual(before); + fs.rmSync(path.join(dir, MOCK)); + await graph.sync(); + expect(production()).toEqual(before); + } finally { + graph.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0dac3e1e1..4c2755688 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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(); 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 `` 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, 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 { + const ids = new Set(); + 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(); 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'); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 055e96ba6..5f2e3494a 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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(); + const byLowerName = new Map(); + const byKind = new Map(); + 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; lowerNames: Set } | null | undefined; + + private manualMockNames(): { names: Set; lowerNames: Set } | null { + if (this.manualMocks !== undefined) return this.manualMocks; + const files = [...(this.knownFiles ?? this.queries.getAllFilePaths())].filter(isManualMockPath); + const names = new Set(); + const lowerNames = new Set(); + 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, '/'); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0862d9814..a396c4208 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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 `` 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('.');