Skip to content

Commit bdfd55e

Browse files
colbymchenryclaude
andauthored
fix(resolution): resolve Svelte/Vue component barrels & workspace imports (colbymchenry#629) (colbymchenry#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>
1 parent 7b62356 commit bdfd55e

6 files changed

Lines changed: 396 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2626
- 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)
2727
- 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)
2828
- 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)
29+
- 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)
2930

3031
## [0.9.9] - 2026-06-02
3132

__tests__/resolution.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,147 @@ func main() {
12481248
const callers = cg.getCallers(signInNode!.id);
12491249
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
12501250
});
1251+
1252+
it('follows a default re-export of a .svelte component (export { default as Foo } from ./RealButton.svelte) (#629)', async () => {
1253+
// The ubiquitous Svelte/React component-barrel form. The leaf is a
1254+
// .svelte component (extracted as kind 'component', the default
1255+
// export). The re-export ALIAS (`Foo`) deliberately differs from the
1256+
// component's real name (`RealButton`) so the name-matcher fallback
1257+
// can't coincidentally connect them — the only path to the edge is
1258+
// the import-chase, which must match a `component` (not just
1259+
// function/class) for the default export. Otherwise the
1260+
// consumer↔component edge is never created and `callers` returns a
1261+
// false 0.
1262+
fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true });
1263+
fs.writeFileSync(
1264+
path.join(tempDir, 'src/lib/RealButton.svelte'),
1265+
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
1266+
);
1267+
fs.writeFileSync(
1268+
path.join(tempDir, 'src/lib/index.ts'),
1269+
`export { default as Foo } from './RealButton.svelte';\n`
1270+
);
1271+
fs.writeFileSync(
1272+
path.join(tempDir, 'src/Bar.svelte'),
1273+
`<script lang="ts">\n import { Foo } from './lib';\n</script>\n\n<Foo />\n`
1274+
);
1275+
1276+
cg = await CodeGraph.init(tempDir, { index: true });
1277+
cg.resolveReferences();
1278+
1279+
const fooNode = cg
1280+
.getNodesByKind('component')
1281+
.find((n) => n.name === 'RealButton' && n.filePath === 'src/lib/RealButton.svelte');
1282+
expect(fooNode).toBeDefined();
1283+
const callers = cg.getCallers(fooNode!.id);
1284+
expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true);
1285+
});
1286+
1287+
it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => {
1288+
// `import { helper } from '.'` (or './') must map to the
1289+
// directory's index.ts before the re-export chase can run. The
1290+
// barrel renames `realHelper` → `helper` so the name-matcher can't
1291+
// mask a path-resolution failure: only the bare-dir resolution +
1292+
// rename chase can connect the edge.
1293+
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
1294+
fs.writeFileSync(
1295+
path.join(tempDir, 'src/util.ts'),
1296+
`export function realHelper(): void {}\n`
1297+
);
1298+
fs.writeFileSync(
1299+
path.join(tempDir, 'src/index.ts'),
1300+
`export { realHelper as helper } from './util';\n`
1301+
);
1302+
fs.writeFileSync(
1303+
path.join(tempDir, 'src/main.ts'),
1304+
`import { helper } from '.';\nexport function go(): void { helper(); }\n`
1305+
);
1306+
fs.writeFileSync(
1307+
path.join(tempDir, 'src/main2.ts'),
1308+
`import { helper } from './';\nexport function go2(): void { helper(); }\n`
1309+
);
1310+
1311+
cg = await CodeGraph.init(tempDir, { index: true });
1312+
cg.resolveReferences();
1313+
1314+
const helperNode = cg
1315+
.getNodesByKind('function')
1316+
.find((n) => n.name === 'realHelper' && n.filePath === 'src/util.ts');
1317+
expect(helperNode).toBeDefined();
1318+
const callers = cg.getCallers(helperNode!.id);
1319+
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
1320+
expect(callers.some((c) => c.node.filePath === 'src/main2.ts')).toBe(true);
1321+
});
1322+
1323+
it('resolves a workspace package-subpath barrel (@scope/pkg/sub) to its index (#629)', async () => {
1324+
// bun/npm/pnpm workspace: `@scope/ui/widgets` → the `ui` package's
1325+
// `widgets/` subdir index, which re-exports a .svelte component.
1326+
// Alias `Thing` ≠ component `Widget` defeats the name-matcher, so
1327+
// only workspace-package resolution can connect the edge.
1328+
fs.mkdirSync(path.join(tempDir, 'packages/ui/widgets'), { recursive: true });
1329+
fs.writeFileSync(
1330+
path.join(tempDir, 'package.json'),
1331+
JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }, null, 2)
1332+
);
1333+
fs.writeFileSync(
1334+
path.join(tempDir, 'packages/ui/package.json'),
1335+
JSON.stringify({ name: '@scope/ui', version: '1.0.0' }, null, 2)
1336+
);
1337+
fs.writeFileSync(
1338+
path.join(tempDir, 'packages/ui/widgets/Widget.svelte'),
1339+
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
1340+
);
1341+
fs.writeFileSync(
1342+
path.join(tempDir, 'packages/ui/widgets/index.ts'),
1343+
`export { default as Thing } from './Widget.svelte';\n`
1344+
);
1345+
fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
1346+
fs.writeFileSync(
1347+
path.join(tempDir, 'app/App.svelte'),
1348+
`<script lang="ts">\n import { Thing } from '@scope/ui/widgets';\n</script>\n\n<Thing />\n`
1349+
);
1350+
1351+
cg = await CodeGraph.init(tempDir, { index: true });
1352+
cg.resolveReferences();
1353+
1354+
const buttonNode = cg
1355+
.getNodesByKind('component')
1356+
.find((n) => n.name === 'Widget' && n.filePath === 'packages/ui/widgets/Widget.svelte');
1357+
expect(buttonNode).toBeDefined();
1358+
const callers = cg.getCallers(buttonNode!.id);
1359+
expect(callers.some((c) => c.node.filePath === 'app/App.svelte')).toBe(true);
1360+
});
1361+
1362+
it('resolves a barrel import from a Vue SFC <script> block (#629)', async () => {
1363+
// The same import-resolution gaps (no SFC import mappings, no SFC
1364+
// extension list, barrel parsed in the consumer's language) broke
1365+
// Vue SFCs too. Guards the resolver-side generalization to `.vue`.
1366+
// The barrel renames `realRun` → `run` so only the import-chase (not
1367+
// the name-matcher) can connect the call.
1368+
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
1369+
fs.writeFileSync(
1370+
path.join(tempDir, 'src/util.ts'),
1371+
`export function realRun(): void {}\n`
1372+
);
1373+
fs.writeFileSync(
1374+
path.join(tempDir, 'src/index.ts'),
1375+
`export { realRun as run } from './util';\n`
1376+
);
1377+
fs.writeFileSync(
1378+
path.join(tempDir, 'src/App.vue'),
1379+
`<script lang="ts">\nimport { run } from './';\nexport default { mounted() { run(); } };\n</script>\n<template><div/></template>\n`
1380+
);
1381+
1382+
cg = await CodeGraph.init(tempDir, { index: true });
1383+
cg.resolveReferences();
1384+
1385+
const runNode = cg
1386+
.getNodesByKind('function')
1387+
.find((n) => n.name === 'realRun' && n.filePath === 'src/util.ts');
1388+
expect(runNode).toBeDefined();
1389+
const callers = cg.getCallers(runNode!.id);
1390+
expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true);
1391+
});
12511392
});
12521393

12531394
describe('C/C++ Import Resolution', () => {

src/resolution/import-resolver.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as path from 'path';
99
import { Language, Node } from '../types';
1010
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
1111
import { applyAliases } from './path-aliases';
12+
import { resolveWorkspaceImport } from './workspace-packages';
1213

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

133+
// Workspace-member imports (`@scope/ui`, `@scope/ui/widgets`) are LOCAL to
134+
// a monorepo even though they look like bare npm specifiers. Consult the
135+
// workspace map first so they aren't misclassified as external (#629). The
136+
// map is null for single-package repos, so this is a no-op there.
137+
const workspaces = context?.getWorkspacePackages?.();
138+
if (workspaces && resolveWorkspaceImport(importPath, workspaces)) {
139+
return false;
140+
}
141+
127142
// Common external patterns
128143
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
129144
// Node built-ins
@@ -255,6 +270,18 @@ function resolveAliasedImport(
255270
}
256271
}
257272

273+
// 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`).
274+
// Resolves a monorepo member import to the member's directory; the
275+
// extension/index permutations below then find its barrel (#629).
276+
const workspaces = context.getWorkspacePackages?.();
277+
if (workspaces) {
278+
const base = resolveWorkspaceImport(importPath, workspaces);
279+
if (base) {
280+
const hit = tryWithExt(base);
281+
if (hit) return hit;
282+
}
283+
}
284+
258285
// 2. Hard-coded fallback list. Kept for projects that use these
259286
// conventional aliases without declaring them in tsconfig.
260287
const fallbackAliases: Record<string, string> = {
@@ -496,6 +523,16 @@ export function extractImportMappings(
496523

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

12491286
// 1. Direct hit: the symbol is declared in this file.
12501287
if (want.isDefault) {
1251-
const direct = nodesInFile.find(
1252-
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
1253-
);
1288+
// Svelte/Vue single-file components ARE the module's default export,
1289+
// but are extracted as kind 'component' (not function/class). Prefer
1290+
// the component node; fall back to an exported function/class for the
1291+
// `.ts`/`.tsx` `export default fn`/`class` case. Without the component
1292+
// branch, an `export { default as X } from './X.svelte'` barrel never
1293+
// resolves and the component shows a false 0 callers (#629).
1294+
const direct =
1295+
nodesInFile.find((n) => n.isExported && n.kind === 'component') ??
1296+
nodesInFile.find(
1297+
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
1298+
);
12541299
if (direct) return direct;
12551300
} else if (want.isNamespace && want.memberName) {
12561301
const direct = nodesInFile.find(

src/resolution/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { detectFrameworks } from './frameworks';
2222
import { synthesizeCallbackEdges } from './callback-synthesizer';
2323
import { loadProjectAliases, type AliasMap } from './path-aliases';
2424
import { loadGoModule, type GoModule } from './go-module';
25+
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
2526
import { logDebug } from '../errors';
2627
import type { ReExport } from './types';
2728
import { LRUCache } from './lru-cache';
@@ -203,6 +204,8 @@ export class ReferenceResolver {
203204
private projectAliases: AliasMap | null | undefined = undefined;
204205
// go.mod module path. Same lazy/immutable convention as projectAliases.
205206
private goModule: GoModule | null | undefined = undefined;
207+
// Monorepo workspace member packages. Same lazy/immutable convention.
208+
private workspacePackages: WorkspacePackages | null | undefined = undefined;
206209

207210
constructor(projectRoot: string, queries: QueryBuilder) {
208211
this.projectRoot = projectRoot;
@@ -423,6 +426,13 @@ export class ReferenceResolver {
423426
return this.goModule;
424427
},
425428

429+
getWorkspacePackages: () => {
430+
if (this.workspacePackages === undefined) {
431+
this.workspacePackages = loadWorkspacePackages(this.projectRoot);
432+
}
433+
return this.workspacePackages;
434+
},
435+
426436
getReExports: (filePath: string, language) => {
427437
const cached = this.reExportCache.get(filePath);
428438
if (cached) return cached;
@@ -431,7 +441,15 @@ export class ReferenceResolver {
431441
this.reExportCache.set(filePath, []);
432442
return [];
433443
}
434-
const reExports = extractReExports(content, language);
444+
// Re-exports are a JS/TS-only construct, and what matters is the
445+
// BARREL file's own language — not the consuming reference's. A
446+
// `.svelte`/`.vue` consumer threads its own language down the
447+
// re-export chase, which would make extractReExports() bail on a
448+
// `.ts` index barrel and silently break the chain (#629). Re-key
449+
// the parse on the barrel's extension so the chase works no matter
450+
// what kind of file imports through it.
451+
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath);
452+
const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
435453
this.reExportCache.set(filePath, reExports);
436454
return reExports;
437455
},

src/resolution/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ export interface ResolutionContext {
9999
* cross-package imports from third-party packages.
100100
*/
101101
getGoModule?(): import('./go-module').GoModule | null;
102+
/**
103+
* Monorepo workspace member packages, keyed by declared package name.
104+
* Returns `null` for single-package repos (no `workspaces` field).
105+
* Lets the resolver treat `@scope/ui/sub` as a local import into the
106+
* member's directory instead of an external npm package (#629).
107+
*/
108+
getWorkspacePackages?(): import('./workspace-packages').WorkspacePackages | null;
102109
/**
103110
* Re-exports declared by a file (`export { x } from './other'`,
104111
* `export * from './other'`). Empty array when the file has none.

0 commit comments

Comments
 (0)