From 7cce07b419bce83eef1fabc24f72db808e9bd35d Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 4 May 2026 10:43:21 +0200 Subject: [PATCH] feat(group): workspace extractors for Node, Python, Go, Java, Elixir (#1260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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. --- gitnexus/src/core/group/config-parser.ts | 2 +- .../extractors/elixir-workspace-extractor.ts | 253 ++++++++++++++++ .../extractors/go-workspace-extractor.ts | 258 ++++++++++++++++ .../extractors/java-workspace-extractor.ts | 261 ++++++++++++++++ .../group/extractors/manifest-extractor.ts | 14 +- .../extractors/node-workspace-extractor.ts | 248 +++++++++++++++ .../extractors/python-workspace-extractor.ts | 254 ++++++++++++++++ .../group/extractors/workspace-extractor.ts | 90 ++++++ gitnexus/src/core/group/sync.ts | 12 +- .../group/elixir-workspace-extractor.test.ts | 261 ++++++++++++++++ .../unit/group/go-workspace-extractor.test.ts | 244 +++++++++++++++ .../group/java-workspace-extractor.test.ts | 240 +++++++++++++++ .../unit/group/manifest-extractor.test.ts | 44 ++- .../group/node-workspace-extractor.test.ts | 285 ++++++++++++++++++ .../group/python-workspace-extractor.test.ts | 241 +++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 64 +++- 16 files changed, 2745 insertions(+), 26 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/go-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/java-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/node-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/python-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/elixir-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/go-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/java-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/node-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/python-workspace-extractor.test.ts diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index d55969a73..4703e6329 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -13,7 +13,7 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, - workspace_deps: true, + workspace_deps: false, }; const DEFAULT_MATCHING = { diff --git a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts new file mode 100644 index 000000000..33afcaed9 --- /dev/null +++ b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts @@ -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, +): Promise { + 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 | 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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.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; +} + +export async function extractElixirWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const appsByName = new Map(); + const appsByGroupPath = new Map(); + + 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(); + + for (const [, app] of appsByGroupPath) { + const groupDeps = app.deps.filter((d) => appsByName.has(d)); + if (groupDeps.length === 0) continue; + + const knownApps = new Map(); + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/go-workspace-extractor.ts b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts new file mode 100644 index 000000000..fbdf0e450 --- /dev/null +++ b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts @@ -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, +): Promise { + 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 | 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(); + 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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.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; +} + +export async function extractGoWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const modulesByPath = new Map(); + const modulesByGroupPath = new Map(); + + 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(); + + for (const [, mod] of modulesByGroupPath) { + const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r)); + if (groupModDeps.length === 0) continue; + + const knownModules = new Map(); + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts new file mode 100644 index 000000000..66ee35ee5 --- /dev/null +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -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(/]*>[\s\S]*?([^<]+)<\/groupId>/); + const projectArtifactMatch = content.match( + /]*>[\s\S]*?([^<]+)<\/artifactId>/, + ); + if (!projectGroupMatch || !projectArtifactMatch) return null; + + const groupId = projectGroupMatch[1].trim(); + const artifactId = projectArtifactMatch[1].trim(); + + const deps: string[] = []; + const depBlocks = content.matchAll(/\s*([\s\S]*?)<\/dependency>/g); + for (const block of depBlocks) { + const gMatch = block[1].match(/([^<]+)<\/groupId>/); + const aMatch = block[1].match(/([^<]+)<\/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, +): Promise { + 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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.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; +} + +export async function extractJavaWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const projectsByKey = new Map(); + const projectsByGroupPath = new Map(); + + 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(); + + for (const [, proj] of projectsByGroupPath) { + const groupDeps = proj.deps.filter((d) => projectsByKey.has(d)); + if (groupDeps.length === 0) continue; + + const knownPackages = new Map(); + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index b65b7712d..3185f05e1 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts new file mode 100644 index 000000000..05a7c95dd --- /dev/null +++ b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts @@ -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; + 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)); + } + } + + return { name, workspaceDeps: [...new Set(deps)] }; +} + +async function scanImports( + repoPath: string, + knownPackages: Set, +): Promise { + 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 '' + // Also: import { Foo as Baz } from '' + 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 '' + 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('') + 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 { + 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 { + 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; +} + +export async function extractNodeWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const packagesByName = new Map(); + const packagesByGroupPath = new Map(); + + 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(); + + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts new file mode 100644 index 000000000..5930808af --- /dev/null +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -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, +): Promise { + 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 import Foo, Bar + // from .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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.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; +} + +export async function extractPythonWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const packagesByImportName = new Map(); + const packagesByGroupPath = new Map(); + + 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(); + + 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(); + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/workspace-extractor.ts b/gitnexus/src/core/group/extractors/workspace-extractor.ts new file mode 100644 index 000000000..652f06e46 --- /dev/null +++ b/gitnexus/src/core/group/extractors/workspace-extractor.ts @@ -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, + repoPaths: Map, + dbExecutors?: Map, +): Promise { + 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 }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index a9ecb51f4..09b289033 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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`, + ); + } } } } diff --git a/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts new file mode 100644 index 000000000..f7af3861c --- /dev/null +++ b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/unit/group/go-workspace-extractor.test.ts b/gitnexus/test/unit/group/go-workspace-extractor.test.ts new file mode 100644 index 000000000..a09e4314e --- /dev/null +++ b/gitnexus/test/unit/group/go-workspace-extractor.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/unit/group/java-workspace-extractor.test.ts b/gitnexus/test/unit/group/java-workspace-extractor.test.ts new file mode 100644 index 000000000..09233ab12 --- /dev/null +++ b/gitnexus/test/unit/group/java-workspace-extractor.test.ts @@ -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 `${gid}${aid}`; + }) + .join('\n'); + return `${g}${a}${depXml}`; + }; + + 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']); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 997445f2e..bb4e203fa 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -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 | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + '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 []; diff --git a/gitnexus/test/unit/group/node-workspace-extractor.test.ts b/gitnexus/test/unit/group/node-workspace-extractor.test.ts new file mode 100644 index 000000000..163b0be37 --- /dev/null +++ b/gitnexus/test/unit/group/node-workspace-extractor.test.ts @@ -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