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>
This commit is contained in:
Mike Fiedler
2026-05-12 13:25:14 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Colby McHenry
parent 1cbd5a8123
commit 6ac2066a7a
5 changed files with 619 additions and 18 deletions
+307
View File
@@ -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', () => {