From 4957d3edd376d1458269da54836390904ed8657b Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Sun, 3 May 2026 02:43:22 +0200 Subject: [PATCH] feat(group): auto-discover Rust workspace cross-crate contracts (#1256) --- gitnexus/src/core/group/config-parser.ts | 1 + .../extractors/rust-workspace-extractor.ts | 270 ++++++++++++++ gitnexus/src/core/group/sync.ts | 42 ++- gitnexus/src/core/group/types.ts | 1 + .../group/rust-workspace-extractor.test.ts | 335 ++++++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 197 +++++++++- 6 files changed, 836 insertions(+), 10 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/rust-workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/rust-workspace-extractor.test.ts diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index cf2141311..d55969a73 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -13,6 +13,7 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, + workspace_deps: true, }; const DEFAULT_MATCHING = { diff --git a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts new file mode 100644 index 000000000..c19af07ca --- /dev/null +++ b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts @@ -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 ::` 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 ::::` patterns + * where is a known workspace dependency. + */ +async function scanImports(repoPath: string, knownCrates: Set): Promise { + const results: ImportedSymbol[] = []; + + const normalizedCrates = new Map(); + 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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + 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; +} + +/** + * Discover cross-crate contracts across all Rust repos in a group. + * + * Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`. + */ +export async function extractRustWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + // Phase 1: Parse all Cargo.toml files to build crate registry + const cratesByName = new Map(); + const cratesByGroupPath = new Map(); + + 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(); + + 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 }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index bd2590ecd..a9ecb51f4 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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 | undefined; + let registryEntries: RegistryEntry[] | undefined; const eo = opts?.extractorOverride; if (eo && eo.length === 0) { autoContracts = await (eo as () => Promise)(); } 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(); + 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)`, ); } } diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 64ce53143..895bef6dc 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -27,6 +27,7 @@ export interface DetectConfig { topics: boolean; shared_libs: boolean; embedding_fallback: boolean; + workspace_deps: boolean; } export interface MatchingConfig { diff --git a/gitnexus/test/unit/group/rust-workspace-extractor.test.ts b/gitnexus/test/unit/group/rust-workspace-extractor.test.ts new file mode 100644 index 000000000..a9e277c36 --- /dev/null +++ b/gitnexus/test/unit/group/rust-workspace-extractor.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 5aa586c25..87a7c6645 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -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, 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', () => {