From ab6ec218ff5d1442109420e41bc2b8d32e203347 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 18:43:16 +0000 Subject: [PATCH] fix(js,ts): a function value imported from the project links only to what its import binds (#2673) A function passed as a value whose name the file imports from its own project still fell back to the project's one function of that name whenever the import resolver bound the name to something that is not a function: outline's `isCloudHosted` constant landed on the server's `isCloudHosted()` check, its `Star` model class on the `` icon, `import * as Slack` on the Slack settings component, and topcoder's `import TCO21 from './themes/TCO21.scss'` on a route component. matchFunctionRef's unique cross-file fallback now also declines when a project import binds the name to something else (importBindsOther): a namespace import, an asset import (stylesheet, image, GraphQL, JSON, font, media; judged by the specifier's extension), or an import the import resolver binds to a node that is not a function or method. A wrapper declared beside the namesake (`export default styled(Disclosure)`) and a lazy loader (`lazy(() => import(...))`) still lead to it, and an import the resolver can't follow at all keeps the fallback. Follow-up to #2671. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../function-ref-project-imports.test.ts | 225 ++++++++++++++++++ src/resolution/name-matcher.ts | 31 ++- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 __tests__/function-ref-project-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbf0745e..0a4534055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `

Don't have an account? `, still links as before. Re-index React projects after upgrading. - 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 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. - `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. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. diff --git a/__tests__/function-ref-project-imports.test.ts b/__tests__/function-ref-project-imports.test.ts new file mode 100644 index 000000000..866280dea --- /dev/null +++ b/__tests__/function-ref-project-imports.test.ts @@ -0,0 +1,225 @@ +/** + * A function passed as a value whose name the file imports from its own + * project names what that import binds. When the import resolver binds it to + * something that is not a function, the function-as-value matcher still fell + * back to the one project function of that name elsewhere: + * - outline's `import isCloudHosted from '~/utils/isCloudHosted'` (a boolean + * constant) landed on the server's `isCloudHosted()` policy helper, and its + * `Star` model class on the `` icon component; + * - outline's `import * as Slack from '../slack'` (a module object) landed on + * the Slack plugin's settings component; + * - topcoder's `import TCO21 from './themes/TCO21.scss'` landed on the TCO21 + * community's routes, and cord-field's `.graphql` document on a component. + * + * A wrapper declared beside the function it wraps (`export default + * styled(Disclosure)`) and a lazy loader (`lazy(() => import('../pages/Home'))`) + * still lead to the namesake, as does an import the import resolver can't + * follow at all (a barrel it doesn't read). + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-project-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +/** ` -> :` for each function-as-value edge leaving `file`. */ +function fnRefsFrom(cg: CodeGraph, file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return [...new Set(cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.metadata?.fnRef === true) + .map((e) => { + const target = cg.getNode(e.target); + return `${String(e.metadata?.refName)} -> ${target?.filePath}:${target?.qualifiedName}`; + }))] + .sort(); +} + +const PACKAGE_JSON = JSON.stringify({ name: 'wiki', dependencies: { react: '^18.2.0', 'styled-components': '^6.1.0' } }); +const TSCONFIG = JSON.stringify({ compilerOptions: { baseUrl: '.', jsx: 'react-jsx' } }); + +describe('a function value the file imports from its own project', () => { + it('names the constant, class or module the import binds, not a function elsewhere', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + // What the imports bind: a constant, a model class and a module. + 'app/utils/isCloudHosted.ts': `export const isCloudHosted = window.location.host.endsWith('.example.com'); +`, + 'app/models/Star.ts': `export default class Star { + id = ''; +} +`, + 'plugins/slack/server/slack.ts': `export function post(text: string): string { return text; } +`, + // The project's only functions of those names, somewhere else. + 'server/policies/utils.ts': `export function isCloudHosted(): boolean { return true; } +`, + 'app/components/Star.tsx': `export function Star() { + return ; +} +`, + 'plugins/slack/client/Settings.tsx': `export function Slack() { + return

; +} +`, + 'app/scenes/Settings.ts': `import { isCloudHosted } from '../utils/isCloudHosted'; +import Star from '../models/Star'; + +export function settings(register: (value: unknown) => void): void { + register(isCloudHosted); + register(Star); +} +`, + 'plugins/slack/server/hooks.test.ts': `import * as Slack from './slack'; + +export function spyOn(spy: (module: unknown) => void): void { + spy(Slack); +} +`, + }); + try { + expect({ + settings: fnRefsFrom(cg, 'app/scenes/Settings.ts'), + hooks: fnRefsFrom(cg, 'plugins/slack/server/hooks.test.ts'), + }).toEqual({ settings: [], hooks: [] }); + } finally { + cg.close(); + } + }, 60_000); + + it('never names a function when it imports a stylesheet, an image or a GraphQL document', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + 'src/components/Countdown/themes/TCO21.scss': `.countdown { color: red; } +`, + 'src/assets/img-QA.png': '', + 'src/assets/visibility.svg': ` +`, + 'src/scenes/Story/Story.graphql': `mutation CreateStory { createStory { id } } +`, + // The project's only functions of those names. + 'src/routes/TCO21/Routes.tsx': `export function TCO21() { + return
; +} +`, + 'src/routes/QA/Routes.tsx': `export function QA() { + return
; +} +`, + 'src/components/VisibilityIcon.tsx': `export function VisibilityIcon() { + return ; +} +`, + 'src/scenes/Story/CreateStory.tsx': `export function CreateStory() { + return
; +} +`, + 'src/components/Countdown/index.tsx': `import TCO21 from './themes/TCO21.scss'; +import QA from '../../assets/img-QA.png'; +import VisibilityIcon from '../../assets/visibility.svg?react'; +import { CreateStoryDocument as CreateStory } from '../../scenes/Story/Story.graphql'; + +export function themes(pick: (value: unknown) => void): void { + pick(TCO21); + pick(QA); + pick(VisibilityIcon); + pick(CreateStory); +} +`, + }); + try { + expect(fnRefsFrom(cg, 'src/components/Countdown/index.tsx')).toEqual([]); + } finally { + cg.close(); + } + }, 60_000); + + it('keeps a wrapper beside its function, a lazy loader, a barrel and a plain import', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + // The default export wraps the function declared beside it. + 'app/components/Disclosure.tsx': `import styled from 'styled-components'; + +function Disclosure(props: { open: boolean }) { + return