mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
feat(group): workspace extractors for Node, Python, Go, Java, Elixir (#1260)
* feat(group): auto-discover Node/TS workspace cross-package contracts
Scan package.json dependencies and ES/CJS imports to find PascalCase
type exports crossing workspace package boundaries. Same pipeline as
Rust workspace extractor — emits GroupManifestLink[] with type:custom.
Supports: ES named imports, default imports, CommonJS destructured
require, scoped packages (@org/pkg), subpath imports, aliased imports.
Filters to PascalCase names only (types/classes, not functions).
* feat(group): auto-discover Python workspace cross-package contracts
Scan pyproject.toml/setup.py dependencies and `from <pkg> import`
statements to find PascalCase type exports crossing workspace package
boundaries. Handles hyphenated names (PEP 503 normalization),
submodule imports, aliased imports, and optional-dependencies.
* feat(group): auto-discover Go workspace cross-module contracts
Scan go.mod require/replace directives and Go source files for
exported PascalCase type usage (pkg.TypeName) crossing module
boundaries within a group. Handles block syntax, subpackage
imports, and local replace directives.
* refactor(group): extract workspace discovery orchestrator from sync
Move per-ecosystem workspace extractor calls into a single
discoverWorkspaceLinks() orchestrator. Reduces sync.ts from 295
to 264 lines and gives a clean extension point for adding
more ecosystem extractors.
* feat(group): auto-discover Java/Kotlin workspace cross-project contracts
Scan Maven pom.xml and Gradle build files for inter-project deps,
then match Java/Kotlin import statements against known group-internal
base packages. Supports Maven dependency blocks, Gradle coordinate
and project() dependencies, static imports, and Kotlin files.
* feat(group): auto-discover Elixir workspace cross-app contracts
Scan mix.exs deps and Elixir source files for alias directives and
direct module references crossing OTP app boundaries. Handles
umbrella deps (in_umbrella), git/path deps, grouped aliases
(alias MyApp.{ModA, ModB}), underscore-to-PascalCase app name
mapping, and collapses nested submodules to top-level contracts.
* fix(group): apply PR review fixes to all workspace extractors
Address review findings from PR #1256 across Node, Python, Go, Java,
and Elixir extractors:
- Replace hardcoded IGNORE sets with shared IgnoreService
(shouldIgnorePath + loadIgnoreRules) to honor .gitnexusignore
- Qualify contract names with provider identifier to prevent
contractId collisions across providers
- Warn and skip duplicate project/module/app names
- Update all test assertions for qualified contract format
* fix(workspace): address review findings and fix CI
- Fix prettier formatting on Rust workspace extractor files
- Fix double readRegistry() call in syncGroup (hoist to function scope)
- Fix console.warn spy leak in duplicate crate test (try/finally)
- Add sync-level integration tests: workspace_deps true/false gating,
Rust and Node link discovery through syncGroup orchestrator (3 tests)
* style(workspace): fix Prettier formatting on all workspace extractors
* fix(workspace): strip qualified prefix in custom contract resolution, default workspace_deps to false
resolveSymbol for custom contracts now strips the "provider::" prefix
before querying graph nodes, so workspace-generated contracts like
"mathlex::Expression" correctly resolve to the "Expression" symbol.
Change workspace_deps default from true to false for safe rollout —
existing groups won't silently gain 6-ecosystem scans on upgrade.
* fix(workspace): address medium review findings from PR #1260
- Elixir: strip comment lines before direct module reference scan to
prevent false positives from commented-out module references
- Go: use full module path for contract naming to avoid basename
collisions between repos with identical last path segments
- Sync tests: replace toBeGreaterThanOrEqual with exact toHaveLength
assertions per DoD §2.7
- Add workspace_deps: false to makeConfig helper for type correctness
- Add Elixir test proving comment-only references do not emit links
* fix(workspace): address second-round medium review findings
- Go: add test asserting aliased imports produce 0 links, guarding the
V1 false-negative boundary at the assertion level
- Elixir: add code comment documenting that contracts use full module
names without appName:: prefix and that resolveSymbol resolution
depends on Elixir indexer storing fully-qualified names
* fix(workspace): eliminate regex backtracking in pyproject.toml parser
CodeQL flagged exponential backtracking in the [project] name regex.
Replace [^\[]*?\n (ambiguous lazy quantifier) with [^\n\[]*\n (atomic
per-line match that still stops at section boundaries).
* fix(test): use mkdtempSync for secure temp dir creation
CodeQL flagged insecure temporary file creation (High) in sync.test.ts.
Replace path.join(os.tmpdir(), predictable-name) + mkdirSync with
fs.mkdtempSync which creates temp dirs atomically with random suffix,
preventing symlink race conditions.
This commit is contained in:
parent
6ec1f04604
commit
7cce07b419
16 changed files with 2745 additions and 26 deletions
|
|
@ -13,7 +13,7 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
workspace_deps: false,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
|
|
|||
253
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
253
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface ElixirAppMeta {
|
||||
appName: string;
|
||||
modulePrefix: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedModule {
|
||||
appName: string;
|
||||
moduleName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseMixExs(
|
||||
repoPath: string,
|
||||
): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> {
|
||||
const mixPath = path.join(repoPath, 'mix.exs');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(mixPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// app: :my_app
|
||||
const appMatch = content.match(/app:\s*:(\w+)/);
|
||||
if (!appMatch) return null;
|
||||
const appName = appMatch[1];
|
||||
|
||||
// Derive module prefix: my_app -> MyApp
|
||||
const modulePrefix = appName
|
||||
.split('_')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
const deps: string[] = [];
|
||||
|
||||
// {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true}
|
||||
// {:dep_name, git: "..."} or {:dep_name, path: "..."}
|
||||
const depMatches = content.matchAll(
|
||||
/\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
deps.push(m[1]);
|
||||
}
|
||||
|
||||
return { appName, modulePrefix, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanElixirImports(
|
||||
repoPath: string,
|
||||
knownApps: Map<string, string>,
|
||||
): Promise<ImportedModule[]> {
|
||||
const results: ImportedModule[] = [];
|
||||
const sourceFiles = await findElixirFiles(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;
|
||||
}
|
||||
|
||||
// alias MyApp.SomeModule
|
||||
// alias MyApp.SomeModule, as: Short
|
||||
// alias MyApp.{ModA, ModB}
|
||||
const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm;
|
||||
let match;
|
||||
while ((match = aliasRegex.exec(content)) !== null) {
|
||||
const aliasExpr = match[1];
|
||||
const modules = expandAlias(aliasExpr);
|
||||
for (const mod of modules) {
|
||||
const appName = matchModuleToApp(mod, knownApps);
|
||||
if (appName) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct module reference: MyApp.Module.func() or MyApp.Module
|
||||
// Strip comment lines and string literals to avoid false positives
|
||||
const codeOnly = content
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('#'))
|
||||
.join('\n');
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
const refRegex = new RegExp(
|
||||
`\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`,
|
||||
'g',
|
||||
);
|
||||
while ((match = refRegex.exec(codeOnly)) !== null) {
|
||||
const mod = match[1];
|
||||
if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function expandAlias(expr: string): string[] {
|
||||
const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/);
|
||||
if (braceMatch) {
|
||||
const prefix = braceMatch[1];
|
||||
return braceMatch[2]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => `${prefix}.${s}`);
|
||||
}
|
||||
return [expr];
|
||||
}
|
||||
|
||||
function matchModuleToApp(moduleName: string, knownApps: Map<string, string>): string | null {
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
if (moduleName === prefix || moduleName.startsWith(prefix + '.')) {
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractTopModule(moduleName: string, prefix: string): string {
|
||||
const rest = moduleName.slice(prefix.length);
|
||||
if (!rest || rest === '.') return moduleName;
|
||||
const afterDot = rest.startsWith('.') ? rest.slice(1) : rest;
|
||||
const parts = afterDot.split('.');
|
||||
return `${prefix}.${parts[0]}`;
|
||||
}
|
||||
|
||||
async function findElixirFiles(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('.ex') || entry.name.endsWith('.exs')) {
|
||||
if (entry.name === 'mix.exs' || entry.name === 'mix.lock') continue;
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface ElixirWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredApps: Map<string, ElixirAppMeta>;
|
||||
}
|
||||
|
||||
export async function extractElixirWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<ElixirWorkspaceResult> {
|
||||
const appsByName = new Map<string, ElixirAppMeta>();
|
||||
const appsByGroupPath = new Map<string, ElixirAppMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseMixExs(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: ElixirAppMeta = {
|
||||
appName: manifest.appName,
|
||||
modulePrefix: manifest.modulePrefix,
|
||||
groupPath,
|
||||
repoPath,
|
||||
deps: manifest.deps,
|
||||
};
|
||||
const existing = appsByName.get(manifest.appName);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
appsByName.set(manifest.appName, meta);
|
||||
appsByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, app] of appsByGroupPath) {
|
||||
const groupDeps = app.deps.filter((d) => appsByName.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownApps = new Map<string, string>();
|
||||
for (const dep of groupDeps) {
|
||||
const depMeta = appsByName.get(dep);
|
||||
if (depMeta) knownApps.set(depMeta.modulePrefix, dep);
|
||||
}
|
||||
|
||||
const imports = await scanElixirImports(app.repoPath, knownApps);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerApp = appsByName.get(imp.appName);
|
||||
if (!providerApp) continue;
|
||||
|
||||
const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix);
|
||||
const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// V1: Elixir contracts use the full module name (e.g. "Core.Schema") without
|
||||
// an "appName::" prefix. resolveSymbol will query the graph with this full
|
||||
// string — resolution depends on Elixir indexer storing fully-qualified names.
|
||||
const link: GroupManifestLink = {
|
||||
from: providerApp.groupPath,
|
||||
to: app.groupPath,
|
||||
type: 'custom',
|
||||
contract: topModule,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredApps: appsByGroupPath };
|
||||
}
|
||||
258
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal file
258
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
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, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface GoModuleMeta {
|
||||
modulePath: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
requires: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
modulePath: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseGoMod(
|
||||
repoPath: string,
|
||||
): Promise<{ modulePath: string; requires: string[] } | null> {
|
||||
const goModPath = path.join(repoPath, 'go.mod');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(goModPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
||||
if (!moduleMatch) return null;
|
||||
const modulePath = moduleMatch[1];
|
||||
|
||||
const requires: string[] = [];
|
||||
|
||||
// Single-line: require github.com/org/repo v1.2.3
|
||||
const singleReqs = content.matchAll(/^require\s+(\S+)\s+/gm);
|
||||
for (const m of singleReqs) requires.push(m[1]);
|
||||
|
||||
// Block: require ( ... )
|
||||
const blockReqs = content.matchAll(/^require\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of blockReqs) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts[0]) requires.push(parts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// replace directives (local path deps)
|
||||
const replaceLines = content.matchAll(/^replace\s+(\S+)\s+=>\s+\.\//gm);
|
||||
for (const m of replaceLines) {
|
||||
if (!requires.includes(m[1])) requires.push(m[1]);
|
||||
}
|
||||
|
||||
const replaceBlocks = content.matchAll(/^replace\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of replaceBlocks) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const match = trimmed.match(/^(\S+)\s+=>\s+\.\//);
|
||||
if (match && !requires.includes(match[1])) requires.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { modulePath, requires: [...new Set(requires)] };
|
||||
}
|
||||
|
||||
async function scanGoImports(
|
||||
repoPath: string,
|
||||
knownModules: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findGoFiles(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;
|
||||
}
|
||||
|
||||
const importPaths = extractImportPaths(content);
|
||||
for (const importPath of importPaths) {
|
||||
const matchedModule = findMatchingModule(importPath, knownModules);
|
||||
if (!matchedModule) continue;
|
||||
|
||||
const symbols = extractUsedTypes(content, importPath);
|
||||
for (const sym of symbols) {
|
||||
results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractImportPaths(content: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
// Single: import "path"
|
||||
const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm);
|
||||
for (const m of singleImports) paths.push(m[1]);
|
||||
|
||||
// Single aliased: import alias "path"
|
||||
const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm);
|
||||
for (const m of aliasedImports) paths.push(m[1]);
|
||||
|
||||
// Block: import ( ... )
|
||||
const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of blockImports) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const pathMatch = trimmed.match(/"([^"]+)"/);
|
||||
if (pathMatch) paths.push(pathMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
function findMatchingModule(importPath: string, knownModules: Map<string, string>): string | null {
|
||||
for (const [modPath] of knownModules) {
|
||||
if (importPath === modPath || importPath.startsWith(modPath + '/')) {
|
||||
return modPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUsedTypes(content: string, importPath: string): string[] {
|
||||
const pkgName = importPath.split('/').pop() || '';
|
||||
if (!pkgName) return [];
|
||||
|
||||
// Match pkg.TypeName where TypeName is PascalCase (exported)
|
||||
const typeRegex = new RegExp(`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`, 'g');
|
||||
const types = new Set<string>();
|
||||
let match;
|
||||
while ((match = typeRegex.exec(content)) !== null) {
|
||||
types.add(match[1]);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function findGoFiles(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('.go') && !entry.name.endsWith('_test.go')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface GoWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredModules: Map<string, GoModuleMeta>;
|
||||
}
|
||||
|
||||
export async function extractGoWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<GoWorkspaceResult> {
|
||||
const modulesByPath = new Map<string, GoModuleMeta>();
|
||||
const modulesByGroupPath = new Map<string, GoModuleMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseGoMod(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: GoModuleMeta = {
|
||||
modulePath: manifest.modulePath,
|
||||
groupPath,
|
||||
repoPath,
|
||||
requires: manifest.requires,
|
||||
};
|
||||
const existing = modulesByPath.get(manifest.modulePath);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[go-workspace-extractor] duplicate module "${manifest.modulePath}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
modulesByPath.set(manifest.modulePath, meta);
|
||||
modulesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, mod] of modulesByGroupPath) {
|
||||
const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r));
|
||||
if (groupModDeps.length === 0) continue;
|
||||
|
||||
const knownModules = new Map<string, string>();
|
||||
for (const dep of groupModDeps) {
|
||||
knownModules.set(dep, dep);
|
||||
}
|
||||
|
||||
const imports = await scanGoImports(mod.repoPath, knownModules);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerMod = modulesByPath.get(imp.modulePath);
|
||||
if (!providerMod) continue;
|
||||
|
||||
const qualifiedContract = `${imp.modulePath}::${imp.symbolName}`;
|
||||
const key = `${mod.groupPath}→${providerMod.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerMod.groupPath,
|
||||
to: mod.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredModules: modulesByGroupPath };
|
||||
}
|
||||
261
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
261
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface JavaProjectMeta {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
basePackage: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
artifactKey: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseJavaManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
|
||||
const pomPath = path.join(repoPath, 'pom.xml');
|
||||
try {
|
||||
const content = await fs.readFile(pomPath, 'utf-8');
|
||||
return parsePom(content);
|
||||
} catch {
|
||||
// fall through to Gradle
|
||||
}
|
||||
|
||||
for (const name of ['build.gradle.kts', 'build.gradle']) {
|
||||
const gradlePath = path.join(repoPath, name);
|
||||
try {
|
||||
const content = await fs.readFile(gradlePath, 'utf-8');
|
||||
return parseGradle(content, repoPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const projectGroupMatch = content.match(/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/);
|
||||
const projectArtifactMatch = content.match(
|
||||
/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/,
|
||||
);
|
||||
if (!projectGroupMatch || !projectArtifactMatch) return null;
|
||||
|
||||
const groupId = projectGroupMatch[1].trim();
|
||||
const artifactId = projectArtifactMatch[1].trim();
|
||||
|
||||
const deps: string[] = [];
|
||||
const depBlocks = content.matchAll(/<dependency>\s*([\s\S]*?)<\/dependency>/g);
|
||||
for (const block of depBlocks) {
|
||||
const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
|
||||
const aMatch = block[1].match(/<artifactId>([^<]+)<\/artifactId>/);
|
||||
if (gMatch && aMatch) {
|
||||
deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseGradle(
|
||||
content: string,
|
||||
repoPath: string,
|
||||
): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
|
||||
const dirName = path.basename(repoPath);
|
||||
const groupId = groupMatch ? groupMatch[1] : '';
|
||||
if (!groupId) return null;
|
||||
|
||||
const artifactId = dirName;
|
||||
|
||||
const deps: string[] = [];
|
||||
// implementation("group:artifact:version") or api("group:artifact:version")
|
||||
const depMatches = content.matchAll(
|
||||
/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
const parts = m[1].split(':');
|
||||
if (parts.length >= 2) {
|
||||
deps.push(`${parts[0]}:${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// implementation(project(":subproject"))
|
||||
const projDeps = content.matchAll(
|
||||
/(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g,
|
||||
);
|
||||
for (const m of projDeps) {
|
||||
const subName = m[1].replace(/^:/, '');
|
||||
deps.push(`${groupId}:${subName}`);
|
||||
}
|
||||
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function deriveBasePackage(groupId: string, artifactId: string): string {
|
||||
const sanitized = artifactId.replace(/-/g, '.');
|
||||
if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) {
|
||||
return groupId;
|
||||
}
|
||||
return `${groupId}.${sanitized}`;
|
||||
}
|
||||
|
||||
async function scanJavaImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findJavaFiles(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;
|
||||
}
|
||||
|
||||
const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm;
|
||||
let match;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const fullImport = match[1];
|
||||
for (const [basePkg, artifactKey] of knownPackages) {
|
||||
if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) {
|
||||
const parts = fullImport.split('.');
|
||||
const className = parts[parts.length - 1];
|
||||
if (isPascalCase(className)) {
|
||||
results.push({
|
||||
artifactKey,
|
||||
symbolName: className,
|
||||
filePath: relFile,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isPascalCase(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findJavaFiles(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('.java') || entry.name.endsWith('.kt')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface JavaWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredProjects: Map<string, JavaProjectMeta>;
|
||||
}
|
||||
|
||||
export async function extractJavaWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<JavaWorkspaceResult> {
|
||||
const projectsByKey = new Map<string, JavaProjectMeta>();
|
||||
const projectsByGroupPath = new Map<string, JavaProjectMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseJavaManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const key = `${manifest.groupId}:${manifest.artifactId}`;
|
||||
const meta: JavaProjectMeta = {
|
||||
groupId: manifest.groupId,
|
||||
artifactId: manifest.artifactId,
|
||||
basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId),
|
||||
groupPath,
|
||||
repoPath,
|
||||
deps: manifest.deps,
|
||||
};
|
||||
const existing = projectsByKey.get(key);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[java-workspace-extractor] duplicate artifact "${key}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
projectsByKey.set(key, meta);
|
||||
projectsByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, proj] of projectsByGroupPath) {
|
||||
const groupDeps = proj.deps.filter((d) => projectsByKey.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
for (const dep of groupDeps) {
|
||||
const depMeta = projectsByKey.get(dep);
|
||||
if (depMeta) knownPackages.set(depMeta.basePackage, dep);
|
||||
}
|
||||
|
||||
const imports = await scanJavaImports(proj.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerProj = projectsByKey.get(imp.artifactKey);
|
||||
if (!providerProj) continue;
|
||||
|
||||
const qualifiedContract = `${providerProj.artifactId}::${imp.symbolName}`;
|
||||
const dedupKey = `${proj.groupPath}→${providerProj.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerProj.groupPath,
|
||||
to: proj.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredProjects: projectsByGroupPath };
|
||||
}
|
||||
|
|
@ -269,17 +269,19 @@ export class ManifestExtractor {
|
|||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
// Workspace extractors produce qualified contracts like "mathlex::Expression".
|
||||
// Graph nodes store the unqualified symbol name ("Expression"), so strip
|
||||
// the "provider::" prefix before querying.
|
||||
const symbolName = link.contract.includes('::')
|
||||
? link.contract.split('::').pop()!
|
||||
: link.contract;
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
WHERE n.name = $symbolName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
{ symbolName },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
|
|
|
|||
248
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
248
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface PackageMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePackageManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const pkgPath = path.join(repoPath, 'package.json');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(pkgPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = typeof pkg.name === 'string' ? pkg.name : '';
|
||||
if (!name) return null;
|
||||
|
||||
const deps: string[] = [];
|
||||
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
||||
const section = pkg[field];
|
||||
if (section && typeof section === 'object') {
|
||||
deps.push(...Object.keys(section as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, workspaceDeps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanImports(
|
||||
repoPath: string,
|
||||
knownPackages: Set<string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findSourceFiles(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;
|
||||
}
|
||||
|
||||
// ES import: import { Foo, Bar } from '<pkg>'
|
||||
// Also: import { Foo as Baz } from '<pkg>'
|
||||
const esImportRegex = /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm;
|
||||
let match;
|
||||
while ((match = esImportRegex.exec(content)) !== null) {
|
||||
const importClause = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
const symbols = parseImportClause(importClause);
|
||||
for (const sym of symbols) {
|
||||
if (isExportedName(sym)) {
|
||||
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ES import default: import Foo from '<pkg>'
|
||||
const defaultImportRegex = /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm;
|
||||
while ((match = defaultImportRegex.exec(content)) !== null) {
|
||||
const symbolName = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
if (isExportedName(symbolName)) {
|
||||
results.push({ packageName: pkgName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
|
||||
// CommonJS: const { Foo, Bar } = require('<pkg>')
|
||||
const cjsRegex = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
|
||||
while ((match = cjsRegex.exec(content)) !== null) {
|
||||
const importClause = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
const symbols = parseImportClause(importClause);
|
||||
for (const sym of symbols) {
|
||||
if (isExportedName(sym)) {
|
||||
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolvePackageName(modulePath: string): string | null {
|
||||
if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null;
|
||||
// Scoped: @scope/pkg or @scope/pkg/sub
|
||||
if (modulePath.startsWith('@')) {
|
||||
const parts = modulePath.split('/');
|
||||
if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;
|
||||
return null;
|
||||
}
|
||||
// Unscoped: pkg or pkg/sub
|
||||
return modulePath.split('/')[0];
|
||||
}
|
||||
|
||||
function parseImportClause(clause: string): string[] {
|
||||
return clause
|
||||
.split(',')
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
// Handle `Foo as Bar` — use the original export name
|
||||
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
|
||||
return asMatch ? asMatch[1] : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isExportedName(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findSourceFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
|
||||
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 {
|
||||
const ext = path.extname(entry.name);
|
||||
if (EXTENSIONS.has(ext)) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface NodeWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredPackages: Map<string, PackageMeta>;
|
||||
}
|
||||
|
||||
export async function extractNodeWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<NodeWorkspaceResult> {
|
||||
const packagesByName = new Map<string, PackageMeta>();
|
||||
const packagesByGroupPath = new Map<string, PackageMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parsePackageManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: PackageMeta = {
|
||||
name: manifest.name,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.workspaceDeps,
|
||||
};
|
||||
const existing = packagesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[node-workspace-extractor] duplicate package name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
packagesByName.set(manifest.name, meta);
|
||||
packagesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, pkg] of packagesByGroupPath) {
|
||||
const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d));
|
||||
if (groupPkgDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Set(groupPkgDeps);
|
||||
const imports = await scanImports(pkg.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerPkg = packagesByName.get(imp.packageName);
|
||||
if (!providerPkg) continue;
|
||||
|
||||
const qualifiedContract = `${imp.packageName}::${imp.symbolName}`;
|
||||
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerPkg.groupPath,
|
||||
to: pkg.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredPackages: packagesByGroupPath };
|
||||
}
|
||||
254
gitnexus/src/core/group/extractors/python-workspace-extractor.ts
Normal file
254
gitnexus/src/core/group/extractors/python-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface PythonPackageMeta {
|
||||
name: string;
|
||||
importName: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePythonManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; importName: string; deps: string[] } | null> {
|
||||
const pyprojectPath = path.join(repoPath, 'pyproject.toml');
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = await fs.readFile(pyprojectPath, 'utf-8');
|
||||
} catch {
|
||||
// fall through to setup.py
|
||||
}
|
||||
|
||||
if (content) return parsePyproject(content);
|
||||
|
||||
const setupPyPath = path.join(repoPath, 'setup.py');
|
||||
try {
|
||||
content = await fs.readFile(setupPyPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseSetupPy(content);
|
||||
}
|
||||
|
||||
function parsePyproject(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
||||
if (depsMatch) {
|
||||
const depLines = depsMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
const optMatch = content.match(/\[project\.optional-dependencies\]\s*\n([\s\S]*?)(?=\n\[|$)/);
|
||||
if (optMatch) {
|
||||
const optDeps = optMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of optDeps) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseSetupPy(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/);
|
||||
if (installMatch) {
|
||||
const depLines = installMatch[1].matchAll(/['"]([^'"]+)['"]/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function extractPepName(spec: string): string {
|
||||
return spec.split(/[><=!~;\[]/)[0].trim();
|
||||
}
|
||||
|
||||
async function scanPythonImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findPythonFiles(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;
|
||||
}
|
||||
|
||||
// from <pkg> import Foo, Bar
|
||||
// from <pkg>.module import Foo
|
||||
const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm;
|
||||
let match;
|
||||
while ((match = fromImportRegex.exec(content)) !== null) {
|
||||
const modulePath = match[1];
|
||||
const importClause = match[2];
|
||||
const rootModule = modulePath.split('.')[0];
|
||||
const originalName = knownPackages.get(rootModule);
|
||||
if (!originalName) continue;
|
||||
|
||||
if (importClause.trim() === '(') continue;
|
||||
|
||||
const symbols = importClause
|
||||
.replace(/\(|\)/g, '')
|
||||
.split(',')
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
|
||||
return asMatch ? asMatch[1] : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sym of symbols) {
|
||||
if (isPascalCase(sym)) {
|
||||
results.push({ packageName: originalName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isPascalCase(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findPythonFiles(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('.py')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface PythonWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredPackages: Map<string, PythonPackageMeta>;
|
||||
}
|
||||
|
||||
export async function extractPythonWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<PythonWorkspaceResult> {
|
||||
const packagesByImportName = new Map<string, PythonPackageMeta>();
|
||||
const packagesByGroupPath = new Map<string, PythonPackageMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parsePythonManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: PythonPackageMeta = {
|
||||
name: manifest.name,
|
||||
importName: manifest.importName,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.deps,
|
||||
};
|
||||
const existing = packagesByImportName.get(manifest.importName);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[python-workspace-extractor] duplicate package "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
packagesByImportName.set(manifest.importName, meta);
|
||||
packagesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, pkg] of packagesByGroupPath) {
|
||||
const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_'));
|
||||
const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d));
|
||||
if (groupPkgDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
for (const dep of groupPkgDeps) {
|
||||
const meta = packagesByImportName.get(dep);
|
||||
if (meta) knownPackages.set(dep, meta.name);
|
||||
}
|
||||
|
||||
const imports = await scanPythonImports(pkg.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerImportName = imp.packageName.replace(/-/g, '_');
|
||||
const providerPkg = packagesByImportName.get(providerImportName);
|
||||
if (!providerPkg) continue;
|
||||
|
||||
const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`;
|
||||
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerPkg.groupPath,
|
||||
to: pkg.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredPackages: packagesByGroupPath };
|
||||
}
|
||||
90
gitnexus/src/core/group/extractors/workspace-extractor.ts
Normal file
90
gitnexus/src/core/group/extractors/workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink } from '../types.js';
|
||||
import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js';
|
||||
import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
|
||||
import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
|
||||
import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
|
||||
import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
|
||||
import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
|
||||
|
||||
export interface WorkspaceDiscoveryResult {
|
||||
links: GroupManifestLink[];
|
||||
stats: WorkspaceExtractorStats[];
|
||||
}
|
||||
|
||||
interface WorkspaceExtractorStats {
|
||||
ecosystem: string;
|
||||
linkCount: number;
|
||||
projectCount: number;
|
||||
}
|
||||
|
||||
export async function discoverWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<WorkspaceDiscoveryResult> {
|
||||
const links: GroupManifestLink[] = [];
|
||||
const stats: WorkspaceExtractorStats[] = [];
|
||||
|
||||
const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (rustResult.links.length > 0) {
|
||||
links.push(...rustResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Rust',
|
||||
linkCount: rustResult.links.length,
|
||||
projectCount: rustResult.discoveredCrates.size,
|
||||
});
|
||||
}
|
||||
|
||||
const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (nodeResult.links.length > 0) {
|
||||
links.push(...nodeResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Node',
|
||||
linkCount: nodeResult.links.length,
|
||||
projectCount: nodeResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (pyResult.links.length > 0) {
|
||||
links.push(...pyResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Python',
|
||||
linkCount: pyResult.links.length,
|
||||
projectCount: pyResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (goResult.links.length > 0) {
|
||||
links.push(...goResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Go',
|
||||
linkCount: goResult.links.length,
|
||||
projectCount: goResult.discoveredModules.size,
|
||||
});
|
||||
}
|
||||
|
||||
const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (javaResult.links.length > 0) {
|
||||
links.push(...javaResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Java',
|
||||
linkCount: javaResult.links.length,
|
||||
projectCount: javaResult.discoveredProjects.size,
|
||||
});
|
||||
}
|
||||
|
||||
const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (elixirResult.links.length > 0) {
|
||||
links.push(...elixirResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Elixir',
|
||||
linkCount: elixirResult.links.length,
|
||||
projectCount: elixirResult.discoveredApps.size,
|
||||
});
|
||||
}
|
||||
|
||||
return { links, stats };
|
||||
}
|
||||
|
|
@ -8,7 +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 { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
|
|
@ -193,13 +193,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
const wsResult = await discoverWorkspaceLinks(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`,
|
||||
);
|
||||
for (const s of wsResult.stats) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
261
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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 { extractElixirWorkspaceLinks } from '../../../src/core/group/extractors/elixir-workspace-extractor.js';
|
||||
|
||||
describe('ElixirWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ex-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-app alias imports', async () => {
|
||||
await writeFile(
|
||||
'core/mix.exs',
|
||||
'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('core/lib/core/schema.ex', 'defmodule Core.Schema do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'web/lib/web/controller.ex',
|
||||
'defmodule Web.Controller do\n alias Core.Schema\nend\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'core',
|
||||
to: 'web',
|
||||
type: 'custom',
|
||||
contract: 'Core.Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles grouped alias (alias MyApp.{ModA, ModB})', async () => {
|
||||
await writeFile(
|
||||
'shared/mix.exs',
|
||||
'defmodule Shared.MixProject do\n use Mix.Project\n def project do\n [app: :shared, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('shared/lib/shared/config.ex', 'defmodule Shared.Config do\nend\n');
|
||||
await writeFile('shared/lib/shared/logger.ex', 'defmodule Shared.Logger do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'app/mix.exs',
|
||||
'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:shared, "~> 0.1"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/lib/app/main.ex',
|
||||
'defmodule App.Main do\n alias Shared.{Config, Logger}\nend\n',
|
||||
);
|
||||
|
||||
const repos = { shared: 'shared', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['shared', path.join(tmpDir, 'shared')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['Shared.Config', 'Shared.Logger']);
|
||||
});
|
||||
|
||||
it('handles direct module references (no alias)', async () => {
|
||||
await writeFile(
|
||||
'auth/mix.exs',
|
||||
'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'api/mix.exs',
|
||||
'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/lib/api/handler.ex',
|
||||
'defmodule Api.Handler do\n def verify do\n Auth.Token.verify()\n end\nend\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Auth.Token');
|
||||
});
|
||||
|
||||
it('handles underscore app names (my_app -> MyApp)', async () => {
|
||||
await writeFile(
|
||||
'data-store/mix.exs',
|
||||
'defmodule DataStore.MixProject do\n use Mix.Project\n def project do\n [app: :data_store, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('data-store/lib/data_store/repo.ex', 'defmodule DataStore.Repo do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:data_store, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile('web/lib/web/page.ex', 'defmodule Web.Page do\n alias DataStore.Repo\nend\n');
|
||||
|
||||
const repos = { store: 'data_store', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['store', path.join(tmpDir, 'data-store')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('DataStore.Repo');
|
||||
});
|
||||
|
||||
it('skips repos without mix.exs', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredApps.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical module refs from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/mix.exs',
|
||||
'defmodule Lib.MixProject do\n use Mix.Project\n def project do\n [app: :lib, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('lib/lib/lib/config.ex', 'defmodule Lib.Config do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'app/mix.exs',
|
||||
'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:lib, "~> 0.1"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile('app/lib/app/a.ex', 'defmodule App.A do\n alias Lib.Config\nend\n');
|
||||
await writeFile('app/lib/app/b.ex', 'defmodule App.B do\n alias Lib.Config\nend\n');
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses nested submodules to top-level module contract', async () => {
|
||||
await writeFile(
|
||||
'core/mix.exs',
|
||||
'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('core/lib/core/auth/token.ex', 'defmodule Core.Auth.Token do\nend\n');
|
||||
await writeFile('core/lib/core/auth/session.ex', 'defmodule Core.Auth.Session do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'web/lib/web/ctrl.ex',
|
||||
'defmodule Web.Ctrl do\n alias Core.Auth.Token\n alias Core.Auth.Session\nend\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Core.Auth');
|
||||
});
|
||||
|
||||
it('does not produce false positives from module references in comments', async () => {
|
||||
await writeFile(
|
||||
'auth/mix.exs',
|
||||
'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'api/mix.exs',
|
||||
'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/lib/api/handler.ex',
|
||||
'defmodule Api.Handler do\n # See Auth.Token for details\n # Auth.Token.verify() is deprecated\n def handle, do: :ok\nend\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles git and path deps alongside umbrella deps', async () => {
|
||||
await writeFile(
|
||||
'utils/mix.exs',
|
||||
'defmodule Utils.MixProject do\n use Mix.Project\n def project do\n [app: :utils, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('utils/lib/utils/helper.ex', 'defmodule Utils.Helper do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/mix.exs',
|
||||
'defmodule Svc.MixProject do\n use Mix.Project\n def project do\n [app: :svc, version: "0.1.0"]\n end\n defp deps do\n [{:utils, git: "https://github.com/org/utils.git"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/lib/svc/worker.ex',
|
||||
'defmodule Svc.Worker do\n alias Utils.Helper\nend\n',
|
||||
);
|
||||
|
||||
const repos = { utils: 'utils', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['utils', path.join(tmpDir, 'utils')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Utils.Helper');
|
||||
});
|
||||
});
|
||||
244
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal file
244
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
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 { extractGoWorkspaceLinks } from '../../../src/core/group/extractors/go-workspace-extractor.js';
|
||||
|
||||
describe('GoWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-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-module type usage via require', async () => {
|
||||
await writeFile('models/go.mod', 'module github.com/org/models\n\ngo 1.21\n');
|
||||
await writeFile('models/schema.go', 'package models\n\ntype Schema struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'api/go.mod',
|
||||
'module github.com/org/api\n\ngo 1.21\n\nrequire github.com/org/models v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/main.go',
|
||||
'package main\n\nimport "github.com/org/models"\n\nfunc main() {\n\tvar s models.Schema\n\t_ = s\n}\n',
|
||||
);
|
||||
|
||||
const repos = {
|
||||
'libs/models': 'models',
|
||||
'services/api': 'api',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs/models', path.join(tmpDir, 'models')],
|
||||
['services/api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'libs/models',
|
||||
to: 'services/api',
|
||||
type: 'custom',
|
||||
contract: 'github.com/org/models::Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles block require syntax', async () => {
|
||||
await writeFile('auth/go.mod', 'module github.com/org/auth\n\ngo 1.21\n');
|
||||
await writeFile('auth/token.go', 'package auth\n\ntype Token struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/go.mod',
|
||||
'module github.com/org/svc\n\ngo 1.21\n\nrequire (\n\tgithub.com/org/auth v1.0.0\n)\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/main.go',
|
||||
'package main\n\nimport (\n\t"github.com/org/auth"\n)\n\nfunc handle() auth.Token { return auth.Token{} }\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/auth::Token');
|
||||
});
|
||||
|
||||
it('handles subpackage imports (module/pkg)', async () => {
|
||||
await writeFile('core/go.mod', 'module github.com/org/core\n\ngo 1.21\n');
|
||||
await writeFile('core/types/entity.go', 'package types\n\ntype Entity struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/core v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/core/types"\n\nvar e types.Entity\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/core::Entity');
|
||||
});
|
||||
|
||||
it('handles replace directive with local paths', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/config.go', 'package lib\n\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.0.0\n\nreplace github.com/org/lib => ./lib\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/lib::Config');
|
||||
});
|
||||
|
||||
it('ignores unexported (lowercase) identifiers', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/util.go', 'package lib\n\nfunc helper() {}\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/lib::Config');
|
||||
});
|
||||
|
||||
it('does not produce links for aliased imports (V1 false-negative limitation)', async () => {
|
||||
await writeFile('shared/go.mod', 'module github.com/org/shared\n\ngo 1.21\n');
|
||||
await writeFile('shared/config.go', 'package shared\n\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/shared v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport cfg "github.com/org/shared"\n\nvar c cfg.Config\n',
|
||||
);
|
||||
|
||||
const repos = { shared: 'shared', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['shared', path.join(tmpDir, 'shared')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips repos without go.mod', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredModules.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical type usage from multiple files', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/model.go', 'package lib\n\ntype Model struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile('app/a.go', 'package main\n\nimport "github.com/org/lib"\n\nvar x lib.Model\n');
|
||||
await writeFile('app/b.go', 'package main\n\nimport "github.com/org/lib"\n\nvar y lib.Model\n');
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('discovers multiple types from the same module', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile(
|
||||
'lib/types.go',
|
||||
'package lib\n\ntype Request struct {}\ntype Response struct {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nfunc handle(r lib.Request) lib.Response { return lib.Response{} }\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['github.com/org/lib::Request', 'github.com/org/lib::Response']);
|
||||
});
|
||||
});
|
||||
240
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
240
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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 { extractJavaWorkspaceLinks } from '../../../src/core/group/extractors/java-workspace-extractor.js';
|
||||
|
||||
describe('JavaWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-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');
|
||||
}
|
||||
|
||||
const pomTemplate = (g: string, a: string, deps: string[] = []) => {
|
||||
const depXml = deps
|
||||
.map((d) => {
|
||||
const [gid, aid] = d.split(':');
|
||||
return `<dependency><groupId>${gid}</groupId><artifactId>${aid}</artifactId></dependency>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<project><groupId>${g}</groupId><artifactId>${a}</artifactId><dependencies>${depXml}</dependencies></project>`;
|
||||
};
|
||||
|
||||
it('discovers cross-project imports via Maven pom.xml', async () => {
|
||||
await writeFile('models/pom.xml', pomTemplate('com.acme', 'models'));
|
||||
await writeFile(
|
||||
'models/src/main/java/com/acme/models/User.java',
|
||||
'package com.acme.models;\npublic class User {}\n',
|
||||
);
|
||||
|
||||
await writeFile('api/pom.xml', pomTemplate('com.acme', 'api', ['com.acme:models']));
|
||||
await writeFile(
|
||||
'api/src/main/java/com/acme/api/UserService.java',
|
||||
'package com.acme.api;\nimport com.acme.models.User;\npublic class UserService {}\n',
|
||||
);
|
||||
|
||||
const repos = { models: 'models', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['models', path.join(tmpDir, 'models')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'models',
|
||||
to: 'api',
|
||||
type: 'custom',
|
||||
contract: 'models::User',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Gradle build files', async () => {
|
||||
await writeFile('core/build.gradle.kts', 'group = "com.acme"\nversion = "1.0"\n');
|
||||
await writeFile(
|
||||
'core/src/main/java/com/acme/core/Config.java',
|
||||
'package com.acme.core;\npublic class Config {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'svc/build.gradle.kts',
|
||||
'group = "com.acme"\nversion = "1.0"\ndependencies {\n implementation("com.acme:core:1.0")\n}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/src/main/java/com/acme/svc/App.java',
|
||||
'package com.acme.svc;\nimport com.acme.core.Config;\npublic class App {}\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('core::Config');
|
||||
});
|
||||
|
||||
it('handles Gradle project dependencies', async () => {
|
||||
await writeFile('common/build.gradle', "group = 'com.org'\nversion = '1.0'\n");
|
||||
await writeFile(
|
||||
'common/src/main/java/com/org/common/Entity.java',
|
||||
'package com.org.common;\npublic class Entity {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'app/build.gradle',
|
||||
"group = 'com.org'\nversion = '1.0'\ndependencies {\n implementation(project(':common'))\n}\n",
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main/java/com/org/app/Main.java',
|
||||
'package com.org.app;\nimport com.org.common.Entity;\npublic class Main {}\n',
|
||||
);
|
||||
|
||||
const repos = { common: 'common', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['common', path.join(tmpDir, 'common')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('common::Entity');
|
||||
});
|
||||
|
||||
it('handles static imports', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Constants.java',
|
||||
'package com.acme.lib;\npublic class Constants {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/Main.java',
|
||||
'package com.acme.app;\nimport static com.acme.lib.Constants;\npublic class Main {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('lib::Constants');
|
||||
});
|
||||
|
||||
it('skips repos without Java manifest', async () => {
|
||||
await writeFile('rs-app/Cargo.toml', '[package]\nname = "rapp"\n');
|
||||
|
||||
const repos = { app: 'rapp' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'rs-app')]]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Token.java',
|
||||
'package com.acme.lib;\npublic class Token {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/A.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Token;\npublic class A {}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/B.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Token;\npublic class B {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('discovers Kotlin file imports from Java projects', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/kotlin/com/acme/lib/Model.kt',
|
||||
'package com.acme.lib\ndata class Model(val id: Int)\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/kotlin/com/acme/app/Main.kt',
|
||||
'package com.acme.app\nimport com.acme.lib.Model\nfun main() {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('lib::Model');
|
||||
});
|
||||
|
||||
it('discovers multiple types from the same dependency', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Request.java',
|
||||
'package com.acme.lib;\npublic class Request {}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Response.java',
|
||||
'package com.acme.lib;\npublic class Response {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/Handler.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Request;\nimport com.acme.lib.Response;\npublic class Handler {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['lib::Request', 'lib::Response']);
|
||||
});
|
||||
});
|
||||
|
|
@ -596,7 +596,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-struct',
|
||||
|
|
@ -611,7 +611,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-enum',
|
||||
|
|
@ -640,6 +640,42 @@ describe('ManifestExtractor', () => {
|
|||
expect(result.crossLinks[0].matchType).toBe('manifest');
|
||||
});
|
||||
|
||||
it('custom contract with qualified name (provider::Symbol) strips prefix before graph query', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'mathlex::Expression',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
let capturedParams: Record<string, unknown> | undefined;
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
capturedParams = params;
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [{ uid: 'uid-expr', name: 'Expression', filePath: 'src/ast.rs' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
['engine/thales', async () => []],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
|
||||
expect(capturedParams?.symbolName).toBe('Expression');
|
||||
expect(provider?.symbolUid).toBe('uid-expr');
|
||||
});
|
||||
|
||||
it('falls back to synthetic uid when custom symbol not found in graph', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
|
|
@ -714,7 +750,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
if (params?.symbolName === 'Token') {
|
||||
return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }];
|
||||
}
|
||||
return [];
|
||||
|
|
@ -723,7 +759,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
if (params?.symbolName === 'Token') {
|
||||
return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }];
|
||||
}
|
||||
return [];
|
||||
|
|
|
|||
285
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
285
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
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 { extractNodeWorkspaceLinks } from '../../../src/core/group/extractors/node-workspace-extractor.js';
|
||||
|
||||
describe('NodeWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-node-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-package ES imports', async () => {
|
||||
await writeFile(
|
||||
'pkg-a/package.json',
|
||||
JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('pkg-a/src/index.ts', 'export class Config {}\nexport class Logger {}\n');
|
||||
|
||||
await writeFile(
|
||||
'pkg-b/package.json',
|
||||
JSON.stringify({
|
||||
name: '@myorg/api',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@myorg/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'pkg-b/src/server.ts',
|
||||
"import { Config } from '@myorg/shared';\nconst c = new Config();\n",
|
||||
);
|
||||
|
||||
const repos = {
|
||||
'libs/shared': '@myorg/shared',
|
||||
'services/api': '@myorg/api',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs/shared', path.join(tmpDir, 'pkg-a')],
|
||||
['services/api', path.join(tmpDir, 'pkg-b')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'libs/shared',
|
||||
to: 'services/api',
|
||||
type: 'custom',
|
||||
contract: '@myorg/shared::Config',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles default imports (PascalCase)', async () => {
|
||||
await writeFile(
|
||||
'ui-lib/package.json',
|
||||
JSON.stringify({ name: 'ui-components', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('ui-lib/src/index.ts', 'export default class Button {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'web-app',
|
||||
version: '1.0.0',
|
||||
dependencies: { 'ui-components': '^1.0.0' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/page.tsx',
|
||||
"import Button from 'ui-components';\nexport default function Page() { return <Button />; }\n",
|
||||
);
|
||||
|
||||
const repos = { 'libs/ui': 'ui-components', 'apps/web': 'web-app' };
|
||||
const repoPaths = new Map([
|
||||
['libs/ui', path.join(tmpDir, 'ui-lib')],
|
||||
['apps/web', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('ui-components::Button');
|
||||
});
|
||||
|
||||
it('handles CommonJS destructured require', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'auth-lib', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.js', 'module.exports = { Authenticator: class {} };\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/package.json',
|
||||
JSON.stringify({
|
||||
name: 'api-svc',
|
||||
version: '1.0.0',
|
||||
dependencies: { 'auth-lib': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('svc/src/handler.js', "const { Authenticator } = require('auth-lib');\n");
|
||||
|
||||
const repos = { lib: 'auth-lib', svc: 'api-svc' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('auth-lib::Authenticator');
|
||||
});
|
||||
|
||||
it('handles scoped package imports with subpaths', async () => {
|
||||
await writeFile('core/package.json', JSON.stringify({ name: '@acme/core', version: '2.0.0' }));
|
||||
await writeFile('core/src/models.ts', 'export class User {}\n');
|
||||
|
||||
await writeFile(
|
||||
'web/package.json',
|
||||
JSON.stringify({
|
||||
name: '@acme/web',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@acme/core': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('web/src/routes.ts', "import { User } from '@acme/core/models';\n");
|
||||
|
||||
const repos = { core: '@acme/core', web: '@acme/web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('@acme/core::User');
|
||||
});
|
||||
|
||||
it('ignores camelCase/snake_case imports (non-type exports)', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'utils', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export function helper() {}\nexport class Formatter {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { utils: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/main.ts', "import { helper, Formatter } from 'utils';\n");
|
||||
|
||||
const repos = { lib: 'utils', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('utils::Formatter');
|
||||
});
|
||||
|
||||
it('skips repos without package.json', async () => {
|
||||
await writeFile('rust-app/Cargo.toml', '[package]\nname = "rapp"\nversion = "0.1.0"\n');
|
||||
await writeFile('rust-app/src/main.rs', 'fn main() {}\n');
|
||||
|
||||
const repos = { app: 'rapp' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'rust-app')]]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredPackages.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'shared', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export class Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { shared: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/a.ts', "import { Config } from 'shared';\n");
|
||||
await writeFile('app/src/b.ts', "import { Config } from 'shared';\n");
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles aliased imports (import { Foo as Bar })', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'models', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export class Entity {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { models: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/main.ts', "import { Entity as BaseEntity } from 'models';\n");
|
||||
|
||||
const repos = { lib: 'models', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('models::Entity');
|
||||
});
|
||||
|
||||
it('handles multiple packages importing from the same provider', async () => {
|
||||
await writeFile(
|
||||
'shared/package.json',
|
||||
JSON.stringify({ name: '@org/shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('shared/src/index.ts', 'export class Schema {}\n');
|
||||
|
||||
await writeFile(
|
||||
'api/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/api',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('api/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
await writeFile(
|
||||
'worker/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/worker',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('worker/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
const repos = {
|
||||
libs: '@org/shared',
|
||||
api: '@org/api',
|
||||
worker: '@org/worker',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs', path.join(tmpDir, 'shared')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
['worker', path.join(tmpDir, 'worker')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const targets = result.links.map((l) => l.to).sort();
|
||||
expect(targets).toEqual(['api', 'worker']);
|
||||
expect(result.links.every((l) => l.contract === '@org/shared::Schema')).toBe(true);
|
||||
});
|
||||
});
|
||||
241
gitnexus/test/unit/group/python-workspace-extractor.test.ts
Normal file
241
gitnexus/test/unit/group/python-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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 { extractPythonWorkspaceLinks } from '../../../src/core/group/extractors/python-workspace-extractor.js';
|
||||
|
||||
describe('PythonWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-py-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-package imports via pyproject.toml', async () => {
|
||||
await writeFile(
|
||||
'models/pyproject.toml',
|
||||
'[project]\nname = "shared-models"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('models/shared_models/__init__.py', 'class Schema: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'api/pyproject.toml',
|
||||
'[project]\nname = "api-server"\nversion = "0.1.0"\ndependencies = [\n "shared-models>=0.1.0",\n]\n',
|
||||
);
|
||||
await writeFile('api/api_server/main.py', 'from shared_models import Schema\n');
|
||||
|
||||
const repos = { models: 'shared-models', api: 'api-server' };
|
||||
const repoPaths = new Map([
|
||||
['models', path.join(tmpDir, 'models')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'models',
|
||||
to: 'api',
|
||||
type: 'custom',
|
||||
contract: 'shared-models::Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers imports via setup.py', async () => {
|
||||
await writeFile(
|
||||
'core/setup.py',
|
||||
"from setuptools import setup\nsetup(name='mycore', version='1.0', install_requires=[])\n",
|
||||
);
|
||||
await writeFile('core/mycore/__init__.py', 'class Engine: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/setup.py',
|
||||
"from setuptools import setup\nsetup(name='myapp', version='1.0', install_requires=['mycore>=1.0'])\n",
|
||||
);
|
||||
await writeFile('app/myapp/run.py', 'from mycore import Engine\n');
|
||||
|
||||
const repos = { core: 'mycore', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('mycore::Engine');
|
||||
});
|
||||
|
||||
it('handles hyphenated package names (normalized to underscore in imports)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "my-utils"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/my_utils/__init__.py', 'class Helper: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/pyproject.toml',
|
||||
'[project]\nname = "my-service"\nversion = "0.1.0"\ndependencies = [\n "my-utils",\n]\n',
|
||||
);
|
||||
await writeFile('svc/my_service/main.py', 'from my_utils import Helper\n');
|
||||
|
||||
const repos = { lib: 'my-utils', svc: 'my-service' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('my-utils::Helper');
|
||||
});
|
||||
|
||||
it('handles submodule imports (from pkg.sub import Class)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "datalib"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/datalib/models.py', 'class Record: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "datalib",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from datalib.models import Record\n');
|
||||
|
||||
const repos = { lib: 'datalib', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('datalib::Record');
|
||||
});
|
||||
|
||||
it('ignores snake_case imports (functions, not types)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "utils"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/utils/__init__.py', 'def helper(): pass\nclass Config: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "utils",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from utils import helper, 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 extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('utils::Config');
|
||||
});
|
||||
|
||||
it('skips repos without Python manifest', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredPackages.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "shared"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/shared/__init__.py', 'class Config: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "shared",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/a.py', 'from shared import Config\n');
|
||||
await writeFile('app/myapp/b.py', 'from shared import 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 extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles aliased imports (from pkg import Foo as Bar)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "models"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/models/__init__.py', 'class Entity: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "models",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from models import Entity as BaseEntity\n');
|
||||
|
||||
const repos = { lib: 'models', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('models::Entity');
|
||||
});
|
||||
|
||||
it('reads optional-dependencies from pyproject.toml', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "extras"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/extras/__init__.py', 'class Plugin: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = []\n\n[project.optional-dependencies]\ndev = [\n "extras>=0.1",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from extras import Plugin\n');
|
||||
|
||||
const repos = { lib: 'extras', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('extras::Plugin');
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,7 @@ describe('syncGroup', () => {
|
|||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: false,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
|
@ -312,8 +313,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
it('writes registry to groupDir when skipWrite is false', async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-write-'));
|
||||
|
||||
try {
|
||||
const config = makeConfig({});
|
||||
|
|
@ -371,8 +371,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
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 });
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -420,9 +419,8 @@ describe('syncGroup', () => {
|
|||
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 });
|
||||
it('workspace_deps: false skips workspace extraction entirely', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-off-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -454,8 +452,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
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 });
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-merge-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -523,12 +520,59 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manifestLinks).toHaveLength(2);
|
||||
|
||||
const contractIds = manifestLinks.map((cl) => cl.contractId);
|
||||
expect(contractIds).toContain('http::GET::/api/parse');
|
||||
expect(contractIds).toContain('custom::mathlex::Expression');
|
||||
});
|
||||
|
||||
it('discovers Node workspace links through syncGroup orchestrator', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-node-'));
|
||||
|
||||
writeFileSync('shared/package.json', '{"name": "@myorg/shared", "version": "1.0.0"}');
|
||||
writeFileSync('shared/src/index.ts', 'export class Config {}\n');
|
||||
|
||||
writeFileSync(
|
||||
'app/package.json',
|
||||
'{"name": "@myorg/app", "version": "1.0.0", "dependencies": {"@myorg/shared": "workspace:*"}}',
|
||||
);
|
||||
writeFileSync('app/src/index.ts', "import { Config } from '@myorg/shared';\n");
|
||||
|
||||
const mockEntries: RegistryEntry[] = [
|
||||
{
|
||||
name: 'shared',
|
||||
path: path.join(tmpDir, 'shared'),
|
||||
storagePath: path.join(tmpDir, 'shared', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
path: path.join(tmpDir, 'app'),
|
||||
storagePath: path.join(tmpDir, 'app', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
];
|
||||
|
||||
const repoManager = await import('../../../src/storage/repo-manager.js');
|
||||
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
||||
|
||||
const config = makeWsConfig({ 'pkg/shared': 'shared', 'pkg/app': 'app' }, true);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks).toHaveLength(1);
|
||||
const nodeLink = manifestLinks.find(
|
||||
(cl) => cl.contractId === 'custom::@myorg/shared::Config',
|
||||
);
|
||||
expect(nodeLink).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue