mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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.
This commit is contained in:
parent
bef791a730
commit
1040bc8902
3 changed files with 500 additions and 0 deletions
246
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
246
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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 ElixirAppMeta {
|
||||
appName: string;
|
||||
modulePrefix: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedModule {
|
||||
appName: string;
|
||||
moduleName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseMixExs(
|
||||
repoPath: string,
|
||||
): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> {
|
||||
const mixPath = path.join(repoPath, 'mix.exs');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(mixPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// app: :my_app
|
||||
const appMatch = content.match(/app:\s*:(\w+)/);
|
||||
if (!appMatch) return null;
|
||||
const appName = appMatch[1];
|
||||
|
||||
// Derive module prefix: my_app -> MyApp
|
||||
const modulePrefix = appName
|
||||
.split('_')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
const deps: string[] = [];
|
||||
|
||||
// {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true}
|
||||
// {:dep_name, git: "..."} or {:dep_name, path: "..."}
|
||||
const depMatches = content.matchAll(
|
||||
/\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
deps.push(m[1]);
|
||||
}
|
||||
|
||||
return { appName, modulePrefix, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanElixirImports(
|
||||
repoPath: string,
|
||||
knownApps: Map<string, string>,
|
||||
): Promise<ImportedModule[]> {
|
||||
const results: ImportedModule[] = [];
|
||||
const sourceFiles = await findElixirFiles(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;
|
||||
}
|
||||
|
||||
// alias MyApp.SomeModule
|
||||
// alias MyApp.SomeModule, as: Short
|
||||
// alias MyApp.{ModA, ModB}
|
||||
const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm;
|
||||
let match;
|
||||
while ((match = aliasRegex.exec(content)) !== null) {
|
||||
const aliasExpr = match[1];
|
||||
const modules = expandAlias(aliasExpr);
|
||||
for (const mod of modules) {
|
||||
const appName = matchModuleToApp(mod, knownApps);
|
||||
if (appName) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct module reference: MyApp.Module.func() or MyApp.Module
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
const refRegex = new RegExp(
|
||||
`\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`,
|
||||
'g',
|
||||
);
|
||||
while ((match = refRegex.exec(content)) !== null) {
|
||||
const mod = match[1];
|
||||
if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function expandAlias(expr: string): string[] {
|
||||
const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/);
|
||||
if (braceMatch) {
|
||||
const prefix = braceMatch[1];
|
||||
return braceMatch[2]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => `${prefix}.${s}`);
|
||||
}
|
||||
return [expr];
|
||||
}
|
||||
|
||||
function matchModuleToApp(
|
||||
moduleName: string,
|
||||
knownApps: Map<string, string>,
|
||||
): string | null {
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
if (moduleName === prefix || moduleName.startsWith(prefix + '.')) {
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractTopModule(moduleName: string, prefix: string): string {
|
||||
const rest = moduleName.slice(prefix.length);
|
||||
if (!rest || rest === '.') return moduleName;
|
||||
const afterDot = rest.startsWith('.') ? rest.slice(1) : rest;
|
||||
const parts = afterDot.split('.');
|
||||
return `${prefix}.${parts[0]}`;
|
||||
}
|
||||
|
||||
async function findElixirFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const IGNORE = new Set([
|
||||
'_build',
|
||||
'deps',
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'.elixir_ls',
|
||||
'cover',
|
||||
]);
|
||||
|
||||
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('.ex') || entry.name.endsWith('.exs')) {
|
||||
if (entry.name !== 'mix.exs' && entry.name !== 'mix.lock') {
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface ElixirWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredApps: Map<string, ElixirAppMeta>;
|
||||
}
|
||||
|
||||
export async function extractElixirWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<ElixirWorkspaceResult> {
|
||||
const appsByName = new Map<string, ElixirAppMeta>();
|
||||
const appsByGroupPath = new Map<string, ElixirAppMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseMixExs(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: ElixirAppMeta = {
|
||||
appName: manifest.appName,
|
||||
modulePrefix: manifest.modulePrefix,
|
||||
groupPath,
|
||||
repoPath,
|
||||
deps: manifest.deps,
|
||||
};
|
||||
appsByName.set(manifest.appName, meta);
|
||||
appsByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, app] of appsByGroupPath) {
|
||||
const groupDeps = app.deps.filter((d) => appsByName.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownApps = new Map<string, string>();
|
||||
for (const dep of groupDeps) {
|
||||
const depMeta = appsByName.get(dep);
|
||||
if (depMeta) knownApps.set(depMeta.modulePrefix, dep);
|
||||
}
|
||||
|
||||
const imports = await scanElixirImports(app.repoPath, knownApps);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerApp = appsByName.get(imp.appName);
|
||||
if (!providerApp) continue;
|
||||
|
||||
const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix);
|
||||
const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerApp.groupPath,
|
||||
to: app.groupPath,
|
||||
type: 'custom',
|
||||
contract: topModule,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredApps: appsByGroupPath };
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
|
|||
import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
|
||||
import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
|
||||
import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
|
||||
import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
|
||||
|
||||
export interface WorkspaceDiscoveryResult {
|
||||
links: GroupManifestLink[];
|
||||
|
|
@ -75,5 +76,15 @@ export async function discoverWorkspaceLinks(
|
|||
});
|
||||
}
|
||||
|
||||
const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (elixirResult.links.length > 0) {
|
||||
links.push(...elixirResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Elixir',
|
||||
linkCount: elixirResult.links.length,
|
||||
projectCount: elixirResult.discoveredApps.size,
|
||||
});
|
||||
}
|
||||
|
||||
return { links, stats };
|
||||
}
|
||||
|
|
|
|||
243
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
243
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
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('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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue