Jelajahi Sumber

test+feat: add cargo workspace crate resolution for rust resolver (#151)

* test+feat: add cargo workspace crate resolution for rust resolver

Agent-Logs-Url: https://github.com/miketheman/codegraph/sessions/0101633b-8b63-4951-a6ca-03efe7fafe0b

Co-authored-by: miketheman <529516+miketheman@users.noreply.github.com>

* perf: cache cargo workspace map during rust resolution

Agent-Logs-Url: https://github.com/miketheman/codegraph/sessions/0101633b-8b63-4951-a6ca-03efe7fafe0b

Co-authored-by: miketheman <529516+miketheman@users.noreply.github.com>

* feat(rust): expand cargo workspace member globs and trust workspace hits

- Parse glob entries in `[workspace].members` (e.g. `crates/*`,
  `helix-*`) via picomatch against a new optional
  `ResolutionContext.listDirectories` so workspaces that don't
  enumerate every member are covered. Implementation walks the
  static-prefix subtree with a depth cap and skips `target`,
  `node_modules`, `.git`, etc.
- Bump Pattern 4's confidence to 0.95 when the workspace map
  produces a hit. The cargo manifest gives an unambiguous
  crate-name -> crate-root mapping, so workspace-driven module
  resolution should beat name-matcher's self-file matches
  (otherwise every file with `use foo::...` self-resolves at 0.7
  and the cross-crate edge never materializes).
- Validated against astral-sh/uv (`members = ["crates/*"]`,
  67 crates, 567 .rs files): 1,969 cross-crate `imports` edges
  reaching 60 distinct member lib.rs files, up from 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mike Fiedler 1 bulan lalu
induk
melakukan
6ac2066a7a

+ 307 - 0
__tests__/frameworks.test.ts

@@ -260,6 +260,313 @@ describe('rustResolver.extract', () => {
   });
 });
 
