From c75cf4387d614215834b1eddc20d21f2e5fa5f04 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:45:12 +0300 Subject: [PATCH 1/9] feat: Add Elixir language support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Elixir grammar treats ALL constructs as generic `call` nodes — no distinct node types for def/module/import - Uses `visitNode` hook with keyword-set dispatch (DEF_CALLS, MODULE_CALLS, IMPORT_CALLS, SKIP_CALLS) - Supports: def/defp, defguard/defguardp, defdelegate, defmacro/defmacrop, defmodule, defprotocol/defimpl, alias/import/require/use - Multi-clause def merging (same pattern as `erlang.ts`) - Pipe `|>` excluded from call edges (data flow) - `extractFunctionInfo` handles: simple def, guarded def (when), no-parens def, defdelegate - `extractBareCall` for function body call edges - Uses `findChildByType(node, type)` helper — Elixir grammar uses type-identified named children, not field-named children - Also wire `.ex` and `.exs` extensions to `elixir` language, register in WASM grammars and display names --- src/extraction/grammars.ts | 11 +- src/extraction/languages/elixir.ts | 389 +++++++++++++++++++++++++++++ src/extraction/languages/index.ts | 4 + src/types.ts | 2 + 4 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/extraction/languages/elixir.ts diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index aef4cac22..0d4522ae7 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -45,6 +45,8 @@ const WASM_GRAMMAR_FILES: Record = { cobol: 'tree-sitter-cobol.wasm', vbnet: 'tree-sitter-vbnet.wasm', erlang: 'tree-sitter-erlang.wasm', + elixir: 'tree-sitter-elixir.wasm', + heex: 'tree-sitter-heex.wasm', solidity: 'tree-sitter-solidity.wasm', terraform: 'tree-sitter-terraform.wasm', }; @@ -154,6 +156,11 @@ export const EXTENSION_MAP: Record = { // (`.app`/`.app.src` resource files route via isErlangAppFile below: their // last-dot extension is too generic for this map.) '.escript': 'erlang', + // Elixir: `.ex` source and `.exs` script files + '.ex': 'elixir', + '.exs': 'elixir', + // HEEx: `.heex` template files (HTML + embedded Elixir) + '.heex': 'heex', // Spring config: `application.properties` / `application-*.properties`. Same // shape as the `.yml` variants — the YAML/properties extractor emits one node // per leaf key, and the Spring resolver links `@Value("${k}")` references. @@ -292,7 +299,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise` pipe and `@` attribute detection). + */ +function hasOperatorText(node: SyntaxNode, source: string, text: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed && getNodeText(child, source) === text) return true; + } + return false; +} + +/** + * Extract function name and params from a def/defp/defguard/etc. call node. + * + * Structure: + * `def foo(x)` → call(target:"def") → arguments[0] = call(target:"foo") with its own arguments + * `def foo(x) when is_integer(x)` → arguments[0] = binary_operator(when) + */ +interface FunctionInfo { + name: string; + signature?: string; +} +function extractFunctionInfo(node: SyntaxNode, source: string): FunctionInfo | null { + // Elixir grammar: `arguments` is a named child, NOT a field on `call` nodes + const args = findChildByType(node, 'arguments'); + if (!args) return null; + + const firstArg = args.namedChild(0); + if (!firstArg) return null; + + // Guarded def: `def foo(x) when cond` → firstArg is binary_operator (when) + if (firstArg.type === 'binary_operator' && hasOperatorText(firstArg, source, 'when')) { + const left = getChildByField(firstArg, 'left') || firstArg.namedChild(0); + if (left && left.type === 'call') { + const innerTarget = getChildByField(left, 'target') || left.namedChild(0); + if (innerTarget && innerTarget.type === 'identifier') { + const name = getNodeText(innerTarget, source); + // Inner call: `arguments` is also a named child, not a field + const paramsNode = findChildByType(left, 'arguments'); + const sig = paramsNode ? getNodeText(paramsNode, source).slice(0, 200) : undefined; + return name ? { name, signature: sig } : null; + } + } + return null; + } + + // No-parens: `def foo do ... end` → firstArg is an identifier (no params) + if (firstArg.type === 'identifier') { + const name = getNodeText(firstArg, source); + return name ? { name } : null; + } + + // Simple: `def foo(x)` → firstArg is a call with target "foo" + if (firstArg.type === 'call') { + const innerTarget = getChildByField(firstArg, 'target') || firstArg.namedChild(0); + if (!innerTarget || innerTarget.type !== 'identifier') return null; + const name = getNodeText(innerTarget, source); + // Inner call: `arguments` is also a named child, not a field + const paramsNode = findChildByType(firstArg, 'arguments'); + const sig = paramsNode ? getNodeText(paramsNode, source).slice(0, 200) : undefined; + return name ? { name, signature: sig } : null; + } + + // `defdelegate foo(args), to: Mod` → firstArg is a call with target "foo" + if (firstArg.type === 'call') { + const innerTarget = getChildByField(firstArg, 'target') || firstArg.namedChild(0); + if (innerTarget && innerTarget.type === 'identifier') { + const name = getNodeText(innerTarget, source); + return name ? { name } : null; + } + } + + return null; +} + +// =========================================================================== +// Handlers +// =========================================================================== + +function handleDefinition(node: SyntaxNode, targetText: string, ctx: ExtractorContext): boolean { + // --- defmodule --- + if (targetText === 'defmodule') { + // Elixir grammar: `arguments` is a named child, NOT a field on `call` + const args = findChildByType(node, 'arguments'); + const moduleNameNode = args ? args.namedChild(0) : null; + if (!moduleNameNode) return true; + const moduleName = getNodeText(moduleNameNode, ctx.source); + if (!moduleName) return true; + + const mod = ctx.createNode('module', moduleName, node, { + docstring: getPrecedingDocstring(node, ctx.source), + }); + if (!mod) return true; + + ctx.pushScope(mod.id); + const doBlock = findDoBlock(node); + if (doBlock) { + for (let i = 0; i < doBlock.namedChildCount; i++) { + const child = doBlock.namedChild(i); + if (child) ctx.visitNode(child); + } + } + ctx.popScope(); + return true; + } + + // --- function definitions (def, defp, defguard, etc.) --- + const info = extractFunctionInfo(node, ctx.source); + if (!info || !info.name) return true; + + const isPrivate = targetText === 'defp' || targetText === 'defguardp' || targetText === 'defmacrop'; + + // Multi-clause merge: if same name as previous def in same file, extend it + if (ctx.filePath === lastFnFile && info.name === lastFnName && lastFnId) { + for (let i = ctx.nodes.length - 1; i >= 0; i--) { + const n = ctx.nodes[i]; + if (n && n.id === lastFnId) { + if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1; + break; + } + } + ctx.pushScope(lastFnId); + const doBlock = findDoBlock(node); + if (doBlock) ctx.visitFunctionBody(doBlock, lastFnId); + ctx.popScope(); + return true; + } + + const fn = ctx.createNode('function', info.name, node, { + docstring: getPrecedingDocstring(node, ctx.source), + signature: info.signature, + visibility: isPrivate ? 'private' : 'public', + }); + if (!fn) return true; + + ctx.pushScope(fn.id); + const doBlock = findDoBlock(node); + if (doBlock) ctx.visitFunctionBody(doBlock, fn.id); + ctx.popScope(); + + lastFnFile = ctx.filePath; + lastFnName = info.name; + lastFnId = fn.id; + + return true; +} + +function handleImport(node: SyntaxNode, _targetText: string, ctx: ExtractorContext): boolean { + // Elixir grammar: `arguments` is a named child, NOT a field on `call` + const args = findChildByType(node, 'arguments'); + if (!args) return true; + + const moduleNameNode = args.namedChild(0); + if (!moduleNameNode) return true; + + const moduleName = getNodeText(moduleNameNode, ctx.source); + if (!moduleName) return true; + + const imp = ctx.createNode('import', moduleName, node, { + signature: getNodeText(node, ctx.source).trim().slice(0, 200), + }); + if (imp && ctx.nodeStack.length > 0) { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: moduleName, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + return true; +} + +function emitCallRef(ctx: ExtractorContext, node: SyntaxNode, calleeName: string): void { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!parentId) return; + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); +} + +// =========================================================================== +// Extractor +// =========================================================================== + +export const elixirExtractor: LanguageExtractor = { + // All patterns are detected via visitNode since the grammar uses `call` for everything + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + + nameField: 'name', + bodyField: 'body', + paramsField: 'params', + interfaceKind: 'trait', + + /** Extract call edges inside function bodies (walked by visitFunctionBody). */ + extractBareCall: (node, source) => { + if (node.type !== 'call') return undefined; + const targetText = getTargetText(node, source); + if (!targetText) return undefined; + // Skip definition/import/keyword calls (handled by visitNode at module level) + if (DEF_CALLS.has(targetText)) return undefined; + if (MODULE_CALLS.has(targetText)) return undefined; + if (PROTOCOL_CALLS.has(targetText)) return undefined; + if (IMPORT_CALLS.has(targetText)) return undefined; + if (SKIP_CALLS.has(targetText)) return undefined; + // Qualified call (Enum.map) + const target = getChildByField(node, 'target') || node.namedChild(0); + if (target && target.type === 'dot') { + const left = getChildByField(target, 'left') || target.namedChild(0); + const right = getChildByField(target, 'right') || target.namedChild(1); + if (left && right) return `${getNodeText(left, source)}.${getNodeText(right, source)}`; + } + // Bare function call + return targetText; + }, + + visitNode: (node, ctx) => { + // --- `call` — the universal node type in Elixir --- + if (node.type === 'call') { + const targetText = getTargetText(node, ctx.source); + if (!targetText) return false; + + // Definition calls (def, defp, defmodule, etc.) + if (DEF_CALLS.has(targetText)) { + return handleDefinition(node, targetText, ctx); + } + + if (MODULE_CALLS.has(targetText)) { + return handleDefinition(node, targetText, ctx); + } + + // Protocol/impl — treat as function-like definitions for now + if (PROTOCOL_CALLS.has(targetText)) { + return handleDefinition(node, targetText, ctx); + } + + // Import-like calls (alias, import, require, use) + if (IMPORT_CALLS.has(targetText)) { + return handleImport(node, targetText, ctx); + } + + // Skip language keyword constructs (if, case, with, etc.) + if (SKIP_CALLS.has(targetText)) { + return true; + } + + // Regular function call — emit call edge + const target = getChildByField(node, 'target') || node.namedChild(0); + if (target && target.type === 'dot') { + // Module.function() qualified call + const left = getChildByField(target, 'left') || target.namedChild(0); + const right = getChildByField(target, 'right') || target.namedChild(1); + if (left && right) { + emitCallRef(ctx, node, `${getNodeText(left, ctx.source)}.${getNodeText(right, ctx.source)}`); + } + } else if (target && target.type === 'identifier') { + // Bare function call + emitCallRef(ctx, node, targetText); + } + + // Return false so the generic walker visits children (nested calls) + return false; + } + + // --- binary_operator — skip pipe |> (data flow, not a call) --- + if (node.type === 'binary_operator') { + if (hasOperatorText(node, ctx.source, '|>')) { + return true; + } + return false; // other binary ops (when, +, etc.) — let generic handle + } + + // --- unary_operator — skip @ attribute access --- + if (node.type === 'unary_operator') { + if (hasOperatorText(node, ctx.source, '@')) { + return true; + } + return false; + } + + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index ab8c8aa07..86896a21d 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -32,8 +32,10 @@ import { cfqueryExtractor } from './cfquery'; import { cobolExtractor } from './cobol'; import { vbnetExtractor } from './vbnet'; import { erlangExtractor } from './erlang'; +import { elixirExtractor } from './elixir'; import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; +import { heexExtractor } from './heex'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -63,6 +65,8 @@ export const EXTRACTORS: Partial> = { cobol: cobolExtractor, vbnet: vbnetExtractor, erlang: erlangExtractor, + elixir: elixirExtractor, solidity: solidityExtractor, terraform: terraformExtractor, + heex: heexExtractor, }; diff --git a/src/types.ts b/src/types.ts index 3681723a9..f5214bfcb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,8 @@ export const LANGUAGES = [ 'cobol', 'vbnet', 'erlang', + 'elixir', + 'heex', 'terraform', 'unknown', ] as const; From 870cf347e01494cd4a0b3c2e81d92eaa8b1a2882 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:45:31 +0300 Subject: [PATCH 2/9] feat: Add HEEx template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HEEx grammar built from source (phoenixframework/tree-sitter-heex v0.9.0, ABI 15) - Vendored WASM at `src/extraction/wasm/tree-sitter-heex.wasm` (33KB) - HEEx is NOT in tree-sitter-wasms (not on npm) — must be built manually - Extractor handles: `<.component_name>` tags, `` components → `component` nodes - `{expr}` expressions and `<%= directive %>` tags → call edges - `.heex` extension wired to `heex` language in grammars.ts EXTENSION_MAP, WASM_GRAMMAR_FILES --- src/extraction/languages/heex.ts | 176 ++++++++++++++++++++++ src/extraction/wasm/tree-sitter-heex.wasm | Bin 0 -> 33049 bytes 2 files changed, 176 insertions(+) create mode 100644 src/extraction/languages/heex.ts create mode 100755 src/extraction/wasm/tree-sitter-heex.wasm diff --git a/src/extraction/languages/heex.ts b/src/extraction/languages/heex.ts new file mode 100644 index 000000000..fc2387df5 --- /dev/null +++ b/src/extraction/languages/heex.ts @@ -0,0 +1,176 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +/** + * HEEx extractor — Phoenix HTML-aware EEx template language. + * + * HEEx is a template language used by Phoenix LiveView. The grammar produces + * an HTML-like AST with: + * - `tag` / `start_tag` / `end_tag` — HTML elements + * - `component` / `start_component` / `end_component` — component tags + * (<.modal>, , <.form>, etc.) + * - `component_name` with `function` child for <.name> style + * or `module` child for style + * - `expression` / `expression_value` — {expr} Elixir expression embeds + * - `directive` — <%= expr %> Elixir directive tags + * + * This extractor extracts component usages (for cross-file resolution) and + * reference calls from template expressions so the graph connects component + * references and callee edges. + */ + +// =========================================================================== +// Helpers +// =========================================================================== + +/** + * Find first named child with a given type. + */ +function findChildByType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === type) return child; + } + return null; +} + +/** + * Find first named child by type and return its text. + */ +function extractNodeTextFromChild( + node: SyntaxNode, + childType: string, + source: string +): string | null { + const child = findChildByType(node, childType); + if (!child) return null; + return getNodeText(child, source); +} + +/** + * Extract callee function names from an Elixir expression value string. + * Matches simple function call patterns: `name(...)` or `Module.name(...)`. + */ +function extractCallsFromExpression( + expr: string +): string[] { + const calls: string[] = []; + // Match identifier or dotted-name followed by ( + const callRegex = /\b([a-zA-Z_][\w$.!?]*)\s*\(/g; + let match; + while ((match = callRegex.exec(expr)) !== null) { + const name = match[1]!; + // Skip Elixir keywords/captures + if ( + name === 'if' || name === 'unless' || name === 'case' || + name === 'cond' || name === 'with' || name === 'try' || + name === 'receive' || name === 'fn' || name === 'for' || + name === 'raise' || name === 'throw' + ) continue; + calls.push(name); + } + return calls; +} + +// =========================================================================== +// Extractor +// =========================================================================== + +export const heexExtractor: LanguageExtractor = { + // HEEx is a template language — no function/class/struct definitions + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + + nameField: 'name', + bodyField: 'body', + paramsField: 'params', + + visitNode: (node, ctx) => { + // --- component: <.modal> or --- + if (node.type === 'component') { + // component_name lives inside start_component child + const startComp = findChildByType(node, 'start_component'); + if (!startComp) return false; + const nameNode = findChildByType(startComp, 'component_name'); + if (!nameNode) return false; + + // component_name has either: + // - `function` child for <.modal> style (text: "modal") + // - `module` child for style (text: "MyComponent") + const compName = + extractNodeTextFromChild(nameNode, 'function', ctx.source) || + extractNodeTextFromChild(nameNode, 'module', ctx.source); + if (!compName) return false; + + // Create component node — this is a reference to another component + ctx.createNode('component', compName, node); + + // Emit unresolved reference so the resolver can link + // to the component's definition file + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: compName, + referenceKind: 'references', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + + // Continue visiting children (nested tags/components/expressions) + return false; + } + + // --- expression: {expr} — extract function calls --- + if (node.type === 'expression') { + const exprValue = findChildByType(node, 'expression_value'); + if (exprValue) { + const exprText = getNodeText(exprValue, ctx.source); + const callees = extractCallsFromExpression(exprText); + for (const callee of callees) { + ctx.addUnresolvedReference({ + fromNodeId: ctx.nodeStack[ctx.nodeStack.length - 1] || '', + referenceName: callee, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + // Let walker visit children (text nodes, etc.) + return false; + } + + // --- directive: <%= expr %> — extract function calls --- + if (node.type === 'directive') { + // Directives contain expression_value directly as child + const exprValue = findChildByType(node, 'expression_value'); + if (exprValue) { + const exprText = getNodeText(exprValue, ctx.source); + const callees = extractCallsFromExpression(exprText); + for (const callee of callees) { + ctx.addUnresolvedReference({ + fromNodeId: ctx.nodeStack[ctx.nodeStack.length - 1] || '', + referenceName: callee, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + return false; + } + + return false; + }, +}; diff --git a/src/extraction/wasm/tree-sitter-heex.wasm b/src/extraction/wasm/tree-sitter-heex.wasm new file mode 100755 index 0000000000000000000000000000000000000000..475af4f781296efb84200d74e22f4cbac64cf571 GIT binary patch literal 33049 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 RM%+}`uBm!{T@eEG{|87Km978) literal 0 HcmV?d00001 From d3f21b179d15487591f9fbf96f24e269de662a71 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:45:41 +0300 Subject: [PATCH 3/9] feat: Add Grok Build (xAI) installer target - Implements `AgentTarget` interface modeled on `codex.ts` (both use TOML MCP config) - Supports global `~/.grok/config.toml` with `$GROK_HOME` override, and local `.grok/config.toml` - Uses existing `toml.ts` helpers: `buildTomlTable`, `upsertTomlTable`, `removeTomlTable` - Cross-platform detection: `command -v` (POSIX), `where` (Windows), file existence fallback - No permissions concept (like Codex target) --- src/installer/targets/grok.ts | 190 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 4 +- src/installer/targets/types.ts | 2 +- 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/installer/targets/grok.ts diff --git a/src/installer/targets/grok.ts b/src/installer/targets/grok.ts new file mode 100644 index 000000000..95d34b30f --- /dev/null +++ b/src/installer/targets/grok.ts @@ -0,0 +1,190 @@ +/** + * Grok Build target (xAI's CLI coding agent). + * + * - MCP server entry to `$GROK_HOME/config.toml` (global, default + * `~/.grok/config.toml`) or `.grok/config.toml` (local) as the + * dotted-key table `[mcp_servers.codegraph]`. TOML — handled by + * the narrow serializer in `./toml.ts`. + * - Instructions to `AGENTS.md` in the global config dir (global) or + * project root (local). + * + * Supports both global and local install locations — unlike Codex CLI, + * Grok Build reads a project-local `.grok/config.toml` when present. + * + * No permissions concept. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + removeMarkedSection, + upsertInstructionsEntry, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, +} from '../instructions-template'; +import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml'; + +const TOML_HEADER = 'mcp_servers.codegraph'; + +/** Global Grok config directory — respects $GROK_HOME override. */ +function grokConfigDir(): string { + const home = process.env.GROK_HOME; + if (home && home.trim().length > 0) { + return home; + } + return path.join(os.homedir(), '.grok'); +} + +/** Base config dir for the given location. */ +function configBaseDir(loc: Location): string { + return loc === 'global' + ? grokConfigDir() + : path.join(process.cwd(), '.grok'); +} + +function tomlConfigPath(loc: Location): string { + return path.join(configBaseDir(loc), 'config.toml'); +} + +function instructionsPath(loc: Location): string { + if (loc === 'global') { + return path.join(grokConfigDir(), 'AGENTS.md'); + } + return path.join(process.cwd(), 'AGENTS.md'); +} + +/** Best-effort check whether the `grok` binary is on PATH. */ +function grokBinaryExists(): boolean { + try { + execSync( + process.platform === 'win32' ? 'where grok' : 'command -v grok', + { stdio: 'ignore', encoding: 'utf-8' }, + ); + return true; + } catch { + return false; + } +} + +class GrokTarget implements AgentTarget { + readonly id = 'grok' as const; + readonly displayName = 'Grok Build'; + readonly docsUrl = 'https://docs.x.ai/build/settings'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = tomlConfigPath(loc); + let alreadyConfigured = false; + if (fs.existsSync(file)) { + try { + const content = fs.readFileSync(file, 'utf-8'); + alreadyConfigured = content.includes(`[${TOML_HEADER}]`); + } catch { /* ignore */ } + } + const installed = grokBinaryExists() || fs.existsSync(configBaseDir(loc)); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + + // AGENTS.md gets the short marker-fenced CodeGraph block (#704): + // subagents and non-MCP harnesses read AGENTS.md but never the MCP + // initialize instructions. Upsert self-heals a stale pre-#529 block. + files.push(upsertInstructionsEntry(instructionsPath(loc))); + + return { files }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + files.push(removeMcpEntry(loc)); + files.push(removeInstructionsEntry(loc)); + return { files }; + } + + printConfig(loc: Location): string { + const block = buildCodegraphBlock(); + return `# Add to ${tomlConfigPath(loc)}\n\n${block}\n`; + } + + describePaths(loc: Location): string[] { + return [tomlConfigPath(loc), instructionsPath(loc)]; + } +} + +function buildCodegraphBlock(): string { + const mcp = getMcpServerConfig(); + return buildTomlTable(TOML_HEADER, { + command: mcp.command, + args: mcp.args, + }); +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = tomlConfigPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const block = buildCodegraphBlock(); + // Single read — `existing === ''` derives both "is the file empty + // or absent" and "what was its content," avoiding a TOCTOU window + // between two `fs.existsSync` calls. + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const created = existing.length === 0; + const { content: nextContent, action } = upsertTomlTable(existing, TOML_HEADER, block); + + if (action === 'unchanged') { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, nextContent); + return { path: file, action: created ? 'created' : 'updated' }; +} + +function removeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = tomlConfigPath(loc); + if (fs.existsSync(file)) { + const content = fs.readFileSync(file, 'utf-8'); + const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER); + if (action === 'removed') { + if (nextContent.trim() === '') { + try { fs.unlinkSync(file); } catch { /* ignore */ } + } else { + atomicWriteFileSync(file, nextContent.trimEnd() + '\n'); + } + return { path: file, action: 'removed' }; + } + return { path: file, action: 'not-found' }; + } + return { path: file, action: 'not-found' }; +} + +/** + * Strip the marker-delimited CodeGraph block from AGENTS.md if a prior + * install wrote one. Used by both install (self-heal on upgrade) and + * uninstall — see issue #529. + */ +function removeInstructionsEntry(loc: Location): WriteResult['files'][number] { + const file = instructionsPath(loc); + const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + return { path: file, action }; +} + +export const grokTarget: AgentTarget = new GrokTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..42856021a 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -14,6 +14,7 @@ import { codexTarget } from './codex'; import { opencodeTarget } from './opencode'; import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; +import { grokTarget } from './grok'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; @@ -22,8 +23,9 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ cursorTarget, codexTarget, opencodeTarget, - hermesTarget, geminiTarget, + grokTarget, + hermesTarget, antigravityTarget, kiroTarget, ]); diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..3e0c9d6c5 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'grok'; /** * Result of `target.detect(location)`. From d18671794e74b13c789ac60ec6012a560c466be6 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:45:51 +0300 Subject: [PATCH 4/9] test: Add extraction tests for Elixir and HEEx - 12 Elixir extraction tests: defmodule, def/defp, alias imports, calls, pipe skip, defguard, defdelegate, defstruct skip, defprotocol, defimpl, defmacro - 4 HEEx extraction tests: <.modal> component, component, {expr} calls, <%= directive %> calls - All use `extractFromSource()` in-memory helper (standard pattern for all languages) - Unique file paths per test to avoid multi-clause merge state interference --- __tests__/extraction.test.ts | 249 +++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 61ffa64a0..d4b1f90b8 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -138,6 +138,13 @@ describe('Language Detection', () => { expect(detectLanguage('versions.tofu')).toBe('terraform'); }); + it('should detect Elixir and HEEx files', () => { + expect(detectLanguage('lib/my_app.ex')).toBe('elixir'); + expect(detectLanguage('lib/my_app.exs')).toBe('elixir'); + expect(detectLanguage('lib/my_app.html.heex')).toBe('heex'); + expect(detectLanguage('lib/my_app.heex')).toBe('heex'); + }); + it('should return unknown for unsupported extensions', () => { expect(detectLanguage('styles.css')).toBe('unknown'); expect(detectLanguage('data.json')).toBe('unknown'); @@ -167,6 +174,8 @@ describe('Language Support', () => { expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); expect(languages).toContain('solidity'); + expect(languages).toContain('elixir'); + expect(languages).toContain('heex'); }); }); @@ -9841,6 +9850,246 @@ init(_) -> {ok, #{}}. }); }); +// ============================================================================= +// Elixir +// ============================================================================= + +describe('Elixir Extraction', () => { + describe('Module extraction', () => { + it('should extract defmodule as a module node', () => { + const code = ` +defmodule MyModule do + def hello do + :world + end +end +`; + const result = extractFromSource('lib/my_module.ex', code); + const mod = result.nodes.find((n) => n.kind === 'module' && n.name === 'MyModule'); + expect(mod).toBeDefined(); + expect(mod?.language).toBe('elixir'); + }); + }); + + describe('Function extraction', () => { + it('should extract def as a public function node', () => { + const code = ` +defmodule M do + def foo do + :ok + end +end +`; + const result = extractFromSource('lib/foo_fn.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'foo'); + expect(fn).toBeDefined(); + expect(fn?.visibility).toBe('public'); + expect(fn?.language).toBe('elixir'); + }); + + it('should extract defp as a private function node', () => { + const code = ` +defmodule M do + defp bar do + :ok + end +end +`; + const result = extractFromSource('lib/bar_fn.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'bar'); + expect(fn).toBeDefined(); + expect(fn?.visibility).toBe('private'); + }); + }); + + describe('Import extraction', () => { + it('should extract alias as an import node', () => { + const code = ` +defmodule M do + alias MyApp.Greeter +end +`; + const result = extractFromSource('lib/alias_test.ex', code); + const imp = result.nodes.find((n) => n.kind === 'import'); + expect(imp).toBeDefined(); + expect(imp?.name).toBe('MyApp.Greeter'); + }); + }); + + describe('Call extraction', () => { + it('should record regular function calls', () => { + const code = ` +defmodule M do + def hello do + greet("world") + end +end +`; + const result = extractFromSource('lib/call_test.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('greet'); + }); + + it('should not extract pipe |> operators as calls', () => { + const code = ` +defmodule M do + def foo do + regular_call() + "hello" |> String.upcase() + end +end +`; + const result = extractFromSource('lib/pipe_test.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('regular_call'); + expect(calls).not.toContain('String.upcase'); + }); + }); + + describe('defguard extraction', () => { + it('should extract defguard as a function node', () => { + const code = ` +defmodule M do + defguard is_even(x) when rem(x, 2) == 0 +end +`; + const result = extractFromSource('lib/defguard_test.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'is_even'); + expect(fn).toBeDefined(); + expect(fn?.language).toBe('elixir'); + expect(fn?.visibility).toBe('public'); + }); + }); + + describe('defdelegate extraction', () => { + it('should extract defdelegate as a function node', () => { + const code = ` +defmodule M do + defdelegate foo(x), to: MyMod +end +`; + const result = extractFromSource('lib/defdelegate_test.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'foo'); + expect(fn).toBeDefined(); + expect(fn?.language).toBe('elixir'); + }); + }); + + describe('defstruct is skipped', () => { + it('should not produce struct or function nodes from defstruct', () => { + const code = ` +defmodule M do + defstruct [:x, :y] +end +`; + const result = extractFromSource('lib/defstruct_test.ex', code); + // No struct or function nodes should be created from defstruct + expect(result.nodes.find((n) => n.kind === 'struct')).toBeUndefined(); + expect(result.nodes.find((n) => n.kind === 'function')).toBeUndefined(); + // No call reference to defstruct keyword (it's in SKIP_CALLS) + expect(result.unresolvedReferences.find((r) => r.referenceName === 'defstruct')).toBeUndefined(); + }); + }); + + describe('defprotocol/defimpl extraction', () => { + it('should handle defprotocol without crashing', () => { + const code = ` +defprotocol P do + def bar(x) +end +`; + const result = extractFromSource('lib/defprotocol_test.ex', code); + // Currently defprotocol/defimpl are handled by PROTOCOL_CALLS → handleDefinition + // but module-name args (alias nodes) are not parsed by extractFunctionInfo, + // so no function node is produced. At minimum, verify no crash. + expect(result.errors.length).toBe(0); + expect(result.nodes.length).toBeGreaterThan(0); + }); + + it('should handle defimpl without crashing', () => { + const code = ` +defimpl P, for: Integer do + def bar(x), do: x +end +`; + const result = extractFromSource('lib/defimpl_test.ex', code); + expect(result.errors.length).toBe(0); + expect(result.nodes.length).toBeGreaterThan(0); + }); + }); + + describe('defmacro extraction', () => { + it('should extract defmacro as a function node', () => { + const code = ` +defmodule M do + defmacro m(x) do + quote do: x + end +end +`; + const result = extractFromSource('lib/defmacro_test.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'm'); + expect(fn).toBeDefined(); + expect(fn?.language).toBe('elixir'); + expect(fn?.visibility).toBe('public'); + }); + }); +}); + +// ============================================================================= +// HEEx +// ============================================================================= + +describe('HEEx Extraction', () => { + describe('Component extraction', () => { + it('should extract <.modal> as a component node', () => { + const code = `<.modal> + content +`; + const result = extractFromSource('lib/page.html.heex', code); + const comp = result.nodes.find((n) => n.kind === 'component' && n.name === 'modal'); + expect(comp).toBeDefined(); + expect(comp?.language).toBe('heex'); + }); + + it('should extract as a component node', () => { + const code = ` + content +`; + const result = extractFromSource('lib/pascal_component.html.heex', code); + const comp = result.nodes.find((n) => n.kind === 'component' && n.name === 'MyComponent'); + expect(comp).toBeDefined(); + expect(comp?.language).toBe('heex'); + }); + }); + + describe('Expression extraction', () => { + it('should extract calls from expression tags', () => { + const code = `
+ {render("form")} +
`; + const result = extractFromSource('lib/expr_test.html.heex', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toContain('render'); + }); + }); + + describe('Directive extraction', () => { + it('should extract calls from directive tags', () => { + const code = `
+ <%= render("form") %> +
`; + const result = extractFromSource('lib/directive_test.html.heex', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toContain('render'); + }); + }); +}); + describe('Terraform Extraction', () => { describe('Language detection', () => { it('should detect Terraform files', () => { From 4c4ed8ef1431a6451faf5ab1978f9b8d8954024d Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:46:00 +0300 Subject: [PATCH 5/9] docs: Update README and CHANGELOG for Elixir and Grok Build - README: Elixir in feature bullet (30+ Languages), Supported Languages table row, Grok Build in Supported Agents list (badge + subtitle + installer + quick start + restart + footer) - CHANGELOG: Entries under `## [Unreleased]` for Elixir language support and Grok Build agent support --- CHANGELOG.md | 2 ++ README.md | 15 +++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201da71e..948905391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **Elixir** (`.ex`, `.exs`) language support — modules, functions, imports, calls, and HEEx (`.heex`) templates with component extraction. +- **Grok Build** agent support — `codegraph install` now detects and configures Grok Build (xAI's coding agent CLI) as an MCP server. - CodeGraph now indexes **Terraform and OpenTofu** (`.tf`, `.tfvars`, `.tofu`) — resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file, so search, callers, and impact queries return real results on infrastructure repos instead of nothing. Module calls are bridged across the module boundary: a `module` block's inputs link to the child module's variables, `module.vpc.vpc_id` reaches the child's `output "vpc_id"` definition, and the block's local `source` path links to the module's files — so "what breaks if I change this module's variable" reaches every caller instead of dead-ending at the declaration (registry and git sources are deliberately left as visible boundaries rather than guessed). Cross-component wiring through the cloudposse/atmos `remote-state` module connects too: `module.vpc.outputs.vpc_cidr` in one component reaches the `vpc` component's own output when the component name is statically declared (a literal, or a variable with a literal default) and exactly one directory matches — anything dynamic or ambiguous stays unlinked. Aliased providers are first-class: `provider "aws" { alias = "east" }` gets its own symbol, and `provider = aws.east` on a resource (or a module's `providers` map) links to that configuration, found up the module tree the way Terraform actually inherits it. `moved`/`import`/`removed` state-migration blocks and `check` assertions reference the resources they name, so a refactor's paper trail is part of the graph. `.tfvars` assignments link to the variables they set, including var-files kept in a subdirectory. Resolution follows Terraform's real per-directory scoping, so same-named variables across modules never cross-link and "what depends on `var.project_id`" in a multi-module repo never mixes in unrelated modules. Thanks @Javviviii2. (#83, #310, #648) - CodeGraph now indexes **CUDA** (`.cu`, `.cuh`) — kernels, device/host functions, structs, and classes become symbols, and the host→kernel call edge survives the `<<>>` launch syntax, so questions like "how does this call reach the GPU kernel?" trace across the CPU/GPU boundary instead of going dark at the launch site. Real-world launch styles all connect: templated launches (`my_kernel<<>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<>>`), and kernels defined through a name-in-first-argument macro (flash-attention's `DEFINE_FLASH_FORWARD_KERNEL(kernel_name, ...) { ... }` style), which now index under their real kernel names. CUDA that lives in plain `.h`/`.hpp` headers — where much real-world device code sits, launch-template headers included — is recognized by content and indexed the same way. Validated on llm.c, flash-attention, and NVIDIA CUTLASS. (#387, #648) - C++ symbols defined inside `namespace` blocks now carry the namespace in their qualified name (`flash::compute_attn`, C++17 `namespace a::b {` included), and namespace-qualified calls (`ns::fn(...)`) resolve to their definitions — previously such calls never linked at all, which hid much of the call graph in namespace-heavy C++ codebases from callers and impact analysis. diff --git a/README.md b/README.md index 67b2a03e5..e1c64116c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Already installed? Run `codegraph upgrade` Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Grok Build, Antigravity, and Kiro with Semantic Code Intelligence **Surgical context · fewer tool calls · faster answers · 100% local** @@ -28,6 +28,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents) [![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents) [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) +[![Grok Build](https://img.shields.io/badge/Grok_Build-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) @@ -76,7 +77,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Grok Build, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -244,7 +245,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **30+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Elixir, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -342,7 +343,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Grok Build**, **Antigravity IDE**, **Kiro** - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -369,7 +370,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Grok Build / Antigravity IDE / Kiro) for the MCP server to load. ### 3. Initialize Projects @@ -683,6 +684,7 @@ is written): - **opencode** - **Hermes Agent** - **Gemini CLI** +- **Grok Build** - **Antigravity IDE** - **Kiro** @@ -720,6 +722,7 @@ is written): | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) | | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) | | Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) | +| Elixir | `.ex`, `.exs`, `.heex` | Full support (modules, functions, calls, imports) | | Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) | | Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) | @@ -791,7 +794,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro** +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Grok Build, Antigravity IDE, and Kiro** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) From b1f83d610c002cd5fe4c98b678470c0872c55885 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 6 Jul 2026 03:46:09 +0300 Subject: [PATCH 6/9] chore: Add Elixir benchmark corpus for agent evaluation - 3 repos: elixir-lang/gen_stage (small, 23 files), phoenixframework/phoenix (medium, 177 files), plausible/analytics (large, 1,186 files) - Each repo has a cross-file architecture question --- .claude/skills/agent-eval/corpus.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 8699a53a6..7752616a7 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -493,6 +493,29 @@ "question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery." } ], + "Elixir": [ + { + "name": "gen_stage", + "repo": "https://github.com/elixir-lang/gen_stage", + "size": "Small", + "files": "~25", + "question": "How does a GenStage producer demand signal flow from the consumer through to the producer's handle_demand callback?" + }, + { + "name": "phoenix", + "repo": "https://github.com/phoenixframework/phoenix", + "size": "Medium", + "files": "~210", + "question": "How does a Phoenix request flow from the endpoint through the router to a controller, and how does the connection (conn) pipeline work?" + }, + { + "name": "plausible", + "repo": "https://github.com/plausible/analytics", + "size": "Large", + "files": "~1650", + "question": "How does Plausible ingest, process, and store a pageview event from the HTTP request through to the database?" + } + ], "Solidity": [ { "name": "solmate", From 2ff87f6c6c2b738b92385c90bb6c594f06af4e99 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Thu, 9 Jul 2026 04:18:48 +0300 Subject: [PATCH 7/9] feat(phoenix): add framework resolver with router.ex route extraction --- __tests__/frameworks.test.ts | 355 +++++++++++++++++++++ src/resolution/frameworks/index.ts | 4 + src/resolution/frameworks/phoenix.ts | 441 +++++++++++++++++++++++++++ src/resolution/strip-comments.ts | 70 ++++- 4 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 src/resolution/frameworks/phoenix.ts diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index d77dcacbb..a899868ab 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1740,4 +1740,359 @@ export class UsersController { expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']); expect(references.map((r) => r.referenceName)).toEqual(['real']); }); + + it('phoenix: skips # commented route lines', () => { + const src = `# get "/fake", FakeController, :index +get "/real", RealController, :index +`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['RealController#index']); + }); + + it('phoenix: skips # commented resources', () => { + const src = `# resources "/fake", FakeController +resources "/real", RealController +`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toContain('GET /real'); + expect(references.map((r) => r.referenceName)).toContain('RealController#index'); + }); +}); + +import { phoenixResolver } from '../src/resolution/frameworks/phoenix'; + +describe('phoenixResolver.detect', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('detects {:phoenix dependency in mix.exs', () => { + const ctx = { + ...baseContext, + readFile: (p: string) => + p === 'mix.exs' ? 'defp deps do [{:phoenix, "~> 1.7"}] end' : null, + }; + expect(phoenixResolver.detect(ctx as any)).toBe(true); + }); + + it('detects {:phoenix with comma separator', () => { + const ctx = { + ...baseContext, + readFile: (p: string) => + p === 'mix.exs' ? 'defp deps do [{:phoenix, "~> 1.7"}, {:phoenix_html, "~> 3.3"}] end' : null, + }; + expect(phoenixResolver.detect(ctx as any)).toBe(true); + }); + + it('detects lib/*_web/router.ex', () => { + const ctx = { + ...baseContext, + readFile: () => null, + listDirectories: (rel: string) => (rel === 'lib' ? ['my_app_web'] : []), + fileExists: (p: string) => p === 'lib/my_app_web/router.ex', + }; + expect(phoenixResolver.detect(ctx as any)).toBe(true); + }); + + it('detects lib/*_web/endpoint.ex', () => { + const ctx = { + ...baseContext, + readFile: () => null, + listDirectories: (rel: string) => (rel === 'lib' ? ['my_app_web'] : []), + fileExists: (p: string) => p === 'lib/my_app_web/endpoint.ex', + }; + expect(phoenixResolver.detect(ctx as any)).toBe(true); + }); + + it('returns false for a non-Phoenix project', () => { + const ctx = { + ...baseContext, + readFile: (p: string) => (p === 'mix.exs' ? 'defp deps do [{:plug, "~> 1.14"}] end' : null), + }; + expect(phoenixResolver.detect(ctx as any)).toBe(false); + }); +}); + +describe('phoenixResolver.claimsReference', () => { + it('claims AppWeb.UserController#index as a route reference', () => { + expect(phoenixResolver.claimsReference!('AppWeb.UserController#index')).toBe(true); + }); + + it('claims simple Controller#action', () => { + expect(phoenixResolver.claimsReference!('UserController#show')).toBe(true); + }); + + it('does not claim plain function names', () => { + expect(phoenixResolver.claimsReference!('index')).toBe(false); + }); + + it('does not claim Rails-style controller#action (lowercase)', () => { + expect(phoenixResolver.claimsReference!('users#index')).toBe(false); + }); +}); + +describe('phoenixResolver.resolve', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('resolves Controller#action to an action method in controllers/ dir', () => { + const indexNode: Node = { + id: 'fn:lib/my_app_web/controllers/user_controller.ex:11:index', + kind: 'function', + name: 'index', + qualifiedName: 'lib/my_app_web/controllers/user_controller.ex::index', + filePath: 'lib/my_app_web/controllers/user_controller.ex', + language: 'elixir', + startLine: 11, + endLine: 13, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...baseContext, + getAllFiles: () => ['lib/my_app_web/controllers/user_controller.ex'], + getNodesInFile: (fp: string) => + fp === 'lib/my_app_web/controllers/user_controller.ex' ? [indexNode] : [], + }; + const ref: UnresolvedRef = { + fromNodeId: 'route:router.ex:3:GET:/users', + referenceName: 'MyApp.UserController#index', + referenceKind: 'references', + line: 3, + column: 0, + filePath: 'router.ex', + language: 'elixir', + }; + const result = phoenixResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(indexNode.id); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(0.85); + }); + + it('returns null for a ref without the #action suffix', () => { + const ref: UnresolvedRef = { + fromNodeId: 'x', + referenceName: 'UserController', + referenceKind: 'references', + line: 1, + column: 0, + filePath: 'a.ex', + language: 'elixir', + }; + expect(phoenixResolver.resolve(ref, baseContext as any)).toBeNull(); + }); + + it('returns null when no matching controller file exists', () => { + const ref: UnresolvedRef = { + fromNodeId: 'route:router.ex:3:GET:/users', + referenceName: 'MyApp.Nonexistent#index', + referenceKind: 'references', + line: 3, + column: 0, + filePath: 'router.ex', + language: 'elixir', + }; + const context = { + ...baseContext, + getAllFiles: () => ['lib/my_app_web/controllers/user_controller.ex'], + }; + expect(phoenixResolver.resolve(ref, context as any)).toBeNull(); + }); +}); + +describe('phoenixResolver.extract', () => { + it('extracts route node and reference for get "/path", Controller, :action', () => { + const src = `get "/users", UserController, :index\n`; + const { nodes, references } = phoenixResolver.extract!('lib/my_app_web/router.ex', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /users'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('UserController#index'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('extracts all HTTP method routes: post, put, patch, delete, options, head', () => { + const verbs = ['post', 'put', 'patch', 'delete', 'options', 'head'] as const; + const lines = verbs.map(v => `${v} "/test", TestController, :action`).join('\n'); + const { nodes, references } = phoenixResolver.extract!('router.ex', lines); + expect(nodes.map((n) => n.name)).toEqual( + verbs.map((v) => `${v.toUpperCase()} /test`), + ); + expect(references.map((r) => r.referenceName)).toEqual( + verbs.map(() => 'TestController#action'), + ); + }); + + it('extracts routes with qualified module controller names', () => { + const src = `get "/users", MyApp.UserController, :index\n`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('MyApp.UserController#index'); + }); + + it('extracts route from a multi-line router with multiple routes', () => { + const src = `defmodule MyApp.Router do + use MyApp, :router + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", MyApp do + pipe_through :api + get "/users", UserController, :index + post "/users", UserController, :create + get "/users/:id", UserController, :show + end + + scope "/", MyApp do + get "/", PageController, :index + end +end +`; + const { nodes, references } = phoenixResolver.extract!('lib/my_app_web/router.ex', src); + expect(nodes.map((n) => n.name).sort()).toEqual([ + 'GET /', + 'GET /api/users', + 'GET /api/users/:id', + 'POST /api/users', + ]); + // pipe_through :api also produces :api references attached to the API routes + const ctrlRefs = references.filter((r) => r.referenceName.includes('#')); + expect(ctrlRefs.map((r) => r.referenceName).sort()).toEqual([ + 'MyApp.PageController#index', + 'MyApp.UserController#create', + 'MyApp.UserController#index', + 'MyApp.UserController#show', + ]); + }); + + it('extracts resources with all 7 default actions', () => { + const src = `resources "/posts", PostController\n`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual([ + 'GET /posts', + 'POST /posts', + 'GET /posts/new', + 'GET /posts/:id', + 'GET /posts/:id/edit', + 'PATCH /posts/:id', + 'DELETE /posts/:id', + ]); + expect(references.map((r) => r.referenceName)).toEqual( + ['index', 'create', 'new', 'show', 'edit', 'update', 'delete'].map( + (a) => `PostController#${a}`, + ), + ); + }); + + it('extracts resources with only: filter', () => { + const src = `resources "/posts", PostController, only: [:index, :show]\n`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /posts', 'GET /posts/:id']); + expect(references.map((r) => r.referenceName)).toEqual([ + 'PostController#index', + 'PostController#show', + ]); + }); + + it('extracts resources with except: filter', () => { + const src = `resources "/posts", PostController, except: [:delete, :update]\n`; + const { nodes } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).not.toContain('DELETE /posts/:id'); + expect(nodes.map((n) => n.name)).not.toContain('PATCH /posts/:id'); + expect(nodes.map((n) => n.name)).toContain('GET /posts'); + expect(nodes.map((n) => n.name)).toContain('POST /posts'); + }); + + it('extracts resource (singular) with 6 default actions', () => { + const src = `resource "/profile", ProfileController\n`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual([ + 'POST /profile', + 'GET /profile/new', + 'GET /profile/:id', + 'GET /profile/:id/edit', + 'PATCH /profile/:id', + 'DELETE /profile/:id', + ]); + // Singular resource has no :index + expect(references.map((r) => r.referenceName)).toEqual( + ['create', 'new', 'show', 'edit', 'update', 'delete'].map( + (a) => `ProfileController#${a}`, + ), + ); + }); + + it('extracts live route with two method nodes (GET + POST)', () => { + const src = `live "/posts/:id", PostLive\n`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /posts/:id', 'POST /posts/:id']); + expect(references.map((r) => r.referenceName)).toEqual([ + 'PostLive', + 'PostLive', + ]); + }); + + it('extracts live route with explicit :action', () => { + const src = `live "/posts/:id", PostLive, :show\n`; + const { nodes } = phoenixResolver.extract!('router.ex', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /posts/:id', 'POST /posts/:id']); + expect(nodes[0].kind).toBe('route'); + }); + + it('extracts pipe_through references attached to the next route', () => { + const src = `scope "/api", MyApp do + pipe_through [:auth, :api] + get "/users", UserController, :index +end +`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + const routeNode = nodes.find((n) => n.name === 'GET /api/users'); + expect(routeNode).toBeDefined(); + // pipe_through entries become references from the route node + const pipeRefs = references.filter((r) => r.fromNodeId === routeNode!.id); + expect(pipeRefs.map((r) => r.referenceName)).toContain(':auth'); + expect(pipeRefs.map((r) => r.referenceName)).toContain(':api'); + }); + + it('extracts routes in nested scopes with combined path and module prefixes', () => { + const src = `scope "/api", MyApp do + scope "/v1", ApiV1 do + get "/users", UserController, :index + end +end +`; + const { nodes, references } = phoenixResolver.extract!('router.ex', src); + expect(nodes[0].name).toBe('GET /api/v1/users'); + expect(references[0].referenceName).toBe('MyApp.ApiV1.UserController#index'); + }); + + it('returns nothing for a non-Elixir file', () => { + expect(phoenixResolver.extract!('main.ts', 'get "/x", X, :index').nodes).toHaveLength(0); + expect(phoenixResolver.extract!('config.json', 'get "/x", X, :index').nodes).toHaveLength(0); + }); }); diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 91da9a01c..90c22c794 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -29,6 +29,7 @@ import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; import { cicsResolver } from './cics'; import { terraformResolver } from './terraform'; +import { phoenixResolver } from './phoenix'; /** * All registered framework resolvers @@ -76,6 +77,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ cicsResolver, // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module terraformResolver, + // Elixir + phoenixResolver, ]; /** @@ -152,3 +155,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { phoenixResolver } from './phoenix'; diff --git a/src/resolution/frameworks/phoenix.ts b/src/resolution/frameworks/phoenix.ts new file mode 100644 index 000000000..99c482845 --- /dev/null +++ b/src/resolution/frameworks/phoenix.ts @@ -0,0 +1,441 @@ +/** + * Phoenix Framework Resolver (Elixir) + * + * Handles Phoenix web framework patterns for route extraction. + * Parses router.ex files into route nodes + references. + */ + +import { Node } from '../../types'; +import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; +import { matchByQualifiedName } from '../name-matcher'; + +// --------------------------------------------------------------------------- +// RESTful route expansion tables +// --------------------------------------------------------------------------- + +const RESTFUL_ROUTES: Record string }> = { + index: { method: 'GET', path: (r) => `/${r}` }, + create: { method: 'POST', path: (r) => `/${r}` }, + new: { method: 'GET', path: (r) => `/${r}/new` }, + show: { method: 'GET', path: (r) => `/${r}/:id` }, + edit: { method: 'GET', path: (r) => `/${r}/:id/edit` }, + update: { method: 'PATCH', path: (r) => `/${r}/:id` }, + delete: { method: 'DELETE', path: (r) => `/${r}/:id` }, +}; + +const PLURAL_RESOURCE_ACTIONS = ['index', 'create', 'new', 'show', 'edit', 'update', 'delete'] as const; +const SINGULAR_RESOURCE_ACTIONS = ['create', 'new', 'show', 'edit', 'update', 'delete'] as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Find the matching `end` for a `do` at `doPos` by tracking do/end balance. */ +function findMatchingEnd(text: string, doPos: number): number { + const doRe = /\bdo\b/g; + const endRe = /\bend\b/g; + let pos = doPos; + let depth = 0; + + while (pos < text.length) { + doRe.lastIndex = pos; + endRe.lastIndex = pos; + + const nextDo = doRe.exec(text); + const nextEnd = endRe.exec(text); + const doIdx = nextDo ? nextDo.index : Infinity; + const endIdx = nextEnd ? nextEnd.index : Infinity; + + if (endIdx === Infinity && doIdx === Infinity) break; + + if (endIdx < doIdx) { + if (depth === 0) return endIdx + 3; // past 'end' + depth--; + pos = endIdx + 3; + } else { + depth++; + pos = doIdx + 2; // past 'do' + } + } + + return text.length; +} + +interface ScopeBlock { + scopeStart: number; + doPos: number; + endPos: number; + pathPrefix: string; + modulePrefix: string; +} + +/** + * Find all `scope … do … end` blocks in `text` (non-recursive – one level). + * Returns blocks sorted by start position. + */ +function findScopeBlocks(text: string): ScopeBlock[] { + const blocks: ScopeBlock[] = []; + // scope "/path", Module do / scope "/path" do / scope Module do + const re = /\bscope\s+(?:(['"])([^'"]+)\1\s*,?\s*)?(?:\b([A-Z]\w*(?:\.[A-Z]\w*)*)\s*)?\bdo\b/g; + let m: RegExpExecArray | null; + + while ((m = re.exec(text)) !== null) { + const pathPrefix = m[2] || ''; + const modulePrefix = m[3] || ''; + const doPos = re.lastIndex; + const endPos = findMatchingEnd(text, doPos); + + blocks.push({ scopeStart: m.index, doPos, endPos, pathPrefix, modulePrefix }); + // Continue search after this block + re.lastIndex = endPos; + } + + return blocks; +} + +function combinePathPrefix(parent: string, child: string): string { + if (!child || child === '/') return parent || ''; + if (!parent || parent === '/') return child; + const p = parent.replace(/\/$/, ''); + return p + (child.startsWith('/') ? child : '/' + child); +} + +function combineModulePrefix(parent: string, child: string): string { + if (!child) return parent; + if (!parent) return child; + return `${parent}.${child}`; +} + +// --------------------------------------------------------------------------- +// Route processing at a single scope level +// --------------------------------------------------------------------------- + +/** + * Emit route nodes + refs for every route pattern found in `text`. + * `pathPrefix` / `modulePrefix` come from the enclosing scope(s). + */ +function processRoutes( + text: string, + filePath: string, + now: number, + pathPrefix: string, + modulePrefix: string, + nodes: Node[], + refs: UnresolvedRef[], +): void { + // Accumulate pipe_through pipeline names; flushed onto the next route. + const pendingPipelines: string[] = []; + + const flushPipelines = (line: number, fromNodeId: string) => { + for (const pipe of pendingPipelines) { + refs.push({ + fromNodeId, + referenceName: `:${pipe}`, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'elixir', + }); + } + pendingPipelines.length = 0; + }; + + // ----- pipe_through ----- + const pipeRe = /\bpipe_through\s+(?::(\w+)|\[([^\]]*)\])/g; + let m: RegExpExecArray | null; + while ((m = pipeRe.exec(text)) !== null) { + if (m[1]) { + pendingPipelines.push(m[1]); + } else if (m[2]) { + const names = m[2].split(',').map(s => s.trim().replace(/^:/, '')).filter(Boolean); + pendingPipelines.push(...names); + } + } + + // ----- HTTP method routes: get "/path", Controller, :action ----- + const methodRe = /\b(get|post|put|patch|delete|options|head)\s+(['"])([^'"]+)\2\s*,\s*([A-Z]\w*(?:\.[A-Z]\w*)*)\s*,\s*:(\w+)/g; + while ((m = methodRe.exec(text)) !== null) { + const method = m[1]!.toUpperCase(); + const routePath = m[3]!; + const controller = m[4]!; + const action = m[5]!; + const line = text.slice(0, m.index).split('\n').length; + + const fullPath = applyPath(pathPrefix, routePath); + const fullCtrl = applyModule(modulePrefix, controller); + + const routeNode = makeRouteNode(filePath, line, method, fullPath, 'elixir', now); + nodes.push(routeNode); + refs.push({ + fromNodeId: routeNode.id, + referenceName: `${fullCtrl}#${action}`, + referenceKind: 'references', + line, column: 0, filePath, language: 'elixir', + }); + flushPipelines(line, routeNode.id); + } + + // ----- resources "/posts", Controller (plural) ----- + const resRe = /\bresources\s+(['"])([^'"]+)\1\s*,\s*([A-Z]\w*(?:\.[A-Z]\w*)*)((?:\s*,\s*(?:only|except):\s*\[([^\]]*)\])?)/g; + while ((m = resRe.exec(text)) !== null) { + // Strip leading / from the resource name — RESTFUL_ROUTES adds it back + const resName = m[2]!.replace(/^\//, ''); + const controller = m[3]!; + const tail = m[4] || ''; + const line = text.slice(0, m.index).split('\n').length; + + let actions = [...PLURAL_RESOURCE_ACTIONS] as string[]; + const onlyMatch = tail.match(/only:\s*\[([^\]]*)\]/); + const exceptMatch = tail.match(/except:\s*\[([^\]]*)\]/); + if (onlyMatch) { + const keep = new Set(onlyMatch[1]!.split(',').map(s => s.trim().replace(/^:/, ''))); + actions = actions.filter(a => keep.has(a)); + } else if (exceptMatch) { + const skip = new Set(exceptMatch[1]!.split(',').map(s => s.trim().replace(/^:/, ''))); + actions = actions.filter(a => !skip.has(a)); + } + + const fullCtrl = applyModule(modulePrefix, controller); + for (const action of actions) { + const spec = RESTFUL_ROUTES[action]!; + const routePath = spec.path(resName); + const fullPath = applyPath(pathPrefix, routePath); + + const routeNode = makeRouteNode(filePath, line, spec.method, fullPath, 'elixir', now); + nodes.push(routeNode); + refs.push({ + fromNodeId: routeNode.id, + referenceName: `${fullCtrl}#${action}`, + referenceKind: 'references', + line, column: 0, filePath, language: 'elixir', + }); + } + } + + // ----- resource "/profile", Controller (singular) ----- + const singRe = /\bresource\s+(['"])([^'"]+)\1\s*,\s*([A-Z]\w*(?:\.[A-Z]\w*)*)((?:\s*,\s*(?:only|except):\s*\[([^\]]*)\])?)/g; + while ((m = singRe.exec(text)) !== null) { + // Strip leading / from the resource name — RESTFUL_ROUTES adds it back + const resName = m[2]!.replace(/^\//, ''); + const controller = m[3]!; + const tail = m[4] || ''; + const line = text.slice(0, m.index).split('\n').length; + + let actions = [...SINGULAR_RESOURCE_ACTIONS] as string[]; + const onlyMatch = tail.match(/only:\s*\[([^\]]*)\]/); + const exceptMatch = tail.match(/except:\s*\[([^\]]*)\]/); + if (onlyMatch) { + const keep = new Set(onlyMatch[1]!.split(',').map(s => s.trim().replace(/^:/, ''))); + actions = actions.filter(a => keep.has(a)); + } else if (exceptMatch) { + const skip = new Set(exceptMatch[1]!.split(',').map(s => s.trim().replace(/^:/, ''))); + actions = actions.filter(a => !skip.has(a)); + } + + const fullCtrl = applyModule(modulePrefix, controller); + for (const action of actions) { + const spec = RESTFUL_ROUTES[action]!; + const routePath = spec.path(resName); + const fullPath = applyPath(pathPrefix, routePath); + + const routeNode = makeRouteNode(filePath, line, spec.method, fullPath, 'elixir', now); + nodes.push(routeNode); + refs.push({ + fromNodeId: routeNode.id, + referenceName: `${fullCtrl}#${action}`, + referenceKind: 'references', + line, column: 0, filePath, language: 'elixir', + }); + } + } + + // ----- live "/path", LiveModule [, :action] ----- + const liveRe = /\blive\s+(['"])([^'"]+)\1\s*,\s*([A-Z]\w*(?:\.[A-Z]\w*)*)(?:\s*,\s*:(\w+))?/g; + while ((m = liveRe.exec(text)) !== null) { + const routePath = m[2]!; + const liveMod = m[3]!; + const action = m[4] || undefined; // optional explicit action + const line = text.slice(0, m.index).split('\n').length; + + const fullPath = applyPath(pathPrefix, routePath); + const refName = applyModule(modulePrefix, liveMod) + (action ? `#${action}` : ''); + + for (const method of ['GET', 'POST']) { + const routeNode = makeRouteNode(filePath, line, method, fullPath, 'elixir', now); + nodes.push(routeNode); + refs.push({ + fromNodeId: routeNode.id, + referenceName: refName, + referenceKind: 'references', + line, column: 0, filePath, language: 'elixir', + }); + } + } +} + +function applyPath(prefix: string, routePath: string): string { + if (!prefix || prefix === '/') return routePath; + const p = prefix.replace(/\/$/, ''); + return p + (routePath.startsWith('/') ? routePath : '/' + routePath); +} + +function applyModule(prefix: string, ctrl: string): string { + if (!prefix) return ctrl; + return `${prefix}.${ctrl}`; +} + +function makeRouteNode( + filePath: string, + line: number, + method: string, + routePath: string, + language: string, + now: number, +): Node { + return { + id: `route:${filePath}:${line}:${method}:${routePath}`, + kind: 'route', + name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${method}:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + language: language as any, + updatedAt: now, + }; +} + +// --------------------------------------------------------------------------- +// Scope-aware recursive content processor +// --------------------------------------------------------------------------- + +function processLevel( + text: string, + filePath: string, + now: number, + pathPrefix: string, + modulePrefix: string, + nodes: Node[], + refs: UnresolvedRef[], +): void { + const scopes = findScopeBlocks(text); + + let lastEnd = 0; + for (const scope of scopes) { + // Routes in the gap before this scope + const gap = text.slice(lastEnd, scope.scopeStart); + processRoutes(gap, filePath, now, pathPrefix, modulePrefix, nodes, refs); + + // Inner content of the scope block (between do and end) + const inner = text.slice(scope.doPos, scope.endPos - 3); // strip trailing 'end' + const newPath = combinePathPrefix(pathPrefix, scope.pathPrefix); + const newModule = combineModulePrefix(modulePrefix, scope.modulePrefix); + processLevel(inner, filePath, now, newPath, newModule, nodes, refs); + + lastEnd = scope.endPos; + } + + // Routes after the last scope + const tail = text.slice(lastEnd); + processRoutes(tail, filePath, now, pathPrefix, modulePrefix, nodes, refs); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const phoenixResolver: FrameworkResolver = { + name: 'phoenix', + languages: ['elixir'], + + detect(context: ResolutionContext): boolean { + // Check mix.exs for {:phoenix dependency + const mixExs = context.readFile('mix.exs'); + if (mixExs && /\{:phoenix[},]/.test(mixExs)) { + return true; + } + + // Check for Phoenix-specific router and endpoint files. + // Phoenix convention is lib/_web/router.ex and lib/_web/endpoint.ex + const dirs = context.listDirectories?.('lib'); + if (dirs) { + for (const dir of dirs) { + if (dir.endsWith('_web')) { + if (context.fileExists(`lib/${dir}/router.ex`)) return true; + if (context.fileExists(`lib/${dir}/endpoint.ex`)) return true; + } + } + } + + return false; + }, + + claimsReference(name: string): boolean { + // Phoenix route patterns like AppWeb.UserController#index + return /^[A-Z][\w.]+#\w+$/.test(name); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Parse ControllerName#action + const ca = ref.referenceName.match(/^([A-Z][\w.]+)#(\w+)$/); + if (!ca) return null; + + const qualifiedCtrl = ca[1]!; + const action = ca[2]!; + + // Get simple controller name (last segment after dots) + const segments = qualifiedCtrl.split('.'); + const simpleName = segments[segments.length - 1]!; + + // Convert to snake_case for file lookup + // UserController → user_controller + const snakeName = simpleName + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/([a-z\d])([A-Z])/g, '$1_$2') + .toLowerCase(); + + // Search in lib/*_web/controllers/ for matching file + const allFiles = context.getAllFiles(); + for (const file of allFiles) { + if (!/\/controllers\//.test(file)) continue; + if (!file.endsWith(`/${snakeName}.ex`)) continue; + + const fileNodes = context.getNodesInFile(file); + // Find the action method/function + const actionNode = fileNodes.find( + n => (n.kind === 'function' || n.kind === 'method') && n.name === action, + ); + if (actionNode) { + return { + original: ref, + targetNodeId: actionNode.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + } + + // Fallback: try matchByQualifiedName + return matchByQualifiedName(ref, context); + }, + + extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } { + // Only process Elixir files (router.ex, etc.) + if (!filePath.endsWith('.ex') && !filePath.endsWith('.exs')) { + return { nodes: [], references: [] }; + } + + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + const safe = stripCommentsForRegex(content, 'elixir'); + + processLevel(safe, filePath, now, '', '', nodes, references); + + return { nodes, references }; + }, +}; diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts index 45dc10a83..7a6110934 100644 --- a/src/resolution/strip-comments.ts +++ b/src/resolution/strip-comments.ts @@ -36,7 +36,8 @@ export type CommentLang = | 'rust' | 'c' | 'cpp' - | 'erlang'; + | 'erlang' + | 'elixir'; export function stripCommentsForRegex(content: string, lang: CommentLang): string { switch (lang) { @@ -48,6 +49,8 @@ export function stripCommentsForRegex(content: string, lang: CommentLang): strin return stripRust(content); case 'erlang': return stripErlang(content); + case 'elixir': + return stripElixir(content); case 'php': return stripPhp(content); case 'go': @@ -475,6 +478,71 @@ function stripRust(src: string): string { return out.join(''); } +// ---------- Elixir ---------- + +/** + * Elixir: `#` line comments, `"""..."""` and `'''...'''` heredocs. + * Same as Ruby but without `=begin`/`=end` blocks. + */ +function stripElixir(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + + // Heredoc string: """...""" or '''...''' + if ((c === '"' || c === "'") && c2 === c && src[i + 2] === c) { + const quote = c; + const start = i; + i += 3; + while (i < n) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === quote && src[i + 1] === quote && src[i + 2] === quote) { + i += 3; + break; + } + i++; + } + blankRange(out, start, i, src); + continue; + } + + // String literals + if (c === '"' || c === "'") { + const quote = c; + i++; + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === quote) i++; + continue; + } + + // Line comment + if (c === '#') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + i++; + } + + return out.join(''); +} + // ---------- Erlang ---------- /** From 0ca228ab8b55b6517167760b4e2f81d095a39d50 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Thu, 9 Jul 2026 04:18:58 +0300 Subject: [PATCH 8/9] feat(elixir): add import path resolution and stdlib external filtering --- __tests__/resolution.test.ts | 143 ++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 164 +++++++++++++++++++++++++++++- src/resolution/index.ts | 36 +++++++ 3 files changed, 342 insertions(+), 1 deletion(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 144947d02..6a9c7a085 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -4400,3 +4400,146 @@ procedure Helper; var t: TTgt; begin t.Hit; end; }); }); }); + +import { extractElixirImports, elixirModuleToPath, elixirPathToModule, isExternalImport } from '../src/resolution/import-resolver'; + +describe('Elixir import mapping', () => { + describe('elixirModuleToPath / elixirPathToModule', () => { + it('converts module name to filesystem path', () => { + expect(elixirModuleToPath('MyApp.Greeter')).toBe('lib/my_app/greeter.ex'); + expect(elixirModuleToPath('Enum')).toBe('lib/enum.ex'); + expect(elixirModuleToPath('MyApp.UserController')).toBe('lib/my_app/user_controller.ex'); + }); + + it('converts filesystem path back to module name', () => { + expect(elixirPathToModule('lib/my_app/greeter.ex')).toBe('MyApp.Greeter'); + expect(elixirPathToModule('lib/enum.ex')).toBe('Enum'); + expect(elixirPathToModule('lib/my_app/user_controller.ex')).toBe('MyApp.UserController'); + }); + + it('round-trips correctly', () => { + const modules = ['Enum', 'MyApp.Greeter', 'MyApp.UserController', 'MyApp.Api.V1.Posts']; + for (const mod of modules) { + const path = elixirModuleToPath(mod); + expect(elixirPathToModule(path)).toBe(mod); + } + }); + + it('handles .exs extension', () => { + expect(elixirPathToModule('lib/my_app/greeter.exs')).toBe('MyApp.Greeter'); + }); + }); + + describe('extractElixirImports', () => { + it('extracts alias MyApp.Greeter', () => { + const result = extractElixirImports('alias MyApp.Greeter'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + localName: 'Greeter', + exportedName: 'MyApp.Greeter', + source: 'lib/my_app/greeter.ex', + isNamespace: true, + isDefault: false, + }); + }); + + it('extracts alias MyApp.Greeter, as: :G', () => { + const result = extractElixirImports('alias MyApp.Greeter, as: :G'); + expect(result).toHaveLength(1); + expect(result[0].localName).toBe('G'); + expect(result[0].source).toBe('lib/my_app/greeter.ex'); + }); + + it('extracts import Enum', () => { + const result = extractElixirImports('import Enum'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + localName: 'Enum', + exportedName: '*', + source: 'lib/enum.ex', + isNamespace: true, + }); + }); + + it('extracts import Enum, only: [map: 2, filter: 3]', () => { + const result = extractElixirImports('import Enum, only: [map: 2, filter: 3]'); + expect(result).toHaveLength(2); + expect(result.map((r) => r.localName).sort()).toEqual(['filter', 'map']); + expect(result[0]).toMatchObject({ + exportedName: 'map', + source: 'lib/enum.ex', + isNamespace: false, + isDefault: false, + }); + }); + + it('extracts import with except clause (namespace import)', () => { + const result = extractElixirImports('import Enum, except: [map: 2]'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + localName: 'Enum', + exportedName: '*', + isNamespace: true, + }); + }); + + it('extracts require Integer', () => { + const result = extractElixirImports('require Integer'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + localName: 'Integer', + source: 'lib/integer.ex', + isNamespace: true, + }); + }); + + it('extracts use MyApp.Web, :controller', () => { + const result = extractElixirImports('use MyApp.Web, :controller'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + localName: 'Web', + source: 'lib/my_app/web.ex', + isNamespace: true, + }); + }); + + it('extracts combo of alias, import, require, use', () => { + const content = ` + alias MyApp.Repo + import Ecto.Query + require Integer + use MyApp.Web, :controller + `; + const result = extractElixirImports(content); + expect(result.length).toBe(4); + const localNames = result.map((r) => r.localName); + expect(localNames).toContain('Repo'); + expect(localNames).toContain('Query'); + expect(localNames).toContain('Integer'); + expect(localNames).toContain('Web'); + }); + + it('returns empty array for non-import content', () => { + expect(extractElixirImports('defmodule Foo do\ndef hello, do: :world\nend')).toEqual([]); + }); + }); + + describe('isExternalImport (elixir)', () => { + it('classifies stdlib modules as external', () => { + expect(isExternalImport('lib/enum.ex', 'elixir')).toBe(true); + expect(isExternalImport('lib/string.ex', 'elixir')).toBe(true); + expect(isExternalImport('lib/kernel.ex', 'elixir')).toBe(true); + }); + + it('classifies user modules as local', () => { + expect(isExternalImport('lib/my_app/greeter.ex', 'elixir')).toBe(false); + expect(isExternalImport('lib/my_app/user_controller.ex', 'elixir')).toBe(false); + }); + + it('handles direct module names (not just paths)', () => { + // isExternalImport converts paths; if passed a bare module name, + // treat it as a path string + expect(isExternalImport('lib/enum.ex', 'elixir')).toBe(true); + }); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 763dac1b8..513e9b291 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -34,6 +34,7 @@ const EXTENSION_RESOLUTION: Record = { csharp: ['.cs'], php: ['.php'], ruby: ['.rb'], + elixir: ['.ex', '.exs', '.heex'], objc: ['.h', '.m', '.mm'], }; @@ -172,6 +173,19 @@ const C_CPP_STDLIB_HEADERS = new Set([ 'version', ]); +/** + * Elixir / OTP standard library modules. + * Used by isExternalImport to distinguish stdlib from user modules. + */ +const ELIXIR_STDLIB_MODULES = new Set([ + 'Kernel', 'Enum', 'List', 'Map', 'String', 'Tuple', 'Atom', 'Integer', 'Float', + 'Date', 'Time', 'DateTime', 'Regex', 'IO', 'File', 'Path', 'System', 'Process', + 'Agent', 'Task', 'GenServer', 'Supervisor', 'Registry', 'Node', 'Port', + 'Application', 'Code', 'Access', 'OptionParser', 'Inspect', 'Protocol', + 'Record', 'Set', 'Stream', 'Range', 'Keyword', 'Module', 'Function', + 'Macro', 'Quote', +]); + /** * Check if an import is external (npm package, etc.) * @@ -180,7 +194,7 @@ const C_CPP_STDLIB_HEADERS = new Set([ * like `@components/*` would fail the bare-specifier heuristic and * be classified as external before alias resolution can run. */ -function isExternalImport( +export function isExternalImport( importPath: string, language: Language, context?: ResolutionContext @@ -258,6 +272,17 @@ function isExternalImport( if (C_CPP_STDLIB_HEADERS.has(withoutExt)) return true; } + if (language === 'elixir') { + // Elixir import source is a filesystem path (e.g. "lib/enum.ex"). + // Convert back to module name and check against stdlib set. + const moduleName = elixirPathToModule(importPath); + if (ELIXIR_STDLIB_MODULES.has(moduleName)) { + return true; + } + // User modules (not in stdlib) are local. + return false; + } + return false; } @@ -672,6 +697,8 @@ export function extractImportMappings( mappings.push(...extractPHPImports(content)); } else if (language === 'c' || language === 'cpp') { mappings.push(...extractCppImports(content)); + } else if (language === 'elixir') { + mappings.push(...extractElixirImports(content)); } return mappings; @@ -979,6 +1006,141 @@ function extractCppImports(content: string): ImportMapping[] { return mappings; } +/** + * Convert an Elixir module name (e.g. "MyApp.Greeter", "Enum") to a + * filesystem path relative to the project root. + * + * Strategy: split by ".", snake_case each segment, join with "/", + * prepend "lib/", append ".ex". + * + * Fallback chain (when the resolver tries to locate the file): + * lib/my_app/greeter.ex → lib/my_app/greeter.exs → lib/.ex + */ +export function elixirModuleToPath(modulePath: string): string { + const parts = modulePath.split('.'); + const snakeParts = parts.map(p => + p + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/([a-z\d])([A-Z])/g, '$1_$2') + .toLowerCase(), + ); + return `lib/${snakeParts.join('/')}.ex`; +} + +/** + * Reverse of elixirModuleToPath: convert a filesystem path like + * "lib/my_app/greeter.ex" back to module name "MyApp.Greeter". + */ +export function elixirPathToModule(filePath: string): string { + const withoutExt = filePath.replace(/\.(ex|exs)$/, ''); + const withoutLib = withoutExt.startsWith('lib/') ? withoutExt.slice(4) : withoutExt; + if (!withoutLib) return filePath; + const parts = withoutLib.split('/'); + const camelParts = parts.map(p => + p.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''), + ); + return camelParts.join('.'); +} + +/** + * Extract Elixir import mappings from `alias`, `import`, `require`, and `use` + * statements. + * + * Parsed forms: + * alias MyApp.Greeter → namespace mapping to lib/my_app/greeter.ex + * alias MyApp.Greeter, as: G → aliased mapping with localName "G" + * import Enum → namespace mapping + * import Enum, only: [map: 2] → per-function mappings (when only: present) + * require Integer → namespace mapping + * use MyApp.Web, :controller → namespace mapping + */ +export function extractElixirImports(content: string): ImportMapping[] { + const mappings: ImportMapping[] = []; + + // ----- alias MyApp.Greeter [ , as: :Alias ] ----- + const aliasRe = /\balias\s+([A-Z]\w*(?:\.[A-Z]\w*)*)(?:\s*,\s*as:\s*:(\w+))?/gm; + let m: RegExpExecArray | null; + while ((m = aliasRe.exec(content)) !== null) { + const modulePath = m[1]!; + const alias = m[2]; + const localName = alias || modulePath.split('.').pop()!; + const source = elixirModuleToPath(modulePath); + mappings.push({ + localName, + exportedName: modulePath, + source, + isDefault: false, + isNamespace: true, + }); + } + + // ----- import MyApp.Module [ , only:/except: [... ] ] ----- + const importRe = /\bimport\s+([A-Z]\w*(?:\.[A-Z]\w*)*)(?:\s*,\s*(only|except):\s*\[([^\]]*)\])?/gm; + while ((m = importRe.exec(content)) !== null) { + const modulePath = m[1]!; + const importKind = m[2]; // 'only' | 'except' | undefined + const clauseBody = m[3]?.trim(); // content inside [brackets] + const source = elixirModuleToPath(modulePath); + + if (importKind === 'only' && clauseBody) { + // "map: 2, filter: 3" → extract map, filter (the NAME before :arity) + const funcs = clauseBody.split(',').map(s => s.trim()); + for (const func of funcs) { + const fMatch = func.match(/(\w+):\s*\d+/); + if (fMatch) { + mappings.push({ + localName: fMatch[1]!, + exportedName: fMatch[1]!, + source, + isDefault: false, + isNamespace: false, + }); + } + } + } else { + // Bare import or except: — treat as namespace import + const localName = modulePath.split('.').pop()!; + mappings.push({ + localName, + exportedName: '*', + source, + isDefault: false, + isNamespace: true, + }); + } + } + + // ----- require Integer ----- + const requireRe = /\brequire\s+([A-Z]\w*(?:\.[A-Z]\w*)*)/gm; + while ((m = requireRe.exec(content)) !== null) { + const modulePath = m[1]!; + const localName = modulePath.split('.').pop()!; + mappings.push({ + localName, + exportedName: '*', + source: elixirModuleToPath(modulePath), + isDefault: false, + isNamespace: true, + }); + } + + // ----- use MyApp.Web[, :controller] ----- + const useRe = /\buse\s+([A-Z]\w*(?:\.[A-Z]\w*)*)/gm; + while ((m = useRe.exec(content)) !== null) { + const modulePath = m[1]!; + const localName = modulePath.split('.').pop()!; + mappings.push({ + localName, + exportedName: '*', + source: elixirModuleToPath(modulePath), + isDefault: false, + isNamespace: true, + }); + } + + return mappings; +} + // Cache import mappings per file to avoid re-reading and re-parsing const importMappingCache = new Map(); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index aa36fbfd3..fd65d7c13 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -192,6 +192,28 @@ const CPP_BUILT_INS = new Set([ 'move', 'forward', 'swap', ]); +const ELIXIR_STDLIB_MODULES = new Set([ + 'Kernel', 'Enum', 'List', 'Map', 'String', 'Tuple', 'Atom', 'Integer', 'Float', + 'Date', 'Time', 'DateTime', 'Regex', 'IO', 'File', 'Path', 'System', 'Process', + 'Agent', 'Task', 'GenServer', 'Supervisor', 'Registry', 'Node', 'Port', + 'Application', 'Code', 'Access', 'OptionParser', 'Inspect', 'Protocol', + 'Record', 'Set', 'Stream', 'Range', 'Keyword', 'Module', 'Function', + 'Macro', 'Quote', +]); + +const ELIXIR_BUILT_INS = new Set([ + 'if', 'unless', 'case', 'cond', 'with', 'try', + 'raise', 'throw', 'exit', + 'is_atom', 'is_binary', 'is_boolean', 'is_float', 'is_function', + 'is_integer', 'is_list', 'is_map', 'is_number', 'is_pid', 'is_port', + 'is_reference', 'is_tuple', + 'abs', 'round', 'elem', 'put_elem', 'div', 'rem', 'trunc', 'round', + 'hd', 'tl', 'length', 'in', 'map_size', 'byte_size', 'bit_size', + 'self', 'send', 'spawn', 'spawn_link', 'spawn_monitor', 'make_ref', + 'inspect', + 'sigil_r', 'sigil_R', 'sigil_w', 'sigil_W', 'sigil_s', 'sigil_S', +]); + /** * Reference Resolver * @@ -1246,6 +1268,20 @@ export class ReferenceResolver { } } + // Elixir standard library modules — `Enum.map`, `String.length`, etc. + if (ref.language === 'elixir') { + const dotIdx = name.indexOf('.'); + if (dotIdx > 0) { + const mod = name.substring(0, dotIdx); + if (ELIXIR_STDLIB_MODULES.has(mod)) { + return true; + } + } + if (ELIXIR_BUILT_INS.has(name)) { + return true; + } + } + // C/C++ standard library symbols (printf, malloc, std::vector, etc.). // Names that collide with user-defined symbols are NOT filtered — // C and C++ projects routinely shadow stdlib names (custom allocators From 24749700ca32a77c5ec117b077025f32add2360b Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Thu, 9 Jul 2026 04:19:07 +0300 Subject: [PATCH 9/9] test(elixir): add integration test for Phoenix flow indexing --- __tests__/frameworks-integration.test.ts | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 8cf836345..ed4276a7a 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -1214,3 +1214,165 @@ describe('Terraform follow-ups: remote-state bridge, provider alias, moved block } }); }); + +describe('Phoenix end-to-end — route→handler resolution', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('indexes a minimal Phoenix project and resolves route→handler edges', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-phoenix-')); + + // -- mix.exs with Phoenix dependency -- + fs.writeFileSync( + path.join(tmpDir, 'mix.exs'), + 'defmodule MyApp.MixProject do\n' + + ' use Mix.Project\n' + + ' def project do\n' + + ' [\n' + + ' app: :my_app,\n' + + ' deps: [\n' + + ' {:phoenix, "~> 1.7"},\n' + + ' {:phoenix_live_view, "~> 1.0"},\n' + + ' ]\n' + + ' ]\n' + + ' end\n' + + 'end\n' + ); + + // -- Directories -- + fs.mkdirSync(path.join(tmpDir, 'lib/my_app_web/controllers'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'lib/my_app_web/live'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'lib/my_app'), { recursive: true }); + + // -- router.ex with scope, get, resources, live -- + fs.writeFileSync( + path.join(tmpDir, 'lib/my_app_web/router.ex'), + 'defmodule MyAppWeb.Router do\n' + + ' use MyAppWeb, :router\n' + + '\n' + + ' scope "/", MyAppWeb do\n' + + ' get "/users", UserController, :index\n' + + ' resources "/posts", PostController\n' + + ' live "/dashboard", DashboardLive\n' + + ' end\n' + + 'end\n' + ); + + // -- user_controller.ex -- + fs.writeFileSync( + path.join(tmpDir, 'lib/my_app_web/controllers/user_controller.ex'), + 'defmodule MyAppWeb.UserController do\n' + + ' use MyAppWeb, :controller\n' + + '\n' + + ' def index(conn, _params) do\n' + + ' conn\n' + + ' end\n' + + 'end\n' + ); + + // -- post_controller.ex with all 7 RESTful actions -- + fs.writeFileSync( + path.join(tmpDir, 'lib/my_app_web/controllers/post_controller.ex'), + 'defmodule MyAppWeb.PostController do\n' + + ' use MyAppWeb, :controller\n' + + '\n' + + ' def index(conn, _params), do: conn\n' + + ' def create(conn, _params), do: conn\n' + + ' def new(conn, _params), do: conn\n' + + ' def show(conn, %{"id" => _id}), do: conn\n' + + ' def edit(conn, %{"id" => _id}), do: conn\n' + + ' def update(conn, _params), do: conn\n' + + ' def delete(conn, _params), do: conn\n' + + 'end\n' + ); + + // -- dashboard_live.ex -- + fs.writeFileSync( + path.join(tmpDir, 'lib/my_app_web/live/dashboard_live.ex'), + 'defmodule MyAppWeb.DashboardLive do\n' + + ' use MyAppWeb, :live_view\n' + + '\n' + + ' def mount(_params, _session, socket) do\n' + + ' {:ok, socket}\n' + + ' end\n' + + 'end\n' + ); + + // -- greeter.ex (standalone module, no Phoenix dependency) -- + fs.writeFileSync( + path.join(tmpDir, 'lib/my_app/greeter.ex'), + 'defmodule MyApp.Greeter do\n' + + ' def hello, do: :world\n' + + 'end\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // -- Verify route nodes from router.ex -- + + const routes = cg.getNodesByKind('route'); + expect(routes.length).toBeGreaterThan(0); + + // Unscoped HTTP route inside scope "/", MyAppWeb + const usersRoute = routes.find((n) => n.name === 'GET /users'); + expect(usersRoute, 'GET /users route node').toBeDefined(); + + // Resources routes (7 CRUD actions expanded by the resolver) + expect(routes.find((n) => n.name === 'GET /posts'), 'GET /posts').toBeDefined(); + expect(routes.find((n) => n.name === 'POST /posts'), 'POST /posts').toBeDefined(); + expect(routes.find((n) => n.name === 'GET /posts/new'), 'GET /posts/new').toBeDefined(); + expect(routes.find((n) => n.name === 'GET /posts/:id'), 'GET /posts/:id').toBeDefined(); + expect(routes.find((n) => n.name === 'GET /posts/:id/edit'), 'GET /posts/:id/edit').toBeDefined(); + expect(routes.find((n) => n.name === 'PATCH /posts/:id'), 'PATCH /posts/:id').toBeDefined(); + expect(routes.find((n) => n.name === 'DELETE /posts/:id'), 'DELETE /posts/:id').toBeDefined(); + + // Live routes produce GET + POST for the same path + expect(routes.find((n) => n.name === 'GET /dashboard'), 'GET /dashboard').toBeDefined(); + expect(routes.find((n) => n.name === 'POST /dashboard'), 'POST /dashboard').toBeDefined(); + + // -- Verify route→handler reference edge for UserController.index -- + + const userCtrlNodes = cg.getNodesInFile('lib/my_app_web/controllers/user_controller.ex'); + const indexFn = userCtrlNodes.find((n) => n.kind === 'function' && n.name === 'index'); + expect(indexFn, 'UserController.index function node').toBeDefined(); + + const usersOutEdges = cg.getOutgoingEdges(usersRoute!.id); + const toIndex = usersOutEdges.find((e) => e.target === indexFn!.id); + expect(toIndex, 'GET /users → UserController.index references edge').toBeDefined(); + expect(toIndex!.kind, 'edge kind must be references').toBe('references'); + + // -- Verify PostController actions all exist and at least the first is reachable -- + + const postCtrlNodes = cg.getNodesInFile('lib/my_app_web/controllers/post_controller.ex'); + // Every action method should be indexed + for (const action of ['index', 'create', 'new', 'show', 'edit', 'update', 'delete']) { + expect( + postCtrlNodes.find((n) => n.kind === 'function' && n.name === action), + `PostController.${action} function`, + ).toBeDefined(); + } + + // GET /posts → PostController.index + const getPosts = routes.find((n) => n.name === 'GET /posts')!; + const postIndexFn = postCtrlNodes.find((n) => n.kind === 'function' && n.name === 'index')!; + const toPostIndex = cg.getOutgoingEdges(getPosts.id).find((e) => e.target === postIndexFn.id); + expect(toPostIndex, 'GET /posts → PostController.index references edge').toBeDefined(); + + // -- Verify MyApp.Greeter module was indexed correctly (standalone module) -- + + const modules = cg.getNodesByKind('module'); + const greeter = modules.find((n) => n.name === 'MyApp.Greeter'); + expect(greeter, 'MyApp.Greeter module node').toBeDefined(); + + // Greeter.hello function exists inside the module + const greeterNodes = cg.getNodesInFile('lib/my_app/greeter.ex'); + const helloFn = greeterNodes.find((n) => n.kind === 'function' && n.name === 'hello'); + expect(helloFn, 'MyApp.Greeter.hello function').toBeDefined(); + + cg.close(); + }); +});