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.
This commit is contained in:
Christian C. Berclaz 2026-05-01 19:47:46 +02:00
parent 825ece09f0
commit 187c343d6d
No known key found for this signature in database
3 changed files with 527 additions and 0 deletions

View file

@ -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<string, string>,
): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const sourceFiles = await findGoFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
const importPaths = extractImportPaths(content);
for (const importPath of importPaths) {
const matchedModule = findMatchingModule(importPath, knownModules);
if (!matchedModule) continue;
const symbols = extractUsedTypes(content, importPath);
for (const sym of symbols) {
results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile });
}
}
}
return results;
}
function extractImportPaths(content: string): string[] {
const paths: string[] = [];
// Single: import "path"
const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm);
for (const m of singleImports) paths.push(m[1]);
// Single aliased: import alias "path"
const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm);
for (const m of aliasedImports) paths.push(m[1]);
// Block: import ( ... )
const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm);
for (const block of blockImports) {
const lines = block[1].split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
const pathMatch = trimmed.match(/"([^"]+)"/);
if (pathMatch) paths.push(pathMatch[1]);
}
}
return [...new Set(paths)];
}
function findMatchingModule(
importPath: string,
knownModules: Map<string, string>,
): string | null {
for (const [modPath] of knownModules) {
if (importPath === modPath || importPath.startsWith(modPath + '/')) {
return modPath;
}
}
return null;
}
function extractUsedTypes(content: string, importPath: string): string[] {
const pkgName = importPath.split('/').pop() || '';
if (!pkgName) return [];
// Match pkg.TypeName where TypeName is PascalCase (exported)
const typeRegex = new RegExp(
`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`,
'g',
);
const types = new Set<string>();
let match;
while ((match = typeRegex.exec(content)) !== null) {
types.add(match[1]);
}
return [...types];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function findGoFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const IGNORE = new Set([
'vendor',
'node_modules',
'.git',
'.gitnexus',
'testdata',
]);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
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<string, GoModuleMeta>;
}
export async function extractGoWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<GoWorkspaceResult> {
const modulesByPath = new Map<string, GoModuleMeta>();
const modulesByGroupPath = new Map<string, GoModuleMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseGoMod(repoPath);
if (!manifest) continue;
const meta: GoModuleMeta = {
modulePath: manifest.modulePath,
groupPath,
repoPath,
requires: manifest.requires,
};
modulesByPath.set(manifest.modulePath, meta);
modulesByGroupPath.set(groupPath, meta);
}
const links: GroupManifestLink[] = [];
const seen = new Set<string>();
for (const [, mod] of modulesByGroupPath) {
const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r));
if (groupModDeps.length === 0) continue;
const knownModules = new Map<string, string>();
for (const dep of groupModDeps) {
knownModules.set(dep, dep);
}
const imports = await scanGoImports(mod.repoPath, knownModules);
for (const imp of imports) {
const providerMod = modulesByPath.get(imp.modulePath);
if (!providerMod) continue;
const 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 };
}

View file

@ -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).

View file

@ -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']);
});
});