feat(group): auto-discover Rust workspace cross-crate contracts (#1256)

This commit is contained in:
Christian C. Berclaz 2026-05-03 02:43:22 +02:00 • committed by Evan Wang
parent 0728a4ef58
commit 4957d3edd3
6 changed files with 836 additions and 10 deletions

View file

@ -13,6 +13,7 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
workspace_deps: true,
};
const DEFAULT_MATCHING = {

View file

@ -0,0 +1,270 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath } from '../../../config/ignore-service.js';
import { loadIgnoreRules } from '../../../config/ignore-service.js';
/**
* Discover cross-crate contracts in a Rust workspace by reading each
* member's `Cargo.toml` dependencies and scanning source files for
* `use <workspace_dep>::<Type>` imports.
*
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
* existing ManifestExtractor pipeline — no new matching logic needed.
*
* Designed for the group-level sync pipeline: it receives all repos in
* a group and produces cross-repo links between them.
*/
interface CrateMeta {
name: string;
groupPath: string;
repoPath: string;
workspaceDeps: string[];
}
interface ImportedSymbol {
crateName: string;
symbolName: string;
filePath: string;
}
/**
* Parse a Cargo.toml to extract the crate name and workspace dependency
* names. Uses simple line-based parsing — no TOML library needed for
* the subset we care about.
*/
async function parseCrateManifest(
repoPath: string,
): Promise<{ name: string; workspaceDeps: string[] } | null> {
const cargoPath = path.join(repoPath, 'Cargo.toml');
let content: string;
try {
content = await fs.readFile(cargoPath, 'utf-8');
} catch {
return null;
}
let name = '';
const workspaceDeps: string[] = [];
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
if (nameMatch) name = nameMatch[1];
// Match dependencies that use workspace = true, which indicates they
// are workspace-internal deps:
// dep_name = { workspace = true }
// dep_name.workspace = true
//
// Also match plain path dependencies:
// dep_name = { path = "../other" }
const depSections = content.matchAll(
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
);
for (const section of depSections) {
const sectionBody = section[2];
// workspace = true style
const wsMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
);
for (const m of wsMatches) workspaceDeps.push(m[1]);
// dotted workspace style: dep_name.workspace = true
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
for (const m of dottedMatches) workspaceDeps.push(m[1]);
// path = "../other" style (local path deps within workspace)
const pathMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
);
for (const m of pathMatches) workspaceDeps.push(m[1]);
}
if (!name) return null;
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
}
/**
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
* where <crate> is a known workspace dependency.
*/
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const normalizedCrates = new Map<string, string>();
for (const c of knownCrates) {
normalizedCrates.set(c.replace(/-/g, '_'), c);
}
const sourceFiles = await findRustFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
// Match patterns:
// use crate_name::Type;
// use crate_name::module::Type;
// use crate_name::{Type1, Type2};
// use crate_name::module::{Type1, Type2};
const useRegex = /^use\s+(\w+)::(.+);/gm;
let match;
while ((match = useRegex.exec(content)) !== null) {
const crateName = match[1];
const originalCrateName = normalizedCrates.get(crateName);
if (!originalCrateName) continue;
const importPath = match[2].trim();
// Handle grouped imports: {Type1, Type2, module::Type3}
const braceMatch = importPath.match(/\{([^}]+)\}/);
if (braceMatch) {
const items = braceMatch[1].split(',').map((s) => s.trim());
for (const item of items) {
const symbolName = extractSymbolName(item);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
} else {
const symbolName = extractSymbolName(importPath);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
}
}
return results;
}
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
function extractSymbolName(importPath: string): string | null {
const trimmed = importPath.trim();
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
const parts = trimmed.split('::');
return parts[parts.length - 1].trim() || null;
}
/**
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
* Functions and modules are snake_case. We only want types as cross-crate
* contracts — functions are too granular and modules too broad.
*/
function isTypeName(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findRustFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.rs')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface RustWorkspaceResult {
links: GroupManifestLink[];
discoveredCrates: Map<string, CrateMeta>;
}
/**
* Discover cross-crate contracts across all Rust repos in a group.
*
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
*/
export async function extractRustWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<RustWorkspaceResult> {
// Phase 1: Parse all Cargo.toml files to build crate registry
const cratesByName = new Map<string, CrateMeta>();
const cratesByGroupPath = new Map<string, CrateMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseCrateManifest(repoPath);
if (!manifest) continue;
const meta: CrateMeta = {
name: manifest.name,
groupPath,
repoPath,
workspaceDeps: manifest.workspaceDeps,
};
const existing = cratesByName.get(manifest.name);
if (existing) {
console.warn(
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;
}
cratesByName.set(manifest.name, meta);
cratesByGroupPath.set(groupPath, meta);
}
// Phase 2: For each crate, identify which of its workspace deps are
// also in this group (i.e., repos we can link to)
const links: GroupManifestLink[] = [];
const seen = new Set<string>();
for (const [, crate] of cratesByGroupPath) {
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
if (groupCrateDeps.length === 0) continue;
// Phase 3: Scan source files for imports from workspace deps
const knownCrates = new Set(groupCrateDeps);
const imports = await scanImports(crate.repoPath, knownCrates);
for (const imp of imports) {
const providerCrate = cratesByName.get(imp.crateName);
if (!providerCrate) continue;
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerCrate.groupPath,
to: crate.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredCrates: cratesByGroupPath };
}

View file

@ -8,6 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
const entries = await readRegistry();
registryEntries = await readRegistry();
const entries = registryEntries;
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Process manifest links declared in group.yaml.
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
// and merge them with explicit manifest links. Discovered links use the same
// ManifestExtractor pipeline as hand-written links in group.yaml.
let allLinks = [...config.links];
if (config.detect.workspace_deps) {
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
if (e) repoPaths.set(groupPath, e.path);
}
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
console.log(
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
);
}
}
}
// Process manifest links declared in group.yaml (plus any auto-discovered).
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
if (allLinks.length > 0) {
const knownRepos = new Set(Object.keys(config.repos));
for (const link of config.links) {
for (const link of allLinks) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
console.warn(
@ -198,12 +222,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
);
}
}

