fix(resolution): resolve Svelte/Vue component barrels & workspace imports (#629) (#657)

Component barrels (`export { default as X } from './X.svelte'`) and
monorepo workspace imports (`@scope/ui/widgets`) left the consumer↔component
edge uncreated, so live components showed a false `0 callers` — the canonical
dead-code signal — risking deletion of live code.

The Svelte default-barrel case broke at FOUR layers, each of which alone left
it unresolved:
- findExportedSymbol matched only function/class for a default export, never
  `component` (Svelte/Vue SFCs are kind 'component').
- extractImportMappings had no svelte/vue branch, so SFC consumers produced
  zero import mappings and resolveViaImport never ran.
- EXTENSION_RESOLUTION had no svelte/vue entry, so relative imports from an
  SFC (`./lib` -> `/index.ts`) resolved to nothing.
- getReExports parsed the barrel in the CONSUMER's threaded language, so a
  .svelte consumer made extractReExports bail on a .ts index barrel.

Workspace package-subpath barrels get a new workspace-packages module
(mirrors go-module/path-aliases): reads package.json `workspaces`
(npm/yarn/bun) + pnpm-workspace.yaml, maps member name->dir, resolves
`@scope/ui/widgets` -> `packages/ui/widgets`. Gated behind the workspaces
field so single-package repos are unaffected.

Bare `./`/`.` directory imports already resolved; covered with a regression
test. Verified both directions (callers/impact AND callees) for Svelte; Vue
script-level imports also resolve. 4 new tests; full suite green (1126).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-02 21:37:56 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7b62356f53
commit bdfd55e69c
6 changed files with 396 additions and 4 deletions
+1
View File
@@ -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
+141
View File
@@ -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', () => {
+48 -3
View File
@@ -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<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'],
@@ -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<string, string> = {
@@ -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') {
@@ -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(
+19 -1
View File
@@ -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';
@@ -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;
@@ -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;
@@ -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;
},
+7
View File
@@ -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.
+180
View File
@@ -0,0 +1,180 @@
/**
* JS/TS workspace (monorepo) package resolution.
*
* npm / yarn / bun read member packages from the root `package.json`
* `workspaces` field; pnpm from `pnpm-workspace.yaml`. A cross-package
* import like `@scope/ui/widgets` is LOCAL to the monorepo, but to a
* single-package resolver it looks exactly like a third-party npm
* specifier so `isExternalImport` flags it external and the
* consumerdefinition edge is never created. For component barrels
* (`export { default as X } from './x.svelte'`) that surfaces as a false
* `0 callers` on a live component (issue #629).
*
* This module maps each member package's declared `name` to its
* directory so the resolver can rewrite `@scope/ui/widgets`
* `packages/ui/widgets` and then run normal extension/index resolution.
*
* Scope deliberately small for v1 (mirrors path-aliases.ts):
* - reads `workspaces` (array OR `{ packages: [...] }`) from package.json,
* plus a minimal `pnpm-workspace.yaml` `packages:` list
* - expands one level of `*` / `**` globs (`packages/*`, `apps/*`)
* - subpath resolution is directory-based (`@scope/ui/sub` `<ui>/sub`);
* it does NOT yet honour a member's `exports` map or `main` field
* - returns null when the project declares no workspaces, so single-
* package repos pay nothing and see no behaviour change.
*/
import * as fs from 'fs';
import * as path from 'path';
import { logDebug } from '../errors';
export interface WorkspacePackages {
/** Member package `name` → directory relative to projectRoot (posix). */
byName: Map<string, string>;
}
/**
* Load workspace member packages for `projectRoot`. Returns `null` when
* the project declares no workspaces (the common single-package case)
* callers then skip all workspace logic.
*
* Cheap to call repeatedly only via the resolver's per-instance cache;
* this function itself touches the filesystem, so the resolver memoises it
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
*/
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
const patterns = readWorkspaceGlobs(projectRoot);
if (patterns.length === 0) return null;
const byName = new Map<string, string>();
for (const pattern of patterns) {
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
const pkgName = readPackageName(path.join(projectRoot, dir));
// First declaration wins — workspace patterns are tried in order.
if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
}
}
if (byName.size === 0) return null;
logDebug('workspace packages loaded', { count: byName.size });
return { byName };
}
/**
* Rewrite a bare workspace import to a path relative to projectRoot,
* WITHOUT an extension the caller applies the language's extension/index
* resolution. `@scope/ui/widgets` `packages/ui/widgets`; the bare package
* name `@scope/ui` its directory. Returns `null` when no member package
* name matches.
*/
export function resolveWorkspaceImport(
importPath: string,
ws: WorkspacePackages
): string | null {
// Longest matching package name wins, so `@scope/ui/core` prefers a
// `@scope/ui/core` package over a `@scope/ui` one when both exist.
let bestName: string | null = null;
for (const name of ws.byName.keys()) {
if (importPath === name || importPath.startsWith(name + '/')) {
if (!bestName || name.length > bestName.length) bestName = name;
}
}
if (!bestName) return null;
const dir = ws.byName.get(bestName)!;
const subpath = importPath.slice(bestName.length); // '' or '/widgets'
return (dir + subpath).replace(/\/{2,}/g, '/');
}
/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
function readWorkspaceGlobs(projectRoot: string): string[] {
const out: string[] = [];
// package.json `workspaces` (npm / yarn / bun): array, or Yarn's
// `{ packages: [...], nohoist: [...] }` object form.
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')
);
const ws = pkg?.workspaces;
if (Array.isArray(ws)) {
out.push(...ws.filter((w: unknown): w is string => typeof w === 'string'));
} else if (ws && Array.isArray(ws.packages)) {
out.push(...ws.packages.filter((w: unknown): w is string => typeof w === 'string'));
}
} catch {
/* no / invalid package.json — not a workspace root */
}
// pnpm-workspace.yaml `packages:` list. Parsed with a minimal line
// scanner so we don't pull in a YAML dependency.
try {
const yaml = fs.readFileSync(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8');
out.push(...parsePnpmPackages(yaml));
} catch {
/* no pnpm-workspace.yaml */
}
return out;
}
/**
* Minimal pnpm-workspace.yaml `packages:` extractor. Handles the only shape
* pnpm actually uses:
* packages:
* - 'packages/*'
* - "apps/*"
* - tools/build
*/
function parsePnpmPackages(yaml: string): string[] {
const out: string[] = [];
const lines = yaml.split(/\r?\n/);
let inPackages = false;
for (const line of lines) {
if (/^\s*packages\s*:/.test(line)) {
inPackages = true;
continue;
}
if (inPackages) {
const item = line.match(/^\s*-\s*(.+?)\s*$/);
if (item) {
out.push(item[1]!.replace(/^['"]|['"]$/g, ''));
continue;
}
// A non-list, non-blank line ends the `packages:` block.
if (line.trim() !== '' && !/^\s/.test(line)) inPackages = false;
}
}
return out;
}
/** Expand one level of a `packages/*` / `apps/**` glob to member dirs. */
function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] {
const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, '');
const star = norm.indexOf('*');
if (star === -1) return [norm]; // exact directory
// Everything before the wildcard segment is the base to enumerate.
const base = norm.slice(0, star).replace(/\/+$/, '');
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(path.join(projectRoot, base), { withFileTypes: true });
} catch {
return [];
}
const out: string[] = [];
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
out.push(base ? `${base}/${e.name}` : e.name);
}
return out;
}
/** Read the `name` field from a member directory's package.json. */
function readPackageName(dirAbs: string): string | null {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8'));
return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
} catch {
return null;
}
}