mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
825ece09f0
commit
187c343d6d
3 changed files with 527 additions and 0 deletions
260
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal file
260
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
256
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal file
256
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue