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
+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(