diff --git a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts new file mode 100644 index 000000000..bbe3f3aa7 --- /dev/null +++ b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts @@ -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, +): Promise { + 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 | 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 { + const results: string[] = []; + const IGNORE = new Set([ + '_build', + 'deps', + 'node_modules', + '.git', + '.gitnexus', + '.elixir_ls', + 'cover', + ]); + + async function walk(dir: string, rel: string): Promise { + 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; +} + +export async function extractElixirWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const appsByName = new Map(); + const appsByGroupPath = new Map(); + + 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(); + + for (const [, app] of appsByGroupPath) { + const groupDeps = app.deps.filter((d) => appsByName.has(d)); + if (groupDeps.length === 0) continue; + + const knownApps = new Map(); + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/workspace-extractor.ts b/gitnexus/src/core/group/extractors/workspace-extractor.ts index 7e452e3f1..652f06e46 100644 --- a/gitnexus/src/core/group/extractors/workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/workspace-extractor.ts @@ -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 }; } diff --git a/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts new file mode 100644 index 000000000..d4c8f8bf6 --- /dev/null +++ b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts @@ -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'); + }); +});