View file

@ -27,6 +27,7 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {

View file

@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { extractRustWorkspaceLinks } from '../../../src/core/group/extractors/rust-workspace-extractor.js';
describe('RustWorkspaceExtractor', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-rust-ws-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function writeFile(relPath: string, content: string) {
const absPath = path.join(tmpDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, content, 'utf-8');
}
it('discovers cross-crate imports from workspace dependencies', async () => {
// Crate A: defines Expression
await writeFile(
'crate-a/Cargo.toml',
`[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('crate-a/src/lib.rs', 'pub struct Expression {}\npub struct Token {}\n');
// Crate B: depends on A via workspace, imports Expression
await writeFile(
'crate-b/Cargo.toml',
`[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n`,
);
await writeFile('crate-b/src/main.rs', 'use mathlex::Expression;\nfn eval(e: Expression) {}\n');
const repos = {
'parser/mathlex': 'mathlex',
'engine/thales': 'thales',
};
const repoPaths = new Map([
['parser/mathlex', path.join(tmpDir, 'crate-a')],
['engine/thales', path.join(tmpDir, 'crate-b')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(1);
expect(result.links[0]).toEqual({
from: 'parser/mathlex',
to: 'engine/thales',
type: 'custom',
contract: 'mathlex::Expression',
role: 'provider',
});
});
it('handles hyphenated crate names (converted to underscores in use statements)', async () => {
await writeFile(
'units/Cargo.toml',
`[package]\nname = "mathcore-units"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('units/src/lib.rs', 'pub struct Unit {}\npub struct Dimension {}\n');
await writeFile(
'engine/Cargo.toml',
`[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathcore-units = { workspace = true }\n`,
);
await writeFile(
'engine/src/main.rs',
'use mathcore_units::Unit;\nuse mathcore_units::Dimension;\n',
);
const repos = {
'core/units': 'mathcore-units',
'engine/thales': 'thales',
};
const repoPaths = new Map([
['core/units', path.join(tmpDir, 'units')],
['engine/thales', path.join(tmpDir, 'engine')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(2);
const contracts = result.links.map((l) => l.contract).sort();
expect(contracts).toEqual(['mathcore-units::Dimension', 'mathcore-units::Unit']);
});
it('handles grouped imports (use crate::{Type1, Type2})', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', 'pub struct Foo {}\npub struct Bar {}\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use shared::{Foo, Bar};\n');
const repos = { lib: 'shared', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(2);
const contracts = result.links.map((l) => l.contract).sort();
expect(contracts).toEqual(['shared::Bar', 'shared::Foo']);
});
it('ignores snake_case imports (functions/modules, not types)', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "utils"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', 'pub fn helper() {}\npub struct Config {}\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nutils = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use utils::helper;\nuse utils::Config;\n');
const repos = { lib: 'utils', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(1);
expect(result.links[0].contract).toBe('utils::Config');
});
it('skips repos without Cargo.toml', async () => {
await writeFile('js-app/package.json', '{"name": "js-app"}');
await writeFile('js-app/src/index.ts', 'export const x = 1;');
const repos = { app: 'js-app' };
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(0);
expect(result.discoveredCrates.size).toBe(0);
});
it('deduplicates identical imports from multiple files', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', 'pub struct Config {}\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use shared::Config;\n');
await writeFile('app/src/other.rs', 'use shared::Config;\n');
const repos = { lib: 'shared', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(1);
});
it('handles path dependencies alongside workspace deps', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', 'pub trait Handler {}\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { path = "../lib" }\n`,
);
await writeFile('app/src/main.rs', 'use mylib::Handler;\n');
const repos = { lib: 'mylib', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(1);
expect(result.links[0].contract).toBe('mylib::Handler');
});
it('warns and skips duplicate crate names', async () => {
await writeFile(
'repo-a/Cargo.toml',
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('repo-a/src/lib.rs', 'pub struct Alpha {}\n');
await writeFile(
'repo-b/Cargo.toml',
`[package]\nname = "shared"\nversion = "0.2.0"\n\n[dependencies]\n`,
);
await writeFile('repo-b/src/lib.rs', 'pub struct Beta {}\n');
await writeFile(
'consumer/Cargo.toml',
`[package]\nname = "consumer"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
);
await writeFile('consumer/src/main.rs', 'use shared::Alpha;\n');
const repos = { a: 'shared-a', b: 'shared-b', consumer: 'consumer' };
const repoPaths = new Map([
['a', path.join(tmpDir, 'repo-a')],
['b', path.join(tmpDir, 'repo-b')],
['consumer', path.join(tmpDir, 'consumer')],
]);
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(String(args[0]));
};
try {
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(warnings.some((w) => w.includes('duplicate crate name "shared"'))).toBe(true);
expect(result.links).toHaveLength(1);
expect(result.links[0].from).toBe('a');
} finally {
console.warn = origWarn;
}
});
it('produces distinct contracts when two crates export same symbol name', async () => {
await writeFile(
'lib-a/Cargo.toml',
`[package]\nname = "alpha"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib-a/src/lib.rs', 'pub struct Config {}\n');
await writeFile(
'lib-b/Cargo.toml',
`[package]\nname = "beta"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib-b/src/lib.rs', 'pub struct Config {}\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nalpha = { workspace = true }\nbeta = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use alpha::Config;\nuse beta::Config;\n');
const repos = { alpha: 'alpha', beta: 'beta', app: 'myapp' };
const repoPaths = new Map([
['alpha', path.join(tmpDir, 'lib-a')],
['beta', path.join(tmpDir, 'lib-b')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(2);
const contracts = result.links.map((l) => l.contract).sort();
expect(contracts).toEqual(['alpha::Config', 'beta::Config']);
});
it('respects .gitnexusignore patterns', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', 'pub struct Real {}\n');
await writeFile('lib/generated/gen.rs', 'pub struct Fake {}\n');
await writeFile('lib/.gitnexusignore', 'generated/\n');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use mylib::Real;\n');
await writeFile('app/generated/gen.rs', 'use mylib::Fake;\n');
await writeFile('app/.gitnexusignore', 'generated/\n');
const repos = { lib: 'mylib', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(1);
expect(result.links[0].contract).toBe('mylib::Real');
});
it('handles nested module imports (use crate::module::Type)', async () => {
await writeFile(
'lib/Cargo.toml',
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
);
await writeFile('lib/src/lib.rs', '');
await writeFile(
'app/Cargo.toml',
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
);
await writeFile('app/src/main.rs', 'use shared::models::User;\nuse shared::auth::Token;\n');
const repos = { lib: 'shared', app: 'myapp' };
const repoPaths = new Map([
['lib', path.join(tmpDir, 'lib')],
['app', path.join(tmpDir, 'app')],
]);
const result = await extractRustWorkspaceLinks(repos, repoPaths);
expect(result.links).toHaveLength(2);
const contracts = result.links.map((l) => l.contract).sort();
expect(contracts).toEqual(['shared::Token', 'shared::User']);
});
});

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@ -335,6 +335,201 @@ describe('syncGroup', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
describe('workspace_deps integration', () => {
let tmpDir: string;
function makeWsConfig(repos: Record<string, string>, workspaceDeps: boolean): GroupConfig {
return {
version: 1,
name: 'test',
description: '',
repos,
links: [],
packages: {},
detect: {
http: false,
grpc: false,
topics: false,
shared_libs: false,
embedding_fallback: false,
workspace_deps: workspaceDeps,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
};
}
function writeFileSync(relPath: string, content: string) {
const absPath = path.join(tmpDir, relPath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, 'utf-8');
}
afterEach(() => {
vi.restoreAllMocks();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('workspace_deps: true discovers Rust crate links through syncGroup', async () => {
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
writeFileSync(
'crate-a/Cargo.toml',
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
);
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
writeFileSync(
'crate-b/Cargo.toml',
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
);
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
const mockEntries: RegistryEntry[] = [
{
name: 'mathlex',
path: path.join(tmpDir, 'crate-a'),
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
indexedAt: '',
lastCommit: '',
},
{
name: 'thales',
path: path.join(tmpDir, 'crate-b'),
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
indexedAt: '',
lastCommit: '',
},
];
const repoManager = await import('../../../src/storage/repo-manager.js');
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
const config = makeWsConfig({ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' }, true);
const result = await syncGroup(config, {
extractorOverride: async () => [],
skipWrite: true,
});
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
expect(manifestLinks).toHaveLength(1);
expect(manifestLinks[0].contractId).toBe('custom::mathlex::Expression');
expect(manifestLinks[0].from.repo).toBe('engine/thales');
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
});
it('workspace_deps: false skips Rust workspace extraction', async () => {
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-off-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
writeFileSync(
'crate-a/Cargo.toml',
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
);
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
writeFileSync(
'crate-b/Cargo.toml',
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
);
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
const repoManager = await import('../../../src/storage/repo-manager.js');
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue([]);
const config = makeWsConfig(
{ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
false,
);
const result = await syncGroup(config, {
extractorOverride: async () => [],
skipWrite: true,
});
expect(result.crossLinks).toHaveLength(0);
expect(result.contracts).toHaveLength(0);
});
it('discovered workspace links merge with explicit manifest links', async () => {
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-merge-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
writeFileSync(
'crate-a/Cargo.toml',
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
);
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
writeFileSync(
'crate-b/Cargo.toml',
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
);
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
const mockEntries: RegistryEntry[] = [
{
name: 'mathlex',
path: path.join(tmpDir, 'crate-a'),
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
indexedAt: '',
lastCommit: '',
},
{
name: 'thales',
path: path.join(tmpDir, 'crate-b'),
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
indexedAt: '',
lastCommit: '',
},
];
const repoManager = await import('../../../src/storage/repo-manager.js');
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
const explicitLinks: GroupManifestLink[] = [
{
from: 'parser/mathlex',
to: 'engine/thales',
type: 'http',
contract: 'GET::/api/parse',
role: 'provider',
},
];
const config: GroupConfig = {
version: 1,
name: 'test',
description: '',
repos: { 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
links: explicitLinks,
packages: {},
detect: {
http: false,
grpc: false,
topics: false,
shared_libs: false,
embedding_fallback: false,
workspace_deps: true,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
};
const result = await syncGroup(config, {
extractorOverride: async () => [],
skipWrite: true,
});
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
expect(manifestLinks.length).toBeGreaterThanOrEqual(2);
const contractIds = manifestLinks.map((cl) => cl.contractId);
expect(contractIds).toContain('http::GET::/api/parse');
expect(contractIds).toContain('custom::mathlex::Expression');
});
});
});
describe('stableRepoPoolId', () => {