mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
feat(group): auto-discover Rust workspace cross-crate contracts (#1256)
This commit is contained in:
parent
0728a4ef58
commit
4957d3edd3
6 changed files with 836 additions and 10 deletions
|
|
@ -13,6 +13,7 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
|
|
|||
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal file
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface DetectConfig {
|
|||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
|
|
|
|||
335
gitnexus/test/unit/group/rust-workspace-extractor.test.ts
Normal file
335
gitnexus/test/unit/group/rust-workspace-extractor.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue