Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)

## [0.9.9] - 2026-06-02

Expand Down
141 changes: 141 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,147 @@ func main() {
const callers = cg.getCallers(signInNode!.id);
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
});

it('follows a default re-export of a .svelte component (export { default as Foo } from ./RealButton.svelte) (#629)', async () => {
// The ubiquitous Svelte/React component-barrel form. The leaf is a
// .svelte component (extracted as kind 'component', the default
// export). The re-export ALIAS (`Foo`) deliberately differs from the
// component's real name (`RealButton`) so the name-matcher fallback
// can't coincidentally connect them — the only path to the edge is
// the import-chase, which must match a `component` (not just
// function/class) for the default export. Otherwise the
// consumer↔component edge is never created and `callers` returns a
// false 0.
fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src/lib/RealButton.svelte'),
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/lib/index.ts'),
`export { default as Foo } from './RealButton.svelte';\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/Bar.svelte'),
`<script lang="ts">\n import { Foo } from './lib';\n</script>\n\n<Foo />\n`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const fooNode = cg
.getNodesByKind('component')
.find((n) => n.name === 'RealButton' && n.filePath === 'src/lib/RealButton.svelte');
expect(fooNode).toBeDefined();
const callers = cg.getCallers(fooNode!.id);
expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true);
});

it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => {
// `import { helper } from '.'` (or './') must map to the
// directory's index.ts before the re-export chase can run. The
// barrel renames `realHelper` → `helper` so the name-matcher can't
// mask a path-resolution failure: only the bare-dir resolution +
// rename chase can connect the edge.
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src/util.ts'),
`export function realHelper(): void {}\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/index.ts'),
`export { realHelper as helper } from './util';\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/main.ts'),
`import { helper } from '.';\nexport function go(): void { helper(); }\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/main2.ts'),
`import { helper } from './';\nexport function go2(): void { helper(); }\n`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const helperNode = cg
.getNodesByKind('function')
.find((n) => n.name === 'realHelper' && n.filePath === 'src/util.ts');
expect(helperNode).toBeDefined();
const callers = cg.getCallers(helperNode!.id);
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
expect(callers.some((c) => c.node.filePath === 'src/main2.ts')).toBe(true);
});

it('resolves a workspace package-subpath barrel (@scope/pkg/sub) to its index (#629)', async () => {
// bun/npm/pnpm workspace: `@scope/ui/widgets` → the `ui` package's
// `widgets/` subdir index, which re-exports a .svelte component.
// Alias `Thing` ≠ component `Widget` defeats the name-matcher, so
// only workspace-package resolution can connect the edge.
fs.mkdirSync(path.join(tempDir, 'packages/ui/widgets'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }, null, 2)
);
fs.writeFileSync(
path.join(tempDir, 'packages/ui/package.json'),
JSON.stringify({ name: '@scope/ui', version: '1.0.0' }, null, 2)
);
fs.writeFileSync(
path.join(tempDir, 'packages/ui/widgets/Widget.svelte'),
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
);
fs.writeFileSync(
path.join(tempDir, 'packages/ui/widgets/index.ts'),
`export { default as Thing } from './Widget.svelte';\n`
);
fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'app/App.svelte'),
`<script lang="ts">\n import { Thing } from '@scope/ui/widgets';\n</script>\n\n<Thing />\n`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const buttonNode = cg
.getNodesByKind('component')
.find((n) => n.name === 'Widget' && n.filePath === 'packages/ui/widgets/Widget.svelte');
expect(buttonNode).toBeDefined();
const callers = cg.getCallers(buttonNode!.id);
expect(callers.some((c) => c.node.filePath === 'app/App.svelte')).toBe(true);
});

it('resolves a barrel import from a Vue SFC <script> block (#629)', async () => {
// The same import-resolution gaps (no SFC import mappings, no SFC
// extension list, barrel parsed in the consumer's language) broke
// Vue SFCs too. Guards the resolver-side generalization to `.vue`.
// The barrel renames `realRun` → `run` so only the import-chase (not
// the name-matcher) can connect the call.
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src/util.ts'),
`export function realRun(): void {}\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/index.ts'),
`export { realRun as run } from './util';\n`
);
fs.writeFileSync(
path.join(tempDir, 'src/App.vue'),
`<script lang="ts">\nimport { run } from './';\nexport default { mounted() { run(); } };\n</script>\n<template><div/></template>\n`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const runNode = cg
.getNodesByKind('function')
.find((n) => n.name === 'realRun' && n.filePath === 'src/util.ts');
expect(runNode).toBeDefined();
const callers = cg.getCallers(runNode!.id);
expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true);
});
});

describe('C/C++ Import Resolution', () => {
Expand Down
51 changes: 48 additions & 3 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
import { applyAliases } from './path-aliases';
import { resolveWorkspaceImport } from './workspace-packages';

/**
* Extension resolution order by language
Expand All @@ -18,6 +19,11 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
// SFC consumers import plain TS/JS, sibling components, and barrels
// (`./lib` → `./lib/index.ts`). Without a list, relative imports from a
// `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629).
svelte: ['.ts', '.js', '.svelte', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.svelte'],
vue: ['.ts', '.js', '.vue', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.vue'],
python: ['.py', '/__init__.py'],
go: ['.go'],
rust: ['.rs', '/mod.rs'],
Expand Down Expand Up @@ -124,6 +130,15 @@ function isExternalImport(
return false;
}

// Workspace-member imports (`@scope/ui`, `@scope/ui/widgets`) are LOCAL to
// a monorepo even though they look like bare npm specifiers. Consult the
// workspace map first so they aren't misclassified as external (#629). The
// map is null for single-package repos, so this is a no-op there.
const workspaces = context?.getWorkspacePackages?.();
if (workspaces && resolveWorkspaceImport(importPath, workspaces)) {
return false;
}

// Common external patterns
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
// Node built-ins
Expand Down Expand Up @@ -255,6 +270,18 @@ function resolveAliasedImport(
}
}

// 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`).
// Resolves a monorepo member import to the member's directory; the
// extension/index permutations below then find its barrel (#629).
const workspaces = context.getWorkspacePackages?.();
if (workspaces) {
const base = resolveWorkspaceImport(importPath, workspaces);
if (base) {
const hit = tryWithExt(base);
if (hit) return hit;
}
}

// 2. Hard-coded fallback list. Kept for projects that use these
// conventional aliases without declaring them in tsconfig.
const fallbackAliases: Record<string, string> = {
Expand Down Expand Up @@ -496,6 +523,16 @@ export function extractImportMappings(

if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
mappings.push(...extractJSImports(content));
} else if (language === 'svelte' || language === 'vue') {
// Svelte/Vue single-file components import via plain ES6 inside their
// `<script>` block. Without this, a `.svelte`/`.vue` consumer produces
// zero import mappings, so `resolveViaImport` can't run and a barrel
// import (`import { Foo } from './lib'`) falls back to name-matching —
// which silently fails whenever the re-export alias differs from the
// component's real name, yielding a false 0 callers (#629). The ES6
// import regex only matches `import … from '…'`, so running it over the
// whole SFC (markup + styles included) is safe.
mappings.push(...extractJSImports(content));
} else if (language === 'python') {
mappings.push(...extractPythonImports(content));
} else if (language === 'go') {
Expand Down Expand Up @@ -1248,9 +1285,17 @@ function findExportedSymbol(

// 1. Direct hit: the symbol is declared in this file.
if (want.isDefault) {
const direct = nodesInFile.find(
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
);
// Svelte/Vue single-file components ARE the module's default export,
// but are extracted as kind 'component' (not function/class). Prefer
// the component node; fall back to an exported function/class for the
// `.ts`/`.tsx` `export default fn`/`class` case. Without the component
// branch, an `export { default as X } from './X.svelte'` barrel never
// resolves and the component shows a false 0 callers (#629).
const direct =
nodesInFile.find((n) => n.isExported && n.kind === 'component') ??
nodesInFile.find(
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
);
if (direct) return direct;
} else if (want.isNamespace && want.memberName) {
const direct = nodesInFile.find(
Expand Down
20 changes: 19 additions & 1 deletion src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { detectFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
import { loadProjectAliases, type AliasMap } from './path-aliases';
import { loadGoModule, type GoModule } from './go-module';
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
import { logDebug } from '../errors';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
Expand Down Expand Up @@ -203,6 +204,8 @@ export class ReferenceResolver {
private projectAliases: AliasMap | null | undefined = undefined;
// go.mod module path. Same lazy/immutable convention as projectAliases.
private goModule: GoModule | null | undefined = undefined;
// Monorepo workspace member packages. Same lazy/immutable convention.
private workspacePackages: WorkspacePackages | null | undefined = undefined;

constructor(projectRoot: string, queries: QueryBuilder) {
this.projectRoot = projectRoot;
Expand Down Expand Up @@ -423,6 +426,13 @@ export class ReferenceResolver {
return this.goModule;
},

getWorkspacePackages: () => {
if (this.workspacePackages === undefined) {
this.workspacePackages = loadWorkspacePackages(this.projectRoot);
}
return this.workspacePackages;
},

getReExports: (filePath: string, language) => {
const cached = this.reExportCache.get(filePath);
if (cached) return cached;
Expand All @@ -431,7 +441,15 @@ export class ReferenceResolver {
this.reExportCache.set(filePath, []);
return [];
}
const reExports = extractReExports(content, language);
// Re-exports are a JS/TS-only construct, and what matters is the
// BARREL file's own language — not the consuming reference's. A
// `.svelte`/`.vue` consumer threads its own language down the
// re-export chase, which would make extractReExports() bail on a
// `.ts` index barrel and silently break the chain (#629). Re-key
// the parse on the barrel's extension so the chase works no matter
// what kind of file imports through it.
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath);
const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
this.reExportCache.set(filePath, reExports);
return reExports;
},
Expand Down
7 changes: 7 additions & 0 deletions src/resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export interface ResolutionContext {
* cross-package imports from third-party packages.
*/
getGoModule?(): import('./go-module').GoModule | null;
/**
* Monorepo workspace member packages, keyed by declared package name.
* Returns `null` for single-package repos (no `workspaces` field).
* Lets the resolver treat `@scope/ui/sub` as a local import into the
* member's directory instead of an external npm package (#629).
*/
getWorkspacePackages?(): import('./workspace-packages').WorkspacePackages | null;
/**
* Re-exports declared by a file (`export { x } from './other'`,
* `export * from './other'`). Empty array when the file has none.
Expand Down
Loading