GitNexus/gitnexus/test/unit/group/go-workspace-extractor.test.ts
Christian C. Berclaz 7cce07b419
feat(group): workspace extractors for Node, Python, Go, Java, Elixir (#1260)
* feat(group): auto-discover Node/TS workspace cross-package contracts

Scan package.json dependencies and ES/CJS imports to find PascalCase
type exports crossing workspace package boundaries. Same pipeline as
Rust workspace extractor — emits GroupManifestLink[] with type:custom.

Supports: ES named imports, default imports, CommonJS destructured
require, scoped packages (@org/pkg), subpath imports, aliased imports.
Filters to PascalCase names only (types/classes, not functions).

* feat(group): auto-discover Python workspace cross-package contracts

Scan pyproject.toml/setup.py dependencies and `from <pkg> import`
statements to find PascalCase type exports crossing workspace package
boundaries. Handles hyphenated names (PEP 503 normalization),
submodule imports, aliased imports, and optional-dependencies.

* feat(group): auto-discover Go workspace cross-module contracts

Scan go.mod require/replace directives and Go source files for
exported PascalCase type usage (pkg.TypeName) crossing module
boundaries within a group. Handles block syntax, subpackage
imports, and local replace directives.

* refactor(group): extract workspace discovery orchestrator from sync

Move per-ecosystem workspace extractor calls into a single
discoverWorkspaceLinks() orchestrator. Reduces sync.ts from 295
to 264 lines and gives a clean extension point for adding
more ecosystem extractors.

* feat(group): auto-discover Java/Kotlin workspace cross-project contracts

Scan Maven pom.xml and Gradle build files for inter-project deps,
then match Java/Kotlin import statements against known group-internal
base packages. Supports Maven dependency blocks, Gradle coordinate
and project() dependencies, static imports, and Kotlin files.

* feat(group): auto-discover Elixir workspace cross-app contracts

Scan mix.exs deps and Elixir source files for alias directives and
direct module references crossing OTP app boundaries. Handles
umbrella deps (in_umbrella), git/path deps, grouped aliases
(alias MyApp.{ModA, ModB}), underscore-to-PascalCase app name
mapping, and collapses nested submodules to top-level contracts.

* fix(group): apply PR review fixes to all workspace extractors

Address review findings from PR #1256 across Node, Python, Go, Java,
and Elixir extractors:
- Replace hardcoded IGNORE sets with shared IgnoreService
  (shouldIgnorePath + loadIgnoreRules) to honor .gitnexusignore
- Qualify contract names with provider identifier to prevent
  contractId collisions across providers
- Warn and skip duplicate project/module/app names
- Update all test assertions for qualified contract format

* fix(workspace): address review findings and fix CI

- Fix prettier formatting on Rust workspace extractor files
- Fix double readRegistry() call in syncGroup (hoist to function scope)
- Fix console.warn spy leak in duplicate crate test (try/finally)
- Add sync-level integration tests: workspace_deps true/false gating,
  Rust and Node link discovery through syncGroup orchestrator (3 tests)

* style(workspace): fix Prettier formatting on all workspace extractors

* fix(workspace): strip qualified prefix in custom contract resolution, default workspace_deps to false

resolveSymbol for custom contracts now strips the "provider::" prefix
before querying graph nodes, so workspace-generated contracts like
"mathlex::Expression" correctly resolve to the "Expression" symbol.

Change workspace_deps default from true to false for safe rollout —
existing groups won't silently gain 6-ecosystem scans on upgrade.

* fix(workspace): address medium review findings from PR #1260

- Elixir: strip comment lines before direct module reference scan to
  prevent false positives from commented-out module references
- Go: use full module path for contract naming to avoid basename
  collisions between repos with identical last path segments
- Sync tests: replace toBeGreaterThanOrEqual with exact toHaveLength
  assertions per DoD §2.7
- Add workspace_deps: false to makeConfig helper for type correctness
- Add Elixir test proving comment-only references do not emit links

* fix(workspace): address second-round medium review findings

- Go: add test asserting aliased imports produce 0 links, guarding the
  V1 false-negative boundary at the assertion level
- Elixir: add code comment documenting that contracts use full module
  names without appName:: prefix and that resolveSymbol resolution
  depends on Elixir indexer storing fully-qualified names

* fix(workspace): eliminate regex backtracking in pyproject.toml parser

CodeQL flagged exponential backtracking in the [project] name regex.
Replace [^\[]*?\n (ambiguous lazy quantifier) with [^\n\[]*\n (atomic
per-line match that still stops at section boundaries).

* fix(test): use mkdtempSync for secure temp dir creation

CodeQL flagged insecure temporary file creation (High) in sync.test.ts.
Replace path.join(os.tmpdir(), predictable-name) + mkdirSync with
fs.mkdtempSync which creates temp dirs atomically with random suffix,
preventing symlink race conditions.
2026-05-04 09:43:21 +01:00

244 lines
8.2 KiB
TypeScript

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