From 187c343d6d6d42569b0c2b294d6e1b7d3b01ad1d Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Fri, 1 May 2026 19:47:46 +0200 Subject: [PATCH] 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. --- .../extractors/go-workspace-extractor.ts | 260 ++++++++++++++++++ gitnexus/src/core/group/sync.ts | 11 + .../unit/group/go-workspace-extractor.test.ts | 256 +++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 gitnexus/src/core/group/extractors/go-workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/go-workspace-extractor.test.ts 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..8a0637023 --- /dev/null +++ b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts @@ -0,0 +1,260 @@ +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'; + +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 IGNORE = new Set([ + 'vendor', + 'node_modules', + '.git', + '.gitnexus', + 'testdata', + ]); + + 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) { + if (IGNORE.has(entry.name)) continue; + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) { + 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, + }; + 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 key = `${mod.groupPath}→${providerMod.groupPath}::${imp.symbolName}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerMod.groupPath, + to: mod.groupPath, + type: 'custom', + contract: imp.symbolName, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredModules: modulesByGroupPath }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 1068c03b0..c852065fa 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -11,6 +11,7 @@ import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js'; import { extractNodeWorkspaceLinks } from './extractors/node-workspace-extractor.js'; import { extractPythonWorkspaceLinks } from './extractors/python-workspace-extractor.js'; +import { extractGoWorkspaceLinks } from './extractors/go-workspace-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -224,6 +225,16 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis ); } } + + const goResult = await extractGoWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (goResult.links.length > 0) { + allLinks = [...allLinks, ...goResult.links]; + if (opts?.verbose) { + console.log( + ` workspace-deps: discovered ${goResult.links.length} cross-module links from ${goResult.discoveredModules.size} Go modules`, + ); + } + } } // Process manifest links declared in group.yaml (plus any auto-discovered). 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..202dda4ee --- /dev/null +++ b/gitnexus/test/unit/group/go-workspace-extractor.test.ts @@ -0,0 +1,256 @@ +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: '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('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('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('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('Config'); + }); + + 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(['Request', 'Response']); + }); +});