+describe('rustResolver.resolve cargo workspace crates', () => {
+  it('resolves crate name from workspace member lib.rs', () => {
+    const workspaceCargo = `
+[workspace]
+members = ["crates/mytool-core", "crates/mytool-fetcher"]
+`;
+    const coreCargo = `
+[package]
+name = "mytool-core"
+version = "0.1.0"
+`;
+    const libNode: Node = {
+      id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
+      kind: 'module',
+      name: 'mytool_core',
+      qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
+      filePath: 'crates/mytool-core/src/lib.rs',
+      language: 'rust',
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+
+    const context = {
+      getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
+      getNodesByName: () => [],
+      getNodesByQualifiedName: () => [],
+      getNodesByKind: () => [],
+      fileExists: (p: string) => (
+        p === 'Cargo.toml' ||
+        p === 'crates/mytool-core/Cargo.toml' ||
+        p === 'crates/mytool-core/src/lib.rs'
+      ),
+      readFile: (p: string) => {
+        if (p === 'Cargo.toml') return workspaceCargo;
+        if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
+        return null;
+      },
+      getProjectRoot: () => '/test',
+      getAllFiles: () => [
+        'Cargo.toml',
+        'crates/mytool-core/Cargo.toml',
+        'crates/mytool-core/src/lib.rs',
+      ],
+      getNodesByLowerName: () => [],
+      getImportMappings: () => [],
+    };
+
+    const ref = {
+      fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
+      referenceName: 'mytool_core',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'crates/mytool-fetcher/src/main.rs',
+      language: 'rust' as const,
+    };
+
+    const result = rustResolver.resolve(ref, context);
+    expect(result?.targetNodeId).toBe(libNode.id);
+    expect(result?.resolvedBy).toBe('framework');
+    // Workspace-manifest hits are unambiguous and must beat name-matcher's
+    // self-file matches (0.7) so cross-crate `imports` edges materialize.
+    expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
+  });
+
+  it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
+    const workspaceCargo = `
+[workspace]
+members = [
+  "crates/mytool-runner",
+]
+`;
+    const runnerCargo = `
+[package]
+name = "mytool-runner"
+version = "0.1.0"
+`;
+    const mainNode: Node = {
+      id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
+      kind: 'module',
+      name: 'mytool_runner',
+      qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
+      filePath: 'crates/mytool-runner/src/main.rs',
+      language: 'rust',
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+
+    const context = {
+      getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
+      getNodesByName: () => [],
+      getNodesByQualifiedName: () => [],
+      getNodesByKind: () => [],
+      fileExists: (p: string) => (
+        p === 'Cargo.toml' ||
+        p === 'crates/mytool-runner/Cargo.toml' ||
+        p === 'crates/mytool-runner/src/main.rs'
+      ),
+      readFile: (p: string) => {
+        if (p === 'Cargo.toml') return workspaceCargo;
+        if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
+        return null;
+      },
+      getProjectRoot: () => '/test',
+      getAllFiles: () => [
+        'Cargo.toml',
+        'crates/mytool-runner/Cargo.toml',
+        'crates/mytool-runner/src/main.rs',
+      ],
+      getNodesByLowerName: () => [],
+      getImportMappings: () => [],
+    };
+
+    const ref = {
+      fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
+      referenceName: 'mytool_runner',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'crates/mytool-runner/src/main.rs',
+      language: 'rust' as const,
+    };
+
+    const result = rustResolver.resolve(ref, context);
+    expect(result?.targetNodeId).toBe(mainNode.id);
+    expect(result?.resolvedBy).toBe('framework');
+  });
+
+  it('resolves crate name when members uses a glob (crates/*)', () => {
+    const workspaceCargo = `
+[workspace]
+members = ["crates/*"]
+`;
+    const fooCargo = `
+[package]
+name = "mytool-foo"
+version = "0.1.0"
+`;
+    const barCargo = `
+[package]
+name = "mytool-bar"
+version = "0.1.0"
+`;
+    const fooLib: Node = {
+      id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
+      kind: 'module',
+      name: 'mytool_foo',
+      qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
+      filePath: 'crates/mytool-foo/src/lib.rs',
+      language: 'rust',
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+    const barLib: Node = {
+      id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
+      kind: 'module',
+      name: 'mytool_bar',
+      qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
+      filePath: 'crates/mytool-bar/src/lib.rs',
+      language: 'rust',
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+
+    const filesByPath: Record<string, string> = {
+      'Cargo.toml': workspaceCargo,
+      'crates/mytool-foo/Cargo.toml': fooCargo,
+      'crates/mytool-bar/Cargo.toml': barCargo,
+    };
+    const nodesByFile: Record<string, Node[]> = {
+      'crates/mytool-foo/src/lib.rs': [fooLib],
+      'crates/mytool-bar/src/lib.rs': [barLib],
+    };
+    const dirsByPath: Record<string, string[]> = {
+      '.': ['crates'],
+      crates: ['mytool-foo', 'mytool-bar'],
+      'crates/mytool-foo': ['src'],
+      'crates/mytool-bar': ['src'],
+    };
+
+    const context = {
+      getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
+      getNodesByName: () => [],
+      getNodesByQualifiedName: () => [],
+      getNodesByKind: () => [],
+      fileExists: (p: string) => (
+        Object.prototype.hasOwnProperty.call(filesByPath, p) ||
+        Object.prototype.hasOwnProperty.call(nodesByFile, p)
+      ),
+      readFile: (p: string) => filesByPath[p] ?? null,
+      getProjectRoot: () => '/test',
+      getAllFiles: () => [
+        'Cargo.toml',
+        ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
+        ...Object.keys(nodesByFile),
+      ],
+      getNodesByLowerName: () => [],
+      getImportMappings: () => [],
+      listDirectories: (rel: string) => dirsByPath[rel] ?? [],
+    };
+
+    const fooRef = {
+      fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
+      referenceName: 'mytool_foo',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'crates/mytool-bar/src/lib.rs',
+      language: 'rust' as const,
+    };
+    const barRef = {
+      fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
+      referenceName: 'mytool_bar',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'crates/mytool-foo/src/lib.rs',
+      language: 'rust' as const,
+    };
+
+    expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
+    expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
+  });
+
+  it('resolves crate name when members uses a name glob at root (helix-*)', () => {
+    const workspaceCargo = `
+[workspace]
+members = ["helix-*"]
+`;
+    const coreCargo = `
+[package]
+name = "helix-core"
+version = "0.1.0"
+`;
+    const coreLib: Node = {
+      id: 'module:helix-core/src/lib.rs:helix_core:1',
+      kind: 'module',
+      name: 'helix_core',
+      qualifiedName: 'helix-core/src/lib.rs::helix_core',
+      filePath: 'helix-core/src/lib.rs',
+      language: 'rust',
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+
+    const filesByPath: Record<string, string> = {
+      'Cargo.toml': workspaceCargo,
+      'helix-core/Cargo.toml': coreCargo,
+    };
+    const nodesByFile: Record<string, Node[]> = {
+      'helix-core/src/lib.rs': [coreLib],
+    };
+    const dirsByPath: Record<string, string[]> = {
+      '.': ['helix-core', 'docs', 'target'],
+      'helix-core': ['src'],
+    };
+
+    const context = {
+      getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
+      getNodesByName: () => [],
+      getNodesByQualifiedName: () => [],
+      getNodesByKind: () => [],
+      fileExists: (p: string) => (
+        Object.prototype.hasOwnProperty.call(filesByPath, p) ||
+        Object.prototype.hasOwnProperty.call(nodesByFile, p)
+      ),
+      readFile: (p: string) => filesByPath[p] ?? null,
+      getProjectRoot: () => '/test',
+      getAllFiles: () => [
+        'Cargo.toml',
+        ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
+        ...Object.keys(nodesByFile),
+      ],
+      getNodesByLowerName: () => [],
+      getImportMappings: () => [],
+      listDirectories: (rel: string) => dirsByPath[rel] ?? [],
+    };
+
+    const ref = {
+      fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
+      referenceName: 'helix_core',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'helix-core/src/lib.rs',
+      language: 'rust' as const,
+    };
+
+    expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
+  });
+});
+
 import { aspnetResolver } from '../src/resolution/frameworks/csharp';
 
 describe('aspnetResolver.extract', () => {

+ 244 - 0
src/resolution/frameworks/cargo-workspace.ts

@@ -0,0 +1,244 @@
+/**
+ * Cargo Workspace Resolver Helper
+ *
+ * Parses a project's root Cargo.toml and member crate manifests to
+ * build a crate-name -> member-directory map. Used by the Rust
+ * resolver to resolve `use crate_name::...` references that point
+ * into workspace member crates.
+ */
+
+import picomatch from 'picomatch';
+import { ResolutionContext } from '../types';
+
+const GLOB_CHARS = /[*?[\]{}!]/;
+const SKIP_DIRS = new Set(['target', 'node_modules', '.git', 'dist', 'build']);
+const MAX_GLOB_WALK_DEPTH = 5;
+
+function getSection(content: string, sectionName: string): string | null {
+  const lines = content.split('\n');
+  let inSection = false;
+  const sectionLines: string[] = [];
+
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (!inSection) {
+      if (trimmed === `[${sectionName}]`) {
+        inSection = true;
+      }
+      continue;
+    }
+
+    if (/^\[[^\]]+\]$/.test(trimmed)) {
+      break;
+    }
+
+    sectionLines.push(line);
+  }
+
+  if (!inSection) return null;
+  return sectionLines.join('\n');
+}
+
+function extractQuotedValues(valueList: string): string[] {
+  const values: string[] = [];
+  let quote: '"' | "'" | null = null;
+  let escaped = false;
+  let current = '';
+
+  for (const ch of valueList) {
+    if (!quote) {
+      if (ch === '"' || ch === "'") {
+        quote = ch;
+        current = '';
+      }
+      continue;
+    }
+
+    if (escaped) {
+      current += ch;
+      escaped = false;
+      continue;
+    }
+
+    if (ch === '\\') {
+      escaped = true;
+      continue;
+    }
+
+    if (ch === quote) {
+      values.push(current.trim());
+      quote = null;
+      current = '';
+      continue;
+    }
+
+    current += ch;
+  }
+
+  return values.filter(Boolean);
+}
+
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function getArrayValue(section: string, key: string): string | null {
+  const keyRegex = new RegExp(`\\b${escapeRegExp(key)}\\b\\s*=`, 'm');
+  const keyMatch = keyRegex.exec(section);
+  if (!keyMatch) return null;
+
+  let i = keyMatch.index + keyMatch[0].length;
+  while (i < section.length && /\s/.test(section.charAt(i))) i++;
+  if (section.charAt(i) !== '[') return null;
+  i++;
+
+  let inQuote: '"' | "'" | null = null;
+  let escaped = false;
+  let depth = 1;
+  const start = i;
+
+  while (i < section.length) {
+    const ch = section.charAt(i);
+
+    if (inQuote) {
+      if (escaped) {
+        escaped = false;
+      } else if (ch === '\\') {
+        escaped = true;
+      } else if (ch === inQuote) {
+        inQuote = null;
+      }
+      i++;
+      continue;
+    }
+
+    if (ch === '"' || ch === "'") {
+      inQuote = ch;
+      i++;
+      continue;
+    }
+
+    if (ch === '[') {
+      depth++;
+      i++;
+      continue;
+    }
+
+    if (ch === ']') {
+      depth--;
+      if (depth === 0) {
+        return section.slice(start, i);
+      }
+      i++;
+      continue;
+    }
+
+    i++;
+  }
+
+  return null;
+}
+
+function parseWorkspaceMembers(cargoToml: string): string[] {
+  const workspaceSection = getSection(cargoToml, 'workspace');
+  if (!workspaceSection) return [];
+  const membersValue = getArrayValue(workspaceSection, 'members');
+  if (!membersValue) return [];
+  return extractQuotedValues(membersValue);
+}
+
+function parsePackageName(cargoToml: string): string | null {
+  const packageSection = getSection(cargoToml, 'package');
+  if (!packageSection) return null;
+  const packageNameMatch = packageSection.match(/name\s*=\s*["']([^"'\n]+)["']/);
+  return packageNameMatch?.[1]?.trim() ?? null;
+}
+
+function addCrateAlias(map: Map<string, string>, crateName: string, memberPath: string): void {
+  const normalized = crateName.replace(/-/g, '_');
+  map.set(crateName, memberPath);
+  if (normalized !== crateName) {
+    map.set(normalized, memberPath);
+  }
+}
+
+function cleanPath(memberPath: string): string {
+  return memberPath.replace(/\\/g, '/').replace(/\/$/, '');
+}
+
+function expandGlobMember(member: string, context: ResolutionContext): string[] {
+  if (!context.listDirectories) return [];
+
+  const firstGlobIdx = member.search(GLOB_CHARS);
+  const staticPrefix = member
+    .slice(0, firstGlobIdx)
+    .replace(/[^/]*$/, '')
+    .replace(/\/$/, '');
+
+  const matcher = picomatch(member, { dot: false });
+  const matches: string[] = [];
+  const seen = new Set<string>();
+
+  function walk(dir: string, depth: number): void {
+    if (depth > MAX_GLOB_WALK_DEPTH) return;
+    const children = context.listDirectories!(dir);
+    for (const child of children) {
+      if (SKIP_DIRS.has(child) || child.startsWith('.')) continue;
+      const rel = dir === '.' ? child : `${dir}/${child}`;
+      if (matcher(rel) && !seen.has(rel)) {
+        seen.add(rel);
+        matches.push(rel);
+      }
+      walk(rel, depth + 1);
+    }
+  }
+
+  walk(staticPrefix || '.', 0);
+  return matches;
+}
+
+function expandMembers(members: string[], context: ResolutionContext): string[] {
+  const expanded: string[] = [];
+  const seen = new Set<string>();
+  for (const member of members) {
+    const candidates = GLOB_CHARS.test(member)
+      ? expandGlobMember(member, context)
+      : [member];
+    for (const candidate of candidates) {
+      const cleaned = cleanPath(candidate);
+      if (seen.has(cleaned)) continue;
+      seen.add(cleaned);
+      expanded.push(cleaned);
+    }
+  }
+  return expanded;
+}
+
+/**
+ * Build a map from crate-name aliases to workspace member directory paths.
+ * Example: "mytool-core" and "mytool_core" -> "crates/mytool-core"
+ *
+ * Supports glob members (e.g. `members = ["crates/*"]`) via picomatch
+ * when the context exposes `listDirectories`.
+ */
+export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map<string, string> {
+  const result = new Map<string, string>();
+  const rootCargoToml = context.readFile('Cargo.toml');
+  if (!rootCargoToml) return result;
+
+  const rawMembers = parseWorkspaceMembers(rootCargoToml);
+  const members = expandMembers(rawMembers, context);
+
+  for (const memberPath of members) {
+    const memberCargoPath = `${memberPath}/Cargo.toml`;
+    const memberCargoToml = context.readFile(memberCargoPath);
+    if (!memberCargoToml) continue;
+
+    const packageName = parsePackageName(memberCargoToml);
+    if (!packageName) continue;
+
+    addCrateAlias(result, packageName, memberPath);
+  }
+
+  return result;
+}

+ 41 - 18
src/resolution/frameworks/rust.ts

@@ -7,6 +7,17 @@
 import { Node } from '../../types';
 import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
 import { stripCommentsForRegex } from '../strip-comments';
+import { getCargoWorkspaceCrateMap } from './cargo-workspace';
+
+const cargoWorkspaceMapCache = new WeakMap<ResolutionContext, Map<string, string>>();
+
+function getCachedCargoWorkspaceCrateMap(context: ResolutionContext): Map<string, string> {
+  const cached = cargoWorkspaceMapCache.get(context);
+  if (cached) return cached;
+  const map = getCargoWorkspaceCrateMap(context);
+  cargoWorkspaceMapCache.set(context, map);
+  return map;
+}
 
 export const rustResolver: FrameworkResolver = {
   name: 'rust',
@@ -61,10 +72,15 @@ export const rustResolver: FrameworkResolver = {
     if (/^[a-z_]+$/.test(ref.referenceName)) {
       const result = resolveModule(ref.referenceName, context);
       if (result) {
+        // Workspace-manifest hits are an exact crate-name -> crate-root
+        // mapping straight from Cargo.toml, so we trust them above
+        // name-matcher self-file matches (which otherwise win at 0.7
+        // because every file containing `use foo::...` has its own
+        // import node named `foo`).
         return {
           original: ref,
-          targetNodeId: result,
-          confidence: 0.6,
+          targetNodeId: result.targetId,
+          confidence: result.fromWorkspace ? 0.95 : 0.6,
           resolvedBy: 'framework',
         };
       }
@@ -191,25 +207,32 @@ function resolveByNameAndKind(
   return kindFiltered[0]!.id;
 }
 
-function resolveModule(name: string, context: ResolutionContext): string | null {
+interface ModuleResolution {
+  targetId: string;
+  fromWorkspace: boolean;
+}
+
+function resolveModule(name: string, context: ResolutionContext): ModuleResolution | null {
   // Rust modules can be either mod.rs in a directory or name.rs
-  const possiblePaths = [
-    `src/${name}.rs`,
-    `src/${name}/mod.rs`,
+  const localPaths = [`src/${name}.rs`, `src/${name}/mod.rs`];
+
+  const workspaceCrates = getCachedCargoWorkspaceCrateMap(context);
+  const cratePath = workspaceCrates.get(name);
+  const workspacePaths = cratePath
+    ? [`${cratePath}/src/lib.rs`, `${cratePath}/src/main.rs`]
+    : [];
+
+  const candidates: Array<{ path: string; fromWorkspace: boolean }> = [
+    ...localPaths.map((path) => ({ path, fromWorkspace: false })),
+    ...workspacePaths.map((path) => ({ path, fromWorkspace: true })),
   ];
 
-  for (const modPath of possiblePaths) {
-    if (context.fileExists(modPath)) {
-      const nodes = context.getNodesInFile(modPath);
-      const modNode = nodes.find((n) => n.kind === 'module');
-      if (modNode) {
-        return modNode.id;
-      }
-      // If no explicit module node, return the first node in the file
-      if (nodes.length > 0) {
-        return nodes[0]!.id;
-      }
-    }
+  for (const { path: modPath, fromWorkspace } of candidates) {
+    if (!context.fileExists(modPath)) continue;
+    const nodes = context.getNodesInFile(modPath);
+    const modNode = nodes.find((n) => n.kind === 'module');
+    if (modNode) return { targetId: modNode.id, fromWorkspace };
+    if (nodes.length > 0) return { targetId: nodes[0]!.id, fromWorkspace };
   }
 
   return null;

+ 18 - 0
src/resolution/index.ts

@@ -257,6 +257,24 @@ export class ReferenceResolver {
         return this.queries.getAllFilePaths();
       },
 
+      listDirectories: (relativePath: string) => {
+        const target = relativePath === '.' || relativePath === ''
+          ? this.projectRoot
+          : path.join(this.projectRoot, relativePath);
+        try {
+          return fs
+            .readdirSync(target, { withFileTypes: true })
+            .filter((entry) => entry.isDirectory())
+            .map((entry) => entry.name);
+        } catch (error) {
+          logDebug('Failed to list directory for resolution', {
+            relativePath,
+            error: String(error),
+          });
+          return [];
+        }
+      },
+
       getNodesByLowerName: (lowerName: string) => {
         const cached = this.lowerNameCache.get(lowerName);
         if (cached !== undefined) return cached;

+ 9 - 0
src/resolution/types.ts

@@ -98,6 +98,15 @@ export interface ResolutionContext {
    * re-export chains when this is provided.
    */
   getReExports?(filePath: string, language: Language): ReExport[];
+  /**
+   * List immediate subdirectories of `relativePath` (relative to the
+   * project root). Returns an empty array when the path doesn't exist
+   * or isn't a directory. Used by framework resolvers that need to
+   * walk build-system metadata (e.g. Cargo workspace globs). Optional
+   * so external context implementations and test fixtures compile
+   * without modification.
+   */
+  listDirectories?(relativePath: string): string[];
 }
 
 /**