mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* 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.
261 lines
9.7 KiB
TypeScript
261 lines
9.7 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 { 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');
|
|
});
|
|
});
|