Jelajahi Sumber

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>
Colby Mchenry 2 minggu lalu
induk
melakukan
bdfd55e69c

+ 1 - 0
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
 

+ 141 - 0
__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'),
+        `<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
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<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
src/resolution/index.ts

@@ -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 - 0
src/resolution/types.ts

@@ -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 - 0
src/resolution/workspace-packages.ts

@@ -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
+ * consumer↔definition 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;
+  }
+}