diff --git a/CHANGELOG.md b/CHANGELOG.md index fa50ec778..4821d8e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..66932c97d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -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'), + `\n\n\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'), + `\n\n\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'), + `\n\n\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'), + `\n\n\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 \n\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', () => { diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bc493704d..5d263cf82 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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 @@ -18,6 +19,11 @@ const EXTENSION_RESOLUTION: Record = { 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'], @@ -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 @@ -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 = { @@ -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 + // `