From 7d29502669b33011cc554a22bdeced2de2e40702 Mon Sep 17 00:00:00 2001 From: Joe Hong Date: Thu, 6 Aug 2026 09:08:40 +0800 Subject: [PATCH] feat(gleam): add full Gleam language support and Erlang FFI resolution Add tree-sitter-backed extraction for Gleam modules, symbols, imports, calls, constructors, and type references, with package-aware src/test resolution. Resolve explicit Erlang FFI targets by module, function, and arity, persisting foreign-reference metadata and extracting Erlang function arities. Handle opaque constructor visibility, prelude and external terminals, _build and deleted-file indexing, extraction invalidation, and regression coverage. --- CHANGELOG.md | 4 + README.md | 3 +- __tests__/db-perf.test.ts | 115 ++++++++ __tests__/extraction.test.ts | 168 ++++++++++++ __tests__/gleam-correctness.test.ts | 218 +++++++++++++++ __tests__/gleam-ffi.test.ts | 192 +++++++++++++ __tests__/resolution.test.ts | 207 ++++++++++++++ src/db/migrations.ts | 15 +- src/db/queries.ts | 133 +++++---- src/db/schema.sql | 7 +- src/extraction/extraction-version.ts | 2 +- src/extraction/grammars.ts | 5 +- src/extraction/index.ts | 37 ++- src/extraction/languages/erlang.ts | 22 +- src/extraction/languages/gleam.ts | 297 +++++++++++++++++++++ src/extraction/languages/index.ts | 2 + src/extraction/tree-sitter-types.ts | 11 + src/extraction/tree-sitter.ts | 1 + src/extraction/wasm/tree-sitter-gleam.wasm | Bin 0 -> 409323 bytes src/resolution/import-resolver.ts | 200 +++++++++++++- src/resolution/index.ts | 135 ++++++++-- src/resolution/name-matcher.ts | 14 +- src/resolution/types.ts | 6 +- src/types.ts | 13 + 24 files changed, 1699 insertions(+), 108 deletions(-) create mode 100644 __tests__/gleam-correctness.test.ts create mode 100644 __tests__/gleam-ffi.test.ts create mode 100644 src/extraction/languages/gleam.ts create mode 100755 src/extraction/wasm/tree-sitter-gleam.wasm diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..c3dd469c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server. +- Added full Gleam language support for `.gleam` files, including the vendored + tree-sitter grammar, functions, types, constants, imports, call references, + and cross-file module resolution. + ### Fixes - C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) diff --git a/README.md b/README.md index f8bb60bbc..01a60610f 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **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, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi, Gleam | | **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 | @@ -787,6 +787,7 @@ is written): | Swift | `.swift` | Full support | | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | +| Gleam | `.gleam` | Full support (functions, external functions, custom types and constructors, type aliases, constants, imports, call edges, and cross-file module resolution) | | Dart | `.dart` | Full support | | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts index 941fb50f5..cf9a717ff 100644 --- a/__tests__/db-perf.test.ts +++ b/__tests__/db-perf.test.ts @@ -142,6 +142,101 @@ describe('deleteResolvedReferences (chunking)', () => { }); }); +describe('unresolved reference metadata', () => { + let dir: string; + let db: DatabaseConnection; + let q: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-unresolved-metadata-')); + db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + q = new QueryBuilder(db.getDb()); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('round-trips structured metadata through a single unresolved-ref insert', () => { + q.insertNode(makeNode('caller')); + const metadata = { + ffi: true, + targetLanguage: 'erlang', + module: 'config_ffi', + function: 'getenv', + arity: 2, + } as const; + + q.insertUnresolvedRef({ + fromNodeId: 'caller', + referenceName: 'config_ffi.getenv', + referenceKind: 'calls', + line: 1, + column: 0, + metadata, + }); + + expect(q.getUnresolvedReferences()).toHaveLength(1); + expect(q.getUnresolvedReferences()[0]?.metadata).toEqual(metadata); + }); + + it('round-trips structured metadata through a batch unresolved-ref insert', () => { + q.insertNode(makeNode('caller')); + const metadata = { + ffi: true, + targetLanguage: 'erlang', + module: 'config_ffi', + function: 'getenv', + arity: 2, + } as const; + + q.insertUnresolvedRefsBatch([ + { + fromNodeId: 'caller', + referenceName: 'config_ffi::getenv', + referenceKind: 'calls', + line: 1, + column: 0, + metadata, + }, + ]); + + expect(q.getUnresolvedReferences()).toHaveLength(1); + expect(q.getUnresolvedReferences()[0]?.metadata).toEqual(metadata); + }); + + it('retains structured metadata when a failed reference is retried', () => { + q.insertNode(makeNode('caller')); + const metadata = { + ffi: true, + targetLanguage: 'erlang', + module: 'config_ffi', + function: 'getenv', + arity: 2, + } as const; + + q.insertUnresolvedRef({ + fromNodeId: 'caller', + referenceName: 'config_ffi::getenv', + referenceKind: 'calls', + line: 1, + column: 0, + metadata, + }); + q.markReferencesFailed([ + { + fromNodeId: 'caller', + referenceName: 'config_ffi::getenv', + referenceKind: 'calls', + }, + ]); + + expect(q.getRetryableFailedReferences(['getenv'])).toHaveLength(1); + expect(q.getRetryableFailedReferences(['getenv'])[0]?.metadata).toEqual(metadata); + }); +}); + describe('insertNode cache invalidation', () => { let dir: string; let db: DatabaseConnection; @@ -363,3 +458,23 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', () fs.rmSync(dir, { recursive: true, force: true }); }); }); + +describe('migration v10: persist unresolved reference metadata', () => { + it('adds the metadata column to a version 9 database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-mig10-')); + const db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + const raw = db.getDb(); + + raw.exec('ALTER TABLE unresolved_refs DROP COLUMN metadata'); + raw.prepare('DELETE FROM schema_versions WHERE version >= 10').run(); + + runMigrations(raw, 9); + + const columns = raw.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>; + expect(columns.some((column) => column.name === 'metadata')).toBe(true); + expect(getCurrentVersion(raw)).toBe(10); + + db.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 292658822..74546858c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { execFileSync } from 'node:child_process'; import { CodeGraph } from '../src'; import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction'; import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars'; @@ -19,6 +20,133 @@ beforeAll(async () => { await loadAllGrammars(); }); +// ============================================================================= +// Gleam +// ============================================================================= + +describe('Gleam Extraction', () => { + describe('Language detection', () => { + it('should detect Gleam files', () => { + expect(detectLanguage('main.gleam')).toBe('gleam'); + expect(detectLanguage('src/app/server.gleam')).toBe('gleam'); + }); + + it('should report Gleam as supported', () => { + expect(isLanguageSupported('gleam')).toBe(true); + expect(getSupportedLanguages()).toContain('gleam'); + }); + }); + + describe('Function extraction', () => { + it('should extract public function declarations', () => { + const code = ` +pub fn add(a: Int, b: Int) -> Int { + a + b +} +`; + const result = extractFromSource('math.gleam', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'add'); + expect(fn).toBeDefined(); + expect(fn?.language).toBe('gleam'); + expect(fn?.isExported).toBe(true); + expect(fn?.signature).toContain('Int'); + }); + + it('should extract private function declarations as not exported', () => { + const code = ` +fn helper(x: Int) -> Int { + x * 2 +} +`; + const result = extractFromSource('util.gleam', code); + const fn = result.nodes.find((n) => n.name === 'helper'); + expect(fn).toBeDefined(); + expect(fn?.isExported).toBe(false); + }); + + it('should extract external functions', () => { + const code = ` +@external(erlang, "io", "format") +pub fn ext_format(fmt: String) -> Nil +`; + const result = extractFromSource('ffi.gleam', code); + expect(result.nodes.find((n) => n.name === 'ext_format')).toBeDefined(); + }); + }); + + describe('Type extraction', () => { + it('should extract custom type as enum with data constructors as enum_member', () => { + const code = ` +pub type Shape { + Circle(radius: Float) + Square(side: Float) + Rectangle(width: Float, height: Float) +} +`; + const result = extractFromSource('shape.gleam', code); + const shape = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Shape'); + expect(shape).toBeDefined(); + expect(shape?.isExported).toBe(true); + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.find((m) => m.name === 'Circle')).toBeDefined(); + expect(members.find((m) => m.name === 'Square')).toBeDefined(); + expect(members.find((m) => m.name === 'Rectangle')).toBeDefined(); + }); + + it('should extract type aliases', () => { + const code = ` +pub type UserId = String +type Pair = #(Int, Int) +`; + const result = extractFromSource('types.gleam', code); + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(aliases.find((a) => a.name === 'UserId')).toBeDefined(); + expect(aliases.find((a) => a.name === 'Pair')).toBeDefined(); + }); + }); + + describe('Constant extraction', () => { + it('should extract top-level constants', () => { + const code = ` +pub const max_retries = 3 +const default_timeout = 30 +`; + const result = extractFromSource('config.gleam', code); + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.find((c) => c.name === 'max_retries')).toBeDefined(); + expect(constants.find((c) => c.name === 'default_timeout')).toBeDefined(); + }); + }); + + describe('Import and call extraction', () => { + it('should extract bare and selective imports', () => { + const code = ` +import gleam/io as out +import gleam/list.{map, filter} +`; + const result = extractFromSource('main.gleam', code); + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.find((i) => i.name === 'gleam/io')).toBeDefined(); + expect(imports.find((i) => i.name === 'gleam/list')).toBeDefined(); + }); + + it('should emit unresolved refs for bare and qualified calls', () => { + const code = ` +import gleam/io.{println} + +pub fn main() -> Nil { + println("hi") + io.print("there") +} +`; + const result = extractFromSource('app.gleam', code); + const callRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(callRefs.find((r) => r.referenceName === 'println')).toBeDefined(); + expect(callRefs.find((r) => r.referenceName.startsWith('io.'))).toBeDefined(); + }); + }); +}); + // Create a temporary directory for each test function createTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-')); @@ -6767,6 +6895,27 @@ describe('Full Indexing', () => { cleanupTempDir(tempDir); }); + it('treats a tracked-but-deleted file as removed during full indexing', async () => { + const filePath = path.join(tempDir, 'src', 'gone.gleam'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'pub fn gone() -> Nil { Nil }\n'); + execFileSync('git', ['init', '-q'], { cwd: tempDir }); + execFileSync('git', ['add', 'src/gone.gleam'], { cwd: tempDir }); + + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + expect(cg.getNodesInFile('src/gone.gleam').some((node) => node.name === 'gone')).toBe(true); + + fs.unlinkSync(filePath); + const result = await cg.indexAll(); + + expect(result.errors.some((error) => error.filePath === 'src/gone.gleam')).toBe(false); + expect(result.filesErrored).toBe(0); + expect(cg.getFile('src/gone.gleam')).toBeNull(); + expect(cg.getNodesInFile('src/gone.gleam')).toEqual([]); + cg.close(); + }); + it('should index a TypeScript file', async () => { // Create test file const srcDir = path.join(tempDir, 'src'); @@ -6981,6 +7130,25 @@ describe('Directory Exclusion', () => { cleanupTempDir(tempDir); }); + it('excludes Gleam _build output by default', () => { + const ig = buildDefaultIgnore(tempDir); + expect(ig.ignores('_build/default/lib/dep/src/generated.gleam')).toBe(true); + expect(ig.ignores('src/_build_helpers/real.gleam')).toBe(false); + }); + + it('does not index tracked Gleam files under _build', () => { + const buildDir = path.join(tempDir, '_build', 'default', 'lib', 'dep', 'src'); + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, 'generated.gleam'), 'pub fn generated() -> Nil { Nil }\n'); + fs.writeFileSync(path.join(srcDir, 'main.gleam'), 'pub fn main() -> Nil { Nil }\n'); + execFileSync('git', ['init', '-q'], { cwd: tempDir }); + execFileSync('git', ['add', '.'], { cwd: tempDir }); + + expect(scanDirectory(tempDir)).toEqual(['src/main.gleam']); + }); + it('should exclude directories listed in .gitignore', () => { // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules const srcDir = path.join(tempDir, 'src'); diff --git a/__tests__/gleam-correctness.test.ts b/__tests__/gleam-correctness.test.ts new file mode 100644 index 000000000..3da21d4f1 --- /dev/null +++ b/__tests__/gleam-correctness.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Gleam graph correctness', () => { + let projectDir = ''; + let graph: CodeGraph | undefined; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-gleam-correctness-')); + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }); + }); + + afterEach(() => { + graph?.destroy(); + graph = undefined; + if (fs.existsSync(projectDir)) fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeProjectFile(filePath: string, content: string): void { + const absolutePath = path.join(projectDir, filePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); + } + + function unresolvedStatuses(filePath: string): Array<{ reference_name: string; status: string }> { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(path.join(projectDir, '.codegraph', 'codegraph.db')); + try { + return db.prepare( + 'SELECT reference_name, status FROM unresolved_refs WHERE file_path = ? ORDER BY reference_name', + ).all(filePath) as Array<{ reference_name: string; status: string }>; + } finally { + db.close(); + } + } + + it('keeps opaque constructors private while exporting ordinary constructors', () => { + const result = extractFromSource('src/types.gleam', [ + 'pub opaque type Secret { Secret(value: String) }', + 'pub type Visible { Visible(value: String) }', + ].join('\n')); + + expect(result.nodes.find((node) => node.kind === 'enum' && node.name === 'Secret')?.isExported).toBe(true); + expect(result.nodes.find((node) => node.kind === 'enum_member' && node.name === 'Secret')?.isExported).toBe(false); + expect(result.nodes.find((node) => node.kind === 'enum_member' && node.name === 'Visible')?.isExported).toBe(true); + }); + + it('resolves a local call to the constructor instead of its same-named type', async () => { + writeProjectFile( + 'src/main.gleam', + 'pub type User { User(name: String) }\npub fn make() -> User { User("A") }\n', + ); + + graph = await CodeGraph.init(projectDir, { index: true }); + const make = graph.getNodesByKind('function').find((node) => node.name === 'make')!; + const call = graph.getOutgoingEdges(make.id).find((edge) => edge.kind === 'calls')!; + + expect(graph.getNode(call.target)).toMatchObject({ kind: 'enum_member', name: 'User' }); + }, 30_000); + + it('resolves an imported call to the constructor instead of its same-named type', async () => { + writeProjectFile('src/models.gleam', 'pub type User { User(name: String) }\n'); + writeProjectFile('src/main.gleam', 'import models.{User}\npub fn make() { User("A") }\n'); + + graph = await CodeGraph.init(projectDir, { index: true }); + const make = graph.getNodesByKind('function').find((node) => node.name === 'make')!; + const call = graph.getOutgoingEdges(make.id).find((edge) => edge.kind === 'calls')!; + + expect(graph.getNode(call.target)).toMatchObject({ kind: 'enum_member', name: 'User' }); + }, 30_000); + + it('extracts local and remote types from every Gleam type position', () => { + const result = extractFromSource('src/domain.gleam', [ + 'import app/models as models', + 'pub type Local { Local }', + 'pub type Event { Event(owner: models.User, local: Local) }', + 'pub type Owner = models.User', + 'pub fn convert(value: models.User, local: Local) -> Result(models.User, Local) { todo }', + ].join('\n')); + const refs = result.unresolvedReferences.filter((ref) => ref.referenceKind === 'references'); + const convert = result.nodes.find((node) => node.kind === 'function' && node.name === 'convert')!; + const event = result.nodes.find((node) => node.kind === 'enum' && node.name === 'Event')!; + const owner = result.nodes.find((node) => node.kind === 'type_alias' && node.name === 'Owner')!; + + expect(refs.filter((ref) => ref.fromNodeId === convert.id).map((ref) => ref.referenceName)) + .toEqual(expect.arrayContaining(['models.User', 'Local'])); + expect(refs.filter((ref) => ref.fromNodeId === event.id).map((ref) => ref.referenceName)) + .toEqual(expect.arrayContaining(['models.User', 'Local'])); + expect(refs.filter((ref) => ref.fromNodeId === owner.id).map((ref) => ref.referenceName)) + .toContain('models.User'); + expect(refs.some((ref) => ref.referenceName === 'Result')).toBe(false); + }); + + it('resolves local and imported Gleam type references end to end', async () => { + writeProjectFile('src/models.gleam', 'pub type User { User(name: String) }\n'); + writeProjectFile('src/main.gleam', [ + 'import models', + 'pub type Local { Local }', + 'pub type Event { Event(owner: models.User, local: Local) }', + 'pub type Owner = models.User', + 'pub fn convert(value: models.User, local: Local) -> Local { local }', + ].join('\n')); + + graph = await CodeGraph.init(projectDir, { index: true }); + const owners = graph.getNodesByKind('function').filter((node) => node.name === 'convert'); + owners.push(...graph.getNodesByKind('enum').filter((node) => node.name === 'Event')); + owners.push(...graph.getNodesByKind('type_alias').filter((node) => node.name === 'Owner')); + const targets = owners.flatMap((ownerNode) => + graph!.getOutgoingEdges(ownerNode.id) + .filter((edge) => edge.kind === 'references') + .map((edge) => graph!.getNode(edge.target)), + ); + + expect(targets).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'enum', name: 'User', filePath: 'src/models.gleam' }), + expect.objectContaining({ kind: 'enum', name: 'Local', filePath: 'src/main.gleam' }), + ])); + }, 30_000); + + it('keeps absent package and unshadowed prelude refs external without false edges', async () => { + writeProjectFile('src/decoy.gleam', 'pub fn decode(value: String) -> String { value }\n'); + writeProjectFile('src/main.gleam', [ + 'import external_pkg/json.{decode}', + 'pub fn main(value: String) {', + ' let _ = decode(value)', + ' Ok(Nil)', + '}', + ].join('\n')); + + graph = await CodeGraph.init(projectDir, { index: true }); + const main = graph.getNodesByKind('function').find((node) => node.name === 'main')!; + + expect(graph.getOutgoingEdges(main.id).filter((edge) => edge.kind === 'calls')).toHaveLength(0); + expect(unresolvedStatuses('src/main.gleam')).toEqual(expect.arrayContaining([ + { reference_name: 'decode', status: 'external' }, + { reference_name: 'Ok', status: 'external' }, + ])); + }, 30_000); + + it('resolves a project constructor that shadows a Gleam prelude name', async () => { + writeProjectFile('src/project_result.gleam', 'pub type ProjectResult { Ok(value: String) }\n'); + writeProjectFile('src/main.gleam', [ + 'import project_result.{Ok}', + 'pub fn main() { Ok("project") }', + ].join('\n')); + + graph = await CodeGraph.init(projectDir, { index: true }); + const main = graph.getNodesByKind('function').find((node) => node.name === 'main')!; + const call = graph.getOutgoingEdges(main.id).find((edge) => edge.kind === 'calls')!; + + expect(graph.getNode(call.target)).toMatchObject({ + kind: 'enum_member', + name: 'Ok', + filePath: 'src/project_result.gleam', + }); + }, 30_000); + + it('does not treat an unrelated project constructor as a prelude shadow', async () => { + writeProjectFile('src/logging.gleam', 'pub type LogLevel { Error }\n'); + writeProjectFile('src/main.gleam', 'pub fn main() { Error("prelude result") }\n'); + + graph = await CodeGraph.init(projectDir, { index: true }); + const main = graph.getNodesByKind('function').find((node) => node.name === 'main')!; + + expect(graph.getOutgoingEdges(main.id).filter((edge) => edge.kind === 'calls')).toHaveLength(0); + expect(unresolvedStatuses('src/main.gleam')).toContainEqual({ + reference_name: 'Error', + status: 'external', + }); + }, 30_000); + + it.each([ + ['without an import', 'pub fn make() { Secret("x") }\n'], + ['through a selective import', 'import secret.{Secret}\npub fn make() { Secret("x") }\n'], + ['through a namespace import', 'import secret\npub fn make() { secret.Secret("x") }\n'], + ])('does not expose an opaque constructor across files %s', async (_label, mainSource) => { + writeProjectFile('src/secret.gleam', 'pub opaque type Secret { Secret(value: String) }\n'); + writeProjectFile('src/main.gleam', mainSource); + + graph = await CodeGraph.init(projectDir, { index: true }); + const make = graph.getNodesByKind('function').find((node) => node.name === 'make')!; + const wrongEdges = graph.getOutgoingEdges(make.id).filter((edge) => { + if (edge.kind !== 'calls' && edge.kind !== 'instantiates') return false; + return graph!.getNode(edge.target)?.filePath === 'src/secret.gleam'; + }); + + expect(wrongEdges).toHaveLength(0); + }, 30_000); + + it('resolves an opaque constructor inside its defining module', async () => { + writeProjectFile( + 'src/secret.gleam', + 'pub opaque type Secret { Secret(value: String) }\nfn make() { Secret("x") }\n', + ); + + graph = await CodeGraph.init(projectDir, { index: true }); + const make = graph.getNodesByKind('function').find((node) => node.name === 'make')!; + const call = graph.getOutgoingEdges(make.id).find((edge) => edge.kind === 'calls')!; + + expect(graph.getNode(call.target)).toMatchObject({ + kind: 'enum_member', + name: 'Secret', + filePath: 'src/secret.gleam', + isExported: false, + }); + }, 30_000); +}); diff --git a/__tests__/gleam-ffi.test.ts b/__tests__/gleam-ffi.test.ts new file mode 100644 index 000000000..3a7e53a1f --- /dev/null +++ b/__tests__/gleam-ffi.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Gleam Erlang FFI extraction', () => { + it.each([ + ['pub fn', true], + ['fn', false], + ])('captures a renamed %s wrapper with module, function, and arity', (declaration, isExported) => { + const result = extractFromSource( + 'src/config.gleam', + `@external(erlang, "config_ffi", "getenv")\n${declaration} os_getenv(key: String, fallback: String) -> String`, + ); + const wrapper = result.nodes.find((node) => node.kind === 'function' && node.name === 'os_getenv'); + const ffi = result.unresolvedReferences.find((ref) => ref.fromNodeId === wrapper?.id); + + expect(wrapper?.isExported).toBe(isExported); + expect(ffi).toMatchObject({ + referenceName: 'config_ffi::getenv', + referenceKind: 'calls', + metadata: { + ffi: true, + targetLanguage: 'erlang', + module: 'config_ffi', + function: 'getenv', + arity: 2, + }, + }); + }); + + it('selects the Erlang target from consecutive platform annotations', () => { + const result = extractFromSource( + 'src/platform.gleam', + '@external(erlang, "platform_ffi", "run")\n@external(javascript, "./platform.mjs", "run")\nfn run(value: Int) -> Int', + ); + expect(result.unresolvedReferences.filter((ref) => ref.metadata?.ffi)).toEqual([ + expect.objectContaining({ + referenceName: 'platform_ffi::run', + metadata: expect.objectContaining({ targetLanguage: 'erlang', arity: 1 }), + }), + ]); + }); + + it.each([ + '@external(erlang, config_ffi, "getenv")\nfn bad() -> Nil', + '@external(erlang, "config_ffi")\nfn bad() -> Nil', + '@external(javascript, "./ffi.mjs", "run")\nfn bad() -> Nil', + '@external(erlang, "config_ffi", "run")\n// detached annotation\nfn bad() -> Nil', + ])('keeps the wrapper but emits no Erlang FFI ref for unsupported input', (source) => { + const result = extractFromSource('src/bad.gleam', source); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'bad')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.metadata?.ffi === true)).toBe(false); + }); +}); + +describe('Erlang FFI target arities', () => { + it('records every arity supported by a grouped Erlang function node', () => { + const result = extractFromSource( + 'src/ffi/config_ffi.erl', + '-module(config_ffi).\n-export([getenv/1, getenv/2]).\ngetenv(Key) -> Key.\ngetenv(Key, Default) -> Default.\n', + ); + const target = result.nodes.find( + (node) => node.language === 'erlang' && node.kind === 'function' && node.qualifiedName === 'config_ffi::getenv', + ); + expect(target?.decorators).toEqual(expect.arrayContaining(['erlang-arity:1', 'erlang-arity:2'])); + }); + + it('recreates the function when the same Erlang file is extracted again', () => { + const source = '-module(config_ffi).\n-export([getenv/2]).\ngetenv(Key, Default) -> Default.\n'; + extractFromSource('src/ffi/config_ffi.erl', source); + + const result = extractFromSource('src/ffi/config_ffi.erl', source); + const target = result.nodes.find( + (node) => node.language === 'erlang' && node.kind === 'function' && node.qualifiedName === 'config_ffi::getenv', + ); + + expect(target?.decorators).toContain('erlang-arity:2'); + }); +}); + +describe('Gleam Erlang FFI resolution', () => { + let projectDir = ''; + let graph: CodeGraph | undefined; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-gleam-ffi-')); + fs.mkdirSync(path.join(projectDir, 'src', 'ffi'), { recursive: true }); + }); + + afterEach(() => { + graph?.destroy(); + graph = undefined; + if (fs.existsSync(projectDir)) fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('links an indexed target by module, function, and arity with FFI metadata', async () => { + fs.writeFileSync( + path.join(projectDir, 'src', 'config.gleam'), + '@external(erlang, "config_ffi", "getenv")\nfn os_getenv(key: String, fallback: String) -> String\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'ffi', 'config_ffi.erl'), + '-module(config_ffi).\n-export([getenv/2]).\ngetenv(Key, Default) -> Default.\n', + ); + + graph = await CodeGraph.init(projectDir, { index: true }); + const wrapper = graph.getNodesByKind('function').find((node) => node.name === 'os_getenv'); + const edges = wrapper ? graph.getOutgoingEdges(wrapper.id).filter((edge) => edge.kind === 'calls') : []; + + expect(edges).toHaveLength(1); + expect(graph.getNode(edges[0]!.target)).toMatchObject({ + language: 'erlang', + qualifiedName: 'config_ffi::getenv', + }); + expect(edges[0]!.metadata).toMatchObject({ + ffi: true, + targetLanguage: 'erlang', + module: 'config_ffi', + function: 'getenv', + arity: 2, + resolvedBy: 'foreign-function', + }); + }, 30_000); + + it('ignores Erlang comments when matching FFI target arity', async () => { + fs.writeFileSync( + path.join(projectDir, 'src', 'config.gleam'), + '@external(erlang, "config_ffi", "getenv")\nfn os_getenv(key: String, fallback: String) -> String\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'ffi', 'config_ffi.erl'), + '-module(config_ffi).\n-export([getenv/2]).\ngetenv(Key, % fallback value\n Default) -> Default.\n', + ); + + graph = await CodeGraph.init(projectDir, { index: true }); + const wrapper = graph.getNodesByKind('function').find((node) => node.name === 'os_getenv')!; + const calls = graph.getOutgoingEdges(wrapper.id).filter((edge) => edge.kind === 'calls'); + + expect(calls).toHaveLength(1); + expect(graph.getNode(calls[0]!.target)).toMatchObject({ + language: 'erlang', + qualifiedName: 'config_ffi::getenv', + }); + }, 30_000); + + it.each([ + ['unindexed OTP target', '@external(erlang, "timer", "sleep")\nfn sleep(ms: Int) -> Nil\n', ''], + ['wrong target arity', '@external(erlang, "config_ffi", "getenv")\nfn getenv(key: String, fallback: String) -> String\n', '-module(config_ffi).\n-export([getenv/1]).\ngetenv(Key) -> Key.\n'], + ])('does not create a false edge for an %s', async (_label, gleam, erlang) => { + fs.writeFileSync(path.join(projectDir, 'src', 'config.gleam'), gleam); + if (erlang) fs.writeFileSync(path.join(projectDir, 'src', 'ffi', 'config_ffi.erl'), erlang); + + graph = await CodeGraph.init(projectDir, { index: true }); + const wrapper = graph.getNodesByKind('function').find( + (node) => node.language === 'gleam' && (node.name === 'sleep' || node.name === 'getenv'), + ); + expect(wrapper ? graph.getOutgoingEdges(wrapper.id).filter((edge) => edge.kind === 'calls') : []).toHaveLength(0); + if (_label === 'unindexed OTP target') { + expect(graph.getNodesByKind('function').some((node) => node.qualifiedName === 'timer::sleep')).toBe(false); + } + }, 30_000); + + it('leaves an ambiguous duplicate Erlang target unresolved', async () => { + fs.writeFileSync( + path.join(projectDir, 'src', 'config.gleam'), + '@external(erlang, "duplicate_ffi", "run")\nfn run(value: Int) -> Int\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'ffi', 'one.erl'), + '-module(duplicate_ffi).\n-export([run/1]).\nrun(Value) -> Value.\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'ffi', 'two.erl'), + '-module(duplicate_ffi).\n-export([run/1]).\nrun(Value) -> Value.\n', + ); + + graph = await CodeGraph.init(projectDir, { index: true }); + const wrapper = graph.getNodesByKind('function').find( + (node) => node.language === 'gleam' && node.name === 'run', + ); + expect(wrapper ? graph.getOutgoingEdges(wrapper.id).filter((edge) => edge.kind === 'calls') : []).toHaveLength(0); + }, 30_000); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 637b4a9d0..fbd5edda3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -604,6 +604,213 @@ from ..services import auth_service expect(mappings.some((m) => m.localName === 'helper')).toBe(true); expect(mappings.some((m) => m.localName === 'User')).toBe(true); }); + + it('should extract Gleam namespace, selective, aliased, and type imports', () => { + const content = [ + 'import app/io', + 'import app/math as m', + 'import app/list.{map, filter as keep, type User}', + ].join('\n'); + + const mappings = extractImportMappings('src/app.gleam', content, 'gleam'); + + expect(mappings).toEqual(expect.arrayContaining([ + expect.objectContaining({ + localName: 'io', source: 'app/io', exportedName: '*', isNamespace: true, + }), + expect.objectContaining({ + localName: 'm', source: 'app/math', exportedName: '*', isNamespace: true, + }), + expect.objectContaining({ + localName: 'map', source: 'app/list', exportedName: 'map', isNamespace: false, + }), + expect.objectContaining({ + localName: 'keep', source: 'app/list', exportedName: 'filter', isNamespace: false, + }), + expect.objectContaining({ + localName: 'User', source: 'app/list', exportedName: 'User', isNamespace: false, + }), + ])); + }); + + it('extracts multiline Gleam imports without commented-out mappings', () => { + const mappings = extractImportMappings( + 'src/main.gleam', + [ + '// import fake/module.{phantom}', + 'import app/list.{', + ' map, // keep the next binding', + ' filter as keep', + '}', + ].join('\n'), + 'gleam', + ); + + expect(mappings.some((mapping) => mapping.source === 'fake/module')).toBe(false); + expect(mappings).toEqual(expect.arrayContaining([ + expect.objectContaining({ localName: 'map', source: 'app/list' }), + expect.objectContaining({ localName: 'keep', exportedName: 'filter', source: 'app/list' }), + ])); + }); + + it('should resolve Gleam project modules and treat the standard library as external', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'src/app/io.gleam', + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => ['src/app/io.gleam'], + }; + + expect(resolveImportPath('app/io', 'src/main.gleam', 'gleam', context)).toBe('src/app/io.gleam'); + expect(resolveImportPath('gleam/io', 'src/main.gleam', 'gleam', context)).toBeNull(); + }); + + it('resolves src and test modules from the nearest Gleam package root', () => { + const files = new Set([ + 'apps/api/gleam.toml', + 'apps/api/src/api/service.gleam', + 'apps/api/test/api/support.gleam', + 'src/api/service.gleam', + ]); + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (filePath) => files.has(filePath), + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => [...files], + }; + + expect(resolveImportPath('api/service', 'apps/api/src/main.gleam', 'gleam', context)) + .toBe('apps/api/src/api/service.gleam'); + expect(resolveImportPath('api/support', 'apps/api/test/main_test.gleam', 'gleam', context)) + .toBe('apps/api/test/api/support.gleam'); + }); + + it('does not escape a Gleam package through repository-global fallback paths', () => { + const nestedFiles = new Set([ + 'apps/api/gleam.toml', + 'app/service.gleam', + ]); + const nestedContext: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (filePath) => nestedFiles.has(filePath), + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => [...nestedFiles], + }; + expect(resolveImportPath('app/service', 'apps/api/src/main.gleam', 'gleam', nestedContext)) + .toBeNull(); + + const rootFiles = new Set(['gleam.toml', 'app/service.gleam']); + const rootContext: ResolutionContext = { + ...nestedContext, + fileExists: (filePath) => rootFiles.has(filePath), + getAllFiles: () => [...rootFiles], + }; + expect(resolveImportPath('app/service', 'src/main.gleam', 'gleam', rootContext)).toBeNull(); + }); + + it('should resolve Gleam imported bare, aliased, and qualified calls', async () => { + fs.mkdirSync(path.join(tempDir, 'app')); + fs.writeFileSync( + path.join(tempDir, 'app', 'lib.gleam'), + `pub fn run() -> Nil { Nil }\n\npub fn format() -> Nil { Nil }\n`, + ); + fs.writeFileSync( + path.join(tempDir, 'app', 'main.gleam'), + `import app/lib.{run, format as fmt}\nimport app/lib as m\n\npub fn main() -> Nil {\n run()\n fmt()\n m.run()\n Nil\n}\n`, + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const main = cg.getNodesByKind('function').find( + (n) => n.name === 'main' && n.filePath.replace(/\\/g, '/') === 'app/main.gleam', + ); + expect(main).toBeDefined(); + const calls = cg.getOutgoingEdges(main!.id).filter((e) => e.kind === 'calls'); + expect(calls).toHaveLength(3); + const targets = calls.map((edge) => cg.getNode(edge.target)); + expect(targets.map((node) => node?.name)).toEqual(expect.arrayContaining(['run', 'format'])); + expect(targets.every((node) => node?.filePath.replace(/\\/g, '/') === 'app/lib.gleam')).toBe(true); + }, 30000); + + it('should not bind external Gleam stdlib imports to project symbols', async () => { + fs.mkdirSync(path.join(tempDir, 'src')); + fs.writeFileSync( + path.join(tempDir, 'src', 'helpers.gleam'), + 'pub fn println(message: String) -> Nil { Nil }\n', + ); + fs.writeFileSync( + path.join(tempDir, 'src', 'main.gleam'), + `import gleam/io.{println}\n\npub fn main() -> Nil {\n println("hello")\n Nil\n}\n`, + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const main = cg.getNodesByKind('function').find( + (n) => n.name === 'main' && n.filePath.replace(/\\/g, '/') === 'src/main.gleam', + ); + expect(main).toBeDefined(); + expect(cg.getOutgoingEdges(main!.id).filter((e) => e.kind === 'calls')).toHaveLength(0); + }, 30000); + + it('should record a file edge for a Gleam module-only import', async () => { + fs.mkdirSync(path.join(tempDir, 'src', 'app'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'app', 'lib.gleam'), + 'pub fn run() -> Nil { Nil }\n', + ); + fs.writeFileSync( + path.join(tempDir, 'src', 'main.gleam'), + 'import app/lib\n\npub fn main() -> Nil { Nil }\n', + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const mainFile = cg.getNodesByKind('file').find( + (n) => n.filePath.replace(/\\/g, '/') === 'src/main.gleam', + ); + expect(mainFile).toBeDefined(); + const importEdges = cg.getOutgoingEdges(mainFile!.id).filter((e) => e.kind === 'imports'); + expect(importEdges).toHaveLength(1); + expect(cg.getNode(importEdges[0]!.target)?.filePath.replace(/\\/g, '/')).toBe('src/app/lib.gleam'); + }, 30000); + + it('should resolve imported Gleam enum constructor calls', async () => { + fs.mkdirSync(path.join(tempDir, 'src', 'app'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'app', 'shape.gleam'), + 'pub type Shape { Circle(radius: Int) }\n', + ); + fs.writeFileSync( + path.join(tempDir, 'src', 'main.gleam'), + `import app/shape.{Circle}\n\npub fn main() -> Nil {\n Circle(1)\n Nil\n}\n`, + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const main = cg.getNodesByKind('function').find( + (n) => n.name === 'main' && n.filePath.replace(/\\/g, '/') === 'src/main.gleam', + ); + expect(main).toBeDefined(); + const calls = cg.getOutgoingEdges(main!.id).filter((e) => e.kind === 'calls'); + expect(calls).toHaveLength(1); + expect(cg.getNode(calls[0]!.target)).toMatchObject({ + kind: 'enum_member', + name: 'Circle', + filePath: 'src/app/shape.gleam', + }); + }, 30000); }); describe('JVM FQN Import Resolution', () => { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index b6c82a5e5..21fd2a36f 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 9; +export const CURRENT_SCHEMA_VERSION = 10; /** * Migration definition @@ -177,6 +177,19 @@ const migrations: Migration[] = [ ); }, }, + { + version: 10, + description: 'Persist structured metadata on unresolved references for explicit FFI targets', + up: (db) => { + const cols = db.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>; + // Some migration tests intentionally exercise a partial legacy schema. + // A real CodeGraph database always has unresolved_refs; when it is absent, + // leave the unrelated fixture alone instead of issuing ALTER TABLE on it. + if (cols.length > 0 && !cols.some((column) => column.name === 'metadata')) { + db.exec('ALTER TABLE unresolved_refs ADD COLUMN metadata TEXT'); + } + }, + }, ]; /** diff --git a/src/db/queries.ts b/src/db/queries.ts index 451c6a90f..de350ea57 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -114,6 +114,7 @@ interface UnresolvedRefRow { line: number; col: number; candidates: string | null; + metadata: string | null; file_path: string; language: string; status: string; @@ -132,6 +133,22 @@ function referenceNameTail(referenceName: string): string { return idx >= 0 ? referenceName.slice(idx + 1) : referenceName; } +/** Convert an unresolved-reference database row to its public representation. */ +function rowToUnresolvedReference(row: UnresolvedRefRow): UnresolvedReference { + return { + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + }; +} + /** * Convert database row to Node object */ @@ -2131,8 +2148,8 @@ export class QueryBuilder { insertUnresolvedRef(ref: UnresolvedReference): void { if (!this.stmts.insertUnresolved) { this.stmts.insertUnresolved = this.db.prepare(` - INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language) - VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language) + INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, metadata, file_path, language) + VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @metadata, @filePath, @language) `); } @@ -2143,6 +2160,7 @@ export class QueryBuilder { line: ref.line, col: ref.column, candidates: ref.candidates ? JSON.stringify(ref.candidates) : null, + metadata: ref.metadata ? JSON.stringify(ref.metadata) : null, filePath: ref.filePath ?? '', language: ref.language ?? 'unknown', }); @@ -2163,14 +2181,15 @@ export class QueryBuilder { ref.line, ref.column, ref.candidates ? JSON.stringify(ref.candidates) : null, + ref.metadata ? JSON.stringify(ref.metadata) : null, ref.filePath ?? '', ref.language ?? 'unknown', ]); } this.runBatched( 'insertUnresolvedRefs', - 'INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language) VALUES ', - '(?,?,?,?,?,?,?,?)', + 'INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, metadata, file_path, language) VALUES ', + '(?,?,?,?,?,?,?,?,?)', rows ); }); @@ -2199,17 +2218,7 @@ export class QueryBuilder { ); } const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[]; - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** @@ -2217,17 +2226,7 @@ export class QueryBuilder { */ getUnresolvedReferences(): UnresolvedReference[] { const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[]; - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** @@ -2265,17 +2264,7 @@ export class QueryBuilder { ); } const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[]; - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** @@ -2293,17 +2282,7 @@ export class QueryBuilder { ); } const rows = this.stmts.getUnresolvedBatchAfter.all(afterRowId, limit) as UnresolvedRefRow[]; - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** @@ -2362,17 +2341,7 @@ export class QueryBuilder { rows.push(...chunkRows); } - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** @@ -2505,6 +2474,40 @@ export class QueryBuilder { return changed; } + /** Mark terminal built-in or external refs so sync never retries them by name. */ + markReferencesExternal( + refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>, + ): number { + if (refs.length === 0) return 0; + const stmt = this.db.prepare( + "UPDATE unresolved_refs SET status = 'external', name_tail = '' WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?", + ); + let changed = 0; + const markMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + changed += stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind).changes; + } + }); + markMany(refs); + return changed; + } + + /** Row-precise counterpart of markReferencesExternal. */ + markReferencesExternalByRowIds(refs: Array<{ rowId: number }>): number { + if (refs.length === 0) return 0; + const stmt = this.db.prepare( + "UPDATE unresolved_refs SET status = 'external', name_tail = '' WHERE id = ?", + ); + let changed = 0; + const markMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + changed += stmt.run(ref.rowId).changes; + } + }); + markMany(refs); + return changed; + } + /** * Failed refs whose name tail matches one of the given symbol names — the * candidates a sync should retry after files carrying those names changed @@ -2544,17 +2547,7 @@ export class QueryBuilder { rows.push(...chunkRows); } - return rows.map((row) => ({ - fromNodeId: row.from_node_id, - referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, - line: row.line, - column: row.col, - candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, - filePath: row.file_path, - language: row.language as Language, - rowId: row.id, - })); + return rows.map(rowToUnresolvedReference); } /** diff --git a/src/db/schema.sql b/src/db/schema.sql index 6237d2d64..a93830b40 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -78,9 +78,11 @@ CREATE TABLE IF NOT EXISTS files ( -- Unresolved References: References that need resolution after full indexing. -- status lifecycle: rows are inserted 'pending' by extraction; a completed --- resolution pass either deletes a row (resolved) or marks it 'failed' +-- resolution pass either deletes a row (resolved), marks it 'failed' -- (attempted, no match — kept so a later sync can retry it when a changed --- file introduces a symbol that could satisfy it, #1240). name_tail is the +-- file introduces a symbol that could satisfy it, #1240), or marks it +-- 'external' (built-in/dependency terminal that must never be retried by name). +-- name_tail is the -- last segment of reference_name ('util.greet' → 'greet'), written when a -- row is marked failed, so the retry lookup matches new node names against -- dotted refs too. Rows follow their from_node via ON DELETE CASCADE, so @@ -93,6 +95,7 @@ CREATE TABLE IF NOT EXISTS unresolved_refs ( line INTEGER NOT NULL, col INTEGER NOT NULL, candidates TEXT, -- JSON array + metadata TEXT, -- JSON object carried from extraction to the resolved edge file_path TEXT NOT NULL DEFAULT '', language TEXT NOT NULL DEFAULT 'unknown', status TEXT NOT NULL DEFAULT 'pending', diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 07ccbb964..8ddab13b8 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 25; +export const EXTRACTION_VERSION = 26; diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d4127631d..3da450612 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -36,6 +36,7 @@ const WASM_GRAMMAR_FILES: Record = { dart: 'tree-sitter-dart.wasm', pascal: 'tree-sitter-pascal.wasm', scala: 'tree-sitter-scala.wasm', + gleam: 'tree-sitter-gleam.wasm', lua: 'tree-sitter-lua.wasm', r: 'tree-sitter-r.wasm', luau: 'tree-sitter-luau.wasm', @@ -119,6 +120,7 @@ export const EXTENSION_MAP: Record = { '.fmx': 'pascal', '.scala': 'scala', '.sc': 'scala', + '.gleam': 'gleam', '.lua': 'lua', '.luau': 'luau', '.m': 'objc', @@ -289,7 +291,7 @@ export async function initGrammars(): Promise { * the vendored wasm together. */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ - 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', + 'pascal', 'scala', 'gleam', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', // R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) + @@ -638,6 +640,7 @@ export function getLanguageDisplayName(language: Language): string { liquid: 'Liquid', pascal: 'Pascal / Delphi', scala: 'Scala', + gleam: 'Gleam', lua: 'Lua', luau: 'Luau', objc: 'Objective-C', diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 22108d1d1..f10a95bb5 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -139,6 +139,10 @@ export function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + /** * Skip files larger than this (bytes). Generated bundles, minified JS, and * vendored blobs blow the WASM heap and the worker-recycle budget for no useful @@ -169,7 +173,7 @@ const DEFAULT_IGNORE_DIRS: ReadonlySet = new Set([ '.docusaurus', 'storybook-static', '.vinxi', '.nitro', 'out-tsc', '.vercel', '.netlify', '.wrangler', // Build output (common across ecosystems) - 'dist', 'build', 'out', '.output', + 'dist', 'build', 'out', '.output', '_build', // Test / coverage 'coverage', '.nyc_output', // Python @@ -1418,6 +1422,20 @@ function resurrectRefFromDroppedEdge( const refName = e.metadata?.refName; if (typeof refName !== 'string' || refName.length === 0) return null; const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind; + const ffiMetadata = + e.metadata?.ffi === true && + e.metadata.targetLanguage === 'erlang' && + typeof e.metadata.module === 'string' && + typeof e.metadata.function === 'string' && + typeof e.metadata.arity === 'number' + ? { + ffi: true, + targetLanguage: 'erlang', + module: e.metadata.module, + function: e.metadata.function, + arity: e.metadata.arity, + } + : undefined; return { fromNodeId: e.source, referenceName: refName, @@ -1426,6 +1444,7 @@ function resurrectRefFromDroppedEdge( column: e.column ?? 0, filePath: e.sourceFilePath, language: e.sourceLanguage, + metadata: ffiMetadata, }; } @@ -1892,6 +1911,22 @@ export class ExtractionOrchestrator { if (signal?.aborted) { aborted = true; break; } if (error || content === null || stats === null) { + if (isMissingFileError(error)) { + processed++; + filesSkipped++; + if (!storeWriter) { + const incoming = this.queries.getCrossFileIncomingEdgesWithTarget(filePath); + if (incoming.length > 0) { + const resurrected = incoming + .map((edge) => resurrectRefFromDroppedEdge(edge)) + .filter((ref): ref is UnresolvedReference => ref !== null); + if (resurrected.length > 0) this.queries.insertUnresolvedRefsBatch(resurrected); + } + this.queries.deleteFile(filePath); + } + onProgress?.({ phase: 'parsing', current: processed, total }); + continue; + } processed++; filesErrored++; errors.push({ diff --git a/src/extraction/languages/erlang.ts b/src/extraction/languages/erlang.ts index 57e6d2573..e5d154cac 100644 --- a/src/extraction/languages/erlang.ts +++ b/src/extraction/languages/erlang.ts @@ -96,6 +96,17 @@ function clauseHeader(clause: SyntaxNode, source: string): string | undefined { return collapseWs(source.substring(clause.startIndex, end)) || undefined; } +const ERLANG_ARITY_PREFIX = 'erlang-arity:'; + +function clauseArity(clause: SyntaxNode): number { + const args = getChildByField(clause, 'args'); + return args?.namedChildren.filter((child) => child.type !== 'comment').length ?? 0; +} + +function arityDecorators(clauses: SyntaxNode[]): string[] { + return [...new Set(clauses.map((clause) => `${ERLANG_ARITY_PREFIX}${clauseArity(clause)}`))]; +} + function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean { const clauses = node.namedChildren.filter((c) => c.type === 'function_clause'); const first = clauses[0]; @@ -107,11 +118,19 @@ function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean { // Continuation clause: extend the existing node's span and attribute this // clause's calls to it. - if (ctx.filePath === lastFnFile && name === lastFnName && lastFnId) { + if ( + ctx.filePath === lastFnFile && + name === lastFnName && + lastFnId && + ctx.nodes.some((candidate) => candidate.id === 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; + const decorators = new Set(n.decorators ?? []); + for (const decorator of arityDecorators(clauses)) decorators.add(decorator); + n.decorators = [...decorators]; break; } } @@ -129,6 +148,7 @@ function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean { ? collapseWs(getNodeText(spec, ctx.source)).slice(0, 300) : clauseHeader(first, ctx.source), isExported: exports === 'all' || exports.has(name), + decorators: arityDecorators(clauses), }); if (!fn) return true; ctx.pushScope(fn.id); diff --git a/src/extraction/languages/gleam.ts b/src/extraction/languages/gleam.ts new file mode 100644 index 000000000..4816c7be2 --- /dev/null +++ b/src/extraction/languages/gleam.ts @@ -0,0 +1,297 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { Node } from '../../types'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { ExtractorContext, ImportInfo, LanguageExtractor } from '../tree-sitter-types'; + +/** + * Tree-sitter-gleam node summary (see vendored tree-sitter-gleam node-types.json): + * + * function fields: name, parameters, body, return_type; children: visibility_modifier + * external_function fields: name, parameters, return_type, body + * constant fields: name, value, type; children: visibility_modifier + * type_definition children: type_name (which has its own `name` field), data_constructors, visibility_modifier + * type_alias children: type_name, opacity_modifier, ...; visibility_modifier + * data_constructor fields: name (constructor_name), arguments + * import fields: module (token text e.g. "gleam/io"), alias, imports (unqualified_imports) + * unqualified_import fields: name, alias + * function_call fields: function, arguments + * + * Names for type_definition / type_alias live inside the child `type_name` node, not on the + * declaration itself — so a custom visitNode hook extracts them rather than relying on the + * generic name-field fallback. + */ + +function hasVisibilityPub(node: SyntaxNode, source: string): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'visibility_modifier') { + return getNodeText(child, source).trim() === 'pub'; + } + } + return false; +} + +function isOpaqueType(node: SyntaxNode): boolean { + return node.namedChildren.some((child) => child.type === 'opacity_modifier'); +} + +function extractTypeName(node: SyntaxNode, source: string): { name: string; positionNode: SyntaxNode } | null { + // type_definition / type_alias both have a child `type_name` whose `name` field + // is a type_identifier (or remote_type_identifier). + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_name') { + const nameField = getChildByField(child, 'name'); + if (nameField) { + return { name: getNodeText(nameField, source), positionNode: nameField }; + } + } + } + return null; +} + +const GLEAM_BUILTIN_TYPES = new Set([ + 'BitArray', + 'Bool', + 'Float', + 'Int', + 'List', + 'Nil', + 'Result', + 'String', + 'UtfCodepoint', +]); + +function emitGleamTypeRefs(typeNode: SyntaxNode, ownerId: string, ctx: ExtractorContext): void { + if (typeNode.type === 'remote_type_identifier') { + const moduleNode = getChildByField(typeNode, 'module'); + const nameNode = getChildByField(typeNode, 'name'); + if (moduleNode && nameNode) { + ctx.addUnresolvedReference({ + fromNodeId: ownerId, + referenceName: `${getNodeText(moduleNode, ctx.source)}.${getNodeText(nameNode, ctx.source)}`, + referenceKind: 'references', + line: typeNode.startPosition.row + 1, + column: typeNode.startPosition.column, + }); + } + return; + } + + if (typeNode.type === 'type_identifier') { + const name = getNodeText(typeNode, ctx.source); + if (!GLEAM_BUILTIN_TYPES.has(name)) { + ctx.addUnresolvedReference({ + fromNodeId: ownerId, + referenceName: name, + referenceKind: 'references', + line: typeNode.startPosition.row + 1, + column: typeNode.startPosition.column, + }); + } + return; + } + + for (const child of typeNode.namedChildren) { + emitGleamTypeRefs(child, ownerId, ctx); + } +} + +function visitDataConstructors( + typeDefNode: SyntaxNode, + ownerId: string, + source: string, + ctx: ExtractorContext, +): void { + const constructorsExported = hasVisibilityPub(typeDefNode, source) && !isOpaqueType(typeDefNode); + for (let i = 0; i < typeDefNode.namedChildCount; i++) { + const child = typeDefNode.namedChild(i); + if (child?.type !== 'data_constructors') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const ctor = child.namedChild(j); + if (ctor?.type !== 'data_constructor') continue; + const nameNode = getChildByField(ctor, 'name'); + if (!nameNode) continue; + ctx.createNode('enum_member', getNodeText(nameNode, source), ctor, { + signature: getNodeText(ctor, source).trim(), + isExported: constructorsExported, + }); + emitGleamTypeRefs(ctor, ownerId, ctx); + } + } +} + +function decodeExternalString(value: SyntaxNode, source: string): string | null { + const stringNode = value.namedChildren.find((child) => child.type === 'string'); + if (!stringNode) return null; + const literal = getNodeText(stringNode, source); + try { + const decoded: unknown = JSON.parse(literal); + return typeof decoded === 'string' ? decoded : null; + } catch { + return null; + } +} + +function gleamFunctionArity(node: SyntaxNode): number { + const parameters = getChildByField(node, 'parameters'); + if (!parameters) return 0; + return parameters.namedChildren.filter((child) => child.type === 'function_parameter').length; +} + +function emitErlangExternalRefs( + syntaxNode: SyntaxNode, + functionNode: Node, + ctx: ExtractorContext, +): void { + let attribute = syntaxNode.previousNamedSibling; + while (attribute?.type === 'attribute') { + const name = getChildByField(attribute, 'name'); + const argumentsNode = getChildByField(attribute, 'arguments'); + if (name && argumentsNode && getNodeText(name, ctx.source) === 'external') { + const values = argumentsNode.namedChildren.filter((child) => child.type === 'attribute_value'); + const targetLanguage = values[0] ? getNodeText(values[0], ctx.source).trim() : ''; + const module = values[1] ? decodeExternalString(values[1], ctx.source) : null; + const foreignFunction = values[2] ? decodeExternalString(values[2], ctx.source) : null; + if (targetLanguage === 'erlang' && module && foreignFunction) { + ctx.addUnresolvedReference({ + fromNodeId: functionNode.id, + referenceName: `${module}::${foreignFunction}`, + referenceKind: 'calls', + line: attribute.startPosition.row + 1, + column: attribute.startPosition.column, + metadata: { + ffi: true, + targetLanguage: 'erlang', + module, + function: foreignFunction, + arity: gleamFunctionArity(syntaxNode), + }, + }); + } + } + attribute = attribute.previousNamedSibling; + } +} + +function afterExtractGleamFunction( + syntaxNode: SyntaxNode, + functionNode: Node, + ctx: ExtractorContext, +): void { + emitErlangExternalRefs(syntaxNode, functionNode, ctx); + const parameters = getChildByField(syntaxNode, 'parameters'); + if (parameters) emitGleamTypeRefs(parameters, functionNode.id, ctx); + const returnType = getChildByField(syntaxNode, 'return_type'); + if (returnType) emitGleamTypeRefs(returnType, functionNode.id, ctx); +} + +export const gleamExtractor: LanguageExtractor = { + functionTypes: ['function', 'external_function'], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + // type_definition/type_alias are handled in `visitNode` below (name lives in nested type_name). + enumTypes: [], + typeAliasTypes: [], + importTypes: ['import'], + callTypes: ['function_call'], + variableTypes: ['constant'], + methodsAreTopLevel: false, + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'return_type', + + getSignature: (node, source) => { + const params = getChildByField(node, 'parameters'); + const ret = getChildByField(node, 'return_type'); + if (!params) return undefined; + let sig = getNodeText(params, source); + if (ret) sig += ' -> ' + getNodeText(ret, source); + return sig; + }, + + isExported: (node, source) => hasVisibilityPub(node, source), + + isConst: (node) => node.type === 'constant', + + afterExtractFunction: afterExtractGleamFunction, + + // Gleam type declarations: drill into the nested `type_name` to get the actual name, + // create a node of the right kind, then walk children to surface data_constructors. + visitNode: (node, ctx) => { + if (node.type === 'type_definition') { + const info = extractTypeName(node, ctx.source); + if (!info) return false; + const created = ctx.createNode('enum', info.name, node, { + signature: getNodeText(node, ctx.source).split('{')[0]!.trim(), + isExported: hasVisibilityPub(node, ctx.source), + }); + if (created) { + ctx.pushScope(created.id); + visitDataConstructors(node, created.id, ctx.source, ctx); + ctx.popScope(); + } + return true; + } + if (node.type === 'type_alias') { + const info = extractTypeName(node, ctx.source); + if (!info) return false; + const created = ctx.createNode('type_alias', info.name, node, { + signature: getNodeText(node, ctx.source).trim(), + isExported: hasVisibilityPub(node, ctx.source), + }); + const aliasedType = node.namedChildren.find((child) => child.type === 'type'); + if (created && aliasedType) emitGleamTypeRefs(aliasedType, created.id, ctx); + return true; + } + + // Gleam data constructors are parsed as `record` nodes rather than + // `function_call` nodes (`Circle(1)` → record{name: constructor_name}). + // Surface the constructor invocation as a normal calls reference so the + // resolver can bind it to the exported enum member. Return false so the + // generic walker still visits nested argument expressions. + if (node.type === 'record') { + const nameNode = getChildByField(node, 'name'); + if (nameNode?.type === 'constructor_name' && ctx.nodeStack.length > 0) { + const callerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (callerId) { + ctx.addUnresolvedReference({ + fromNodeId: callerId, + referenceName: getNodeText(nameNode, ctx.source), + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + return false; + } + + return false; + }, + + // `import gleam/io.{println, pretty_print as pp} as my_io` + // The hook returns the module path so the core creates ONE import node + ONE coarse + // `imports` unresolved-ref. The fine-grained mappings that make bare `println()` resolve + // cross-file are built separately by the Gleam import resolver. + extractImport: (node, source): ImportInfo | null => { + const moduleNode = getChildByField(node, 'module'); + if (!moduleNode) return null; + return { + moduleName: getNodeText(moduleNode, source).trim(), + signature: source.substring(node.startIndex, node.endIndex).trim(), + }; + }, + + // Function bodies use the core call walker, which dispatches bare-call + // extraction rather than the top-level visitNode hook. Gleam constructors + // have no `function_call` node, so expose their `record` shape here too. + extractBareCall: (node, source) => { + if (node.type !== 'record') return undefined; + const nameNode = getChildByField(node, 'name'); + return nameNode?.type === 'constructor_name' ? getNodeText(nameNode, source) : undefined; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..9832ec733 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -23,6 +23,7 @@ import { kotlinExtractor } from './kotlin'; import { dartExtractor } from './dart'; import { pascalExtractor } from './pascal'; import { scalaExtractor } from './scala'; +import { gleamExtractor } from './gleam'; import { luaExtractor } from './lua'; import { rExtractor } from './r'; import { luauExtractor } from './luau'; @@ -56,6 +57,7 @@ export const EXTRACTORS: Partial> = { dart: dartExtractor, pascal: pascalExtractor, scala: scalaExtractor, + gleam: gleamExtractor, lua: luaExtractor, r: rExtractor, luau: luauExtractor, diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 6808895e1..1acdce182 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -196,6 +196,17 @@ export interface LanguageExtractor { */ visitNode?: (node: SyntaxNode, ctx: ExtractorContext) => boolean; + /** + * Inspect a function after the generic extractor has created its symbol. + * Languages use this for relationships encoded outside the declaration node, + * such as Gleam's preceding @external attribute. + */ + afterExtractFunction?: ( + syntaxNode: SyntaxNode, + functionNode: Node, + ctx: ExtractorContext, + ) => void; + /** * Synthesize members that exist at compile time but not in the source AST, * called at the end of class extraction with the class still on the scope diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8d71d7f18..5912857e9 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1609,6 +1609,7 @@ export class TreeSitterExtractor { // present in Python `@decorator def f():` and Java/Kotlin // annotations on free functions). this.extractDecoratorsFor(node, funcNode.id); + this.extractor.afterExtractFunction?.(node, funcNode, this.makeExtractorContext()); // Push to stack and visit body this.nodeStack.push(funcNode.id); diff --git a/src/extraction/wasm/tree-sitter-gleam.wasm b/src/extraction/wasm/tree-sitter-gleam.wasm new file mode 100755 index 0000000000000000000000000000000000000000..4abc79023e876f1508abb98ba44b4016112a06ef GIT binary patch literal 409323 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≥=)8_*zzix(?8H`0E(2W+>l70xw*_U)Ol^xLe#k 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 = { ruby: ['.rb'], objc: ['.h', '.m', '.mm'], nix: ['.nix', '/default.nix'], + gleam: ['.gleam'], }; export function isNixPathImportRef(ref: UnresolvedRef): boolean { @@ -57,6 +58,68 @@ export function isNixPathImportRef(ref: UnresolvedRef): boolean { ); } +/** Whether a Gleam module path belongs to the compiler-provided standard library. */ +export function isGleamStdlibPath(importPath: string): boolean { + return importPath === 'gleam' || importPath.startsWith('gleam/'); +} + +/** Identify Gleam references bound to imports with no indexed project module. */ +export function isExternalGleamReference( + ref: UnresolvedRef, + context: ResolutionContext, +): boolean { + if (ref.language !== 'gleam') return false; + if (ref.referenceKind === 'imports') { + return resolveImportPath(ref.referenceName, ref.filePath, ref.language, context) === null; + } + return context.getImportMappings(ref.filePath, ref.language).some( + (imp) => + (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) && + resolveImportPath(imp.source, ref.filePath, ref.language, context) === null, + ); +} + +const GLEAM_PRELUDE_NAMES = new Set([ + 'True', + 'False', + 'Nil', + 'Ok', + 'Error', + 'Some', + 'None', +]); + +/** True for an unshadowed constructor supplied by Gleam's prelude. */ +export function isGleamPreludeReference( + ref: UnresolvedRef, + context: ResolutionContext, +): boolean { + if (ref.language !== 'gleam' || !GLEAM_PRELUDE_NAMES.has(ref.referenceName)) return false; + + const localConstructor = context.getNodesInFile(ref.filePath).some( + (node) => + node.language === 'gleam' && + node.kind === 'enum_member' && + node.name === ref.referenceName, + ); + if (localConstructor) return false; + + const importedConstructor = context.getImportMappings(ref.filePath, ref.language).some((imp) => { + if (imp.isNamespace || imp.localName !== ref.referenceName) return false; + const importedFile = resolveImportPath(imp.source, ref.filePath, ref.language, context); + if (!importedFile) return false; + return context.getNodesInFile(importedFile).some( + (node) => + node.language === 'gleam' && + node.kind === 'enum_member' && + node.isExported && + node.name === imp.exportedName, + ); + }); + + return !importedConstructor; +} + /** * Resolve an import path to an actual file */ @@ -160,7 +223,7 @@ function resolveImportPathUncached( } // Handle absolute/aliased imports (like @/ or src/) - const aliased = resolveAliasedImport(importPath, projectRoot, language, context); + const aliased = resolveAliasedImport(importPath, fromFile, language, context); if (aliased) return aliased; // C/C++ include directory search: when neither relative nor aliased @@ -363,6 +426,12 @@ function isExternalImport( return true; } + if (language === 'gleam') { + // Gleam's standard library and OTP bindings use the `gleam/...` namespace. + // Project modules such as `app/io` are source paths and must remain local. + return isGleamStdlibPath(importPath); + } + if (language === 'c' || language === 'cpp') { // C/C++ standard library headers — both C-style () and // C++-style (, ) forms. Checked against the import @@ -439,10 +508,11 @@ function resolveRelativeImport( */ function resolveAliasedImport( importPath: string, - projectRoot: string, + fromFile: string, language: Language, context: ResolutionContext ): string | null { + const projectRoot = context.getProjectRoot(); const extensions = EXTENSION_RESOLUTION[language] || []; const tryWithExt = (basePath: string): string | null => { for (const ext of extensions) { @@ -453,6 +523,21 @@ function resolveAliasedImport( return null; }; + // Gleam module names are package-relative and omit their source-root prefix. + // A repository may contain multiple packages, so anchor lookup at the nearest + // gleam.toml instead of assuming the repository root is the package root. + if (language === 'gleam') { + const packageRoot = gleamPackageRoot(fromFile, context); + for (const sourceDir of ['src', 'test']) { + const base = packageRoot + ? path.posix.join(packageRoot, sourceDir, importPath) + : path.posix.join(sourceDir, importPath); + const hit = tryWithExt(base); + if (hit) return hit; + } + if (packageRoot !== null) return null; + } + // 1. Project tsconfig/jsconfig paths. const aliasMap = context.getProjectAliases?.(); if (aliasMap) { @@ -496,6 +581,22 @@ function resolveAliasedImport( return tryWithExt(importPath); } +/** Find the package containing a Gleam source file, supporting monorepos. */ +function gleamPackageRoot(fromFile: string, context: ResolutionContext): string | null { + let directory = path.posix.dirname(fromFile.replace(/\\/g, '/')); + + while (directory && directory !== '.') { + if (context.fileExists(path.posix.join(directory, 'gleam.toml'))) { + return directory; + } + const parent = path.posix.dirname(directory); + if (parent === directory) break; + directory = parent; + } + + return context.fileExists('gleam.toml') ? '' : null; +} + /** * C/C++ include directory cache (keyed by project root). * Loaded once per resolver instance, shared across calls. @@ -787,6 +888,8 @@ export function extractImportMappings( mappings.push(...extractPHPImports(content)); } else if (language === 'c' || language === 'cpp') { mappings.push(...extractCppImports(content)); + } else if (language === 'gleam') { + mappings.push(...extractGleamImports(content)); } return mappings; @@ -1094,6 +1197,56 @@ function extractCppImports(content: string): ImportMapping[] { return mappings; } +/** + * Extract Gleam import mappings. + * + * Gleam imports bind the module namespace (`import app/io`), optionally under + * an alias (`as io`), and can selectively bind names from that module: + * `import app/list.{map, filter as keep, type User}`. The resolver needs both + * the coarse namespace mapping and each selective local-to-exported mapping. + */ +function extractGleamImports(content: string): ImportMapping[] { + const mappings: ImportMapping[] = []; + const importSource = content.replace(/\/\/.*$/gm, ''); + const importRegex = /^\s*import\s+([a-zA-Z0-9_/]+)(?:\s*\.\{\s*([^}]*?)\s*\})?(?:\s+as\s+(\w+))?\s*$/gm; + let match: RegExpExecArray | null; + + while ((match = importRegex.exec(importSource)) !== null) { + const source = match[1]!; + const selective = match[2]; + const alias = match[3]; + const namespaceName = alias || source.split('/').pop()!; + + mappings.push({ + localName: namespaceName, + exportedName: '*', + source, + isDefault: false, + isNamespace: true, + }); + + if (!selective) continue; + for (const rawItem of selective.split(',')) { + const item = rawItem.trim(); + if (!item) continue; + const withoutType = item.replace(/^type\s+/, '').trim(); + if (!withoutType) continue; + const aliasMatch = withoutType.match(/^([A-Za-z0-9_]+)\s+as\s+([A-Za-z0-9_]+)$/); + const exportedName = aliasMatch?.[1] ?? withoutType; + const localName = aliasMatch?.[2] ?? withoutType; + mappings.push({ + localName, + exportedName, + source, + isDefault: false, + isNamespace: false, + }); + } + } + + return mappings; +} + // Cache import mappings per file to avoid re-reading and re-parsing const importMappingCache = new Map(); @@ -1426,6 +1579,20 @@ export function resolveViaImport( return null; } + // Use cached import mappings (avoids re-reading and re-parsing per ref) + // Gleam module imports are file dependencies even when no imported symbol is + // referenced. Resolve the module path directly to its indexed file node; + // standard-library paths intentionally remain unresolved. + if (ref.language === 'gleam' && ref.referenceKind === 'imports') { + const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); + if (!resolvedPath || resolvedPath === ref.filePath) return null; + const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file'); + if (fileNode) { + return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' }; + } + return null; + } + // Use cached import mappings (avoids re-reading and re-parsing per ref) const imports = context.getImportMappings(ref.filePath, ref.language); if (imports.length === 0 && !context.readFile(ref.filePath)) { @@ -1521,7 +1688,13 @@ export function resolveViaImport( const targetNode = findExportedSymbol( resolvedPath, - { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName }, + { + isDefault: imp.isDefault, + isNamespace: imp.isNamespace, + exportedName, + memberName, + referenceKind: ref.referenceKind, + }, ref.language, context, new Set() @@ -2084,6 +2257,7 @@ function findExportedSymbol( isNamespace: boolean; exportedName: string; memberName: string | null; + referenceKind: ReferenceKind; }, language: Language, context: ResolutionContext, @@ -2101,7 +2275,7 @@ function findExportedSymbol( memo = new Map(); exportedSymbolMemos.set(context, memo); } - const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${language}`; + const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${want.referenceKind}\0${language}`; if (memo.has(key)) return memo.get(key); const result = findExportedSymbolWalk(filePath, want, language, context, visited, depth); memo.set(key, result); @@ -2117,6 +2291,7 @@ function findExportedSymbolWalk( isNamespace: boolean; exportedName: string; memberName: string | null; + referenceKind: ReferenceKind; }, language: Language, context: ResolutionContext, @@ -2129,6 +2304,20 @@ function findExportedSymbolWalk( const exportIndex = getFileExportIndex(filePath, context); + const wantedName = want.isNamespace && want.memberName + ? want.memberName + : want.exportedName; + if (language === 'gleam' && want.referenceKind === 'calls') { + const callable = context.getNodesInFile(filePath).find( + (node) => + node.isExported && + (node.kind === 'function' || node.kind === 'enum_member') && + node.name === wantedName, + ); + if (callable) return callable; + return undefined; + } + // 1. Direct hit: the symbol is declared in this file. if (want.isDefault) { // Svelte/Vue single-file components ARE the module's default export, @@ -2166,6 +2355,7 @@ function findExportedSymbolWalk( isNamespace: false, exportedName: rex.originalName, memberName: null, + referenceKind: want.referenceKind, }, language, context, diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 01f615b28..a1d3e5f68 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Language, Node, UnresolvedReference, Edge } from '../types'; +import { Language, Node, UnresolvedReference, Edge, ForeignFunctionMetadata } from '../types'; import { QueryBuilder } from '../db/queries'; import { UnresolvedRef, @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isExternalGleamReference, isGleamPreludeReference, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -48,6 +48,23 @@ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/; /** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */ const PHP_PROP_SHAPE = /^this->\w+\.\w+$/; +function gleamErlangFfiMetadata(ref: UnresolvedRef): ForeignFunctionMetadata | null { + const metadata = ref.metadata; + if ( + ref.language !== 'gleam' || + ref.referenceKind !== 'calls' || + metadata?.ffi !== true || + metadata.targetLanguage !== 'erlang' || + typeof metadata.module !== 'string' || + typeof metadata.function !== 'string' || + !Number.isInteger(metadata.arity) || + (metadata.arity as number) < 0 + ) { + return null; + } + return metadata as ForeignFunctionMetadata; +} + /** * Cache size limits. Each per-resolver cache is bounded so memory * stays flat on large codebases (20k+ files). Sizes were chosen to @@ -849,12 +866,42 @@ export class ReferenceResolver { return false; } + private resolveGleamErlangFfi( + ref: UnresolvedRef, + ffi: ForeignFunctionMetadata, + ): ResolvedRef | null { + const arityDecorator = `erlang-arity:${ffi.arity}`; + const targets = this.context + .getNodesByQualifiedName(`${ffi.module}::${ffi.function}`) + .filter( + (node) => + node.language === 'erlang' && + node.kind === 'function' && + node.decorators?.includes(arityDecorator), + ); + if (targets.length !== 1) return null; + return { + original: ref, + targetNodeId: targets[0]!.id, + confidence: 1, + resolvedBy: 'foreign-function', + }; + } + /** * Resolve a single reference */ resolveOne(ref: UnresolvedRef): ResolvedRef | null { + const ffi = gleamErlangFfiMetadata(ref); + if (ffi) { + // An explicit foreign target is exact. A miss must stay unresolved and + // must never fall through to same-named project symbols. + return this.resolveGleamErlangFfi(ref, ffi); + } + // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { + ref.disposition = 'external'; return null; } @@ -1100,6 +1147,7 @@ export class ReferenceResolver { line: ref.original.line, column: ref.original.column, metadata: { + ...(ref.original.metadata ?? {}), confidence: ref.confidence, resolvedBy: ref.resolvedBy, // The ORIGINAL reference text (and kind, when edge-kind promotion @@ -1157,21 +1205,34 @@ export class ReferenceResolver { * outcome can differ per call site (receiver-type inference reads the * ref's line), so a sibling must not inherit this row's failure (#1269). */ - private static partitionFailedCleanup(unresolved: UnresolvedRef[]): { - byRowId: Array<{ rowId: number; referenceName: string }>; - legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>; + private static partitionUnresolvedCleanup(unresolved: UnresolvedRef[]): { + failed: { + byRowId: Array<{ rowId: number; referenceName: string }>; + legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>; + }; + external: { + byRowId: Array<{ rowId: number; referenceName: string }>; + legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>; + }; } { - const byRowId: Array<{ rowId: number; referenceName: string }> = []; - const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = []; + const failed = { + byRowId: [] as Array<{ rowId: number; referenceName: string }>, + legacyKeys: [] as Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>, + }; + const external = { + byRowId: [] as Array<{ rowId: number; referenceName: string }>, + legacyKeys: [] as Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>, + }; for (const r of unresolved) { - if (r.rowId != null) byRowId.push({ rowId: r.rowId, referenceName: r.referenceName }); - else legacyKeys.push({ + const target = r.disposition === 'external' ? external : failed; + if (r.rowId != null) target.byRowId.push({ rowId: r.rowId, referenceName: r.referenceName }); + else target.legacyKeys.push({ fromNodeId: r.fromNodeId, referenceName: r.referenceName, referenceKind: r.referenceKind, }); } - return { byRowId, legacyKeys }; + return { failed, external }; } /** @@ -1208,9 +1269,11 @@ export class ReferenceResolver { // is still 'pending', so any pending row at rest belongs to an // interrupted run and the sweep can key off the pending count. if (result.unresolved.length > 0) { - const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(result.unresolved); - this.queries.markReferencesFailedByRowIds(byRowId); - this.queries.markReferencesFailed(legacyKeys); + const cleanup = ReferenceResolver.partitionUnresolvedCleanup(result.unresolved); + this.queries.markReferencesFailedByRowIds(cleanup.failed.byRowId); + this.queries.markReferencesFailed(cleanup.failed.legacyKeys); + this.queries.markReferencesExternalByRowIds(cleanup.external.byRowId); + this.queries.markReferencesExternal(cleanup.external.legacyKeys); } return result; @@ -1246,13 +1309,21 @@ export class ReferenceResolver { await maybeYield(); } - const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved); - for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) { - this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK)); + const cleanup = ReferenceResolver.partitionUnresolvedCleanup(result.unresolved); + for (let i = 0; i < cleanup.failed.byRowId.length; i += PERSIST_CHUNK) { + this.queries.markReferencesFailedByRowIds(cleanup.failed.byRowId.slice(i, i + PERSIST_CHUNK)); + await maybeYield(); + } + for (let i = 0; i < cleanup.failed.legacyKeys.length; i += PERSIST_CHUNK) { + this.queries.markReferencesFailed(cleanup.failed.legacyKeys.slice(i, i + PERSIST_CHUNK)); + await maybeYield(); + } + for (let i = 0; i < cleanup.external.byRowId.length; i += PERSIST_CHUNK) { + this.queries.markReferencesExternalByRowIds(cleanup.external.byRowId.slice(i, i + PERSIST_CHUNK)); await maybeYield(); } - for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) { - this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK)); + for (let i = 0; i < cleanup.external.legacyKeys.length; i += PERSIST_CHUNK) { + this.queries.markReferencesExternal(cleanup.external.legacyKeys.slice(i, i + PERSIST_CHUNK)); await maybeYield(); } @@ -1343,6 +1414,7 @@ export class ReferenceResolver { column: raw.column, filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId), language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), + metadata: raw.metadata, rowId: raw.rowId, }; const result = this.resolveOneTimed(ref); @@ -1463,6 +1535,7 @@ export class ReferenceResolver { column: raw.column, filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId), language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), + metadata: raw.metadata, rowId: raw.rowId, }; const result = this.resolveOneTimed(ref); @@ -1838,13 +1911,21 @@ export class ReferenceResolver { // only see pending rows) but stay retryable when a later sync adds a // symbol that could satisfy them (#1240). tLp = Date.now(); - const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved); - for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) { - removedThisBatch += this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK)); + const cleanup = ReferenceResolver.partitionUnresolvedCleanup(result.unresolved); + for (let i = 0; i < cleanup.failed.byRowId.length; i += PERSIST_CHUNK) { + removedThisBatch += this.queries.markReferencesFailedByRowIds(cleanup.failed.byRowId.slice(i, i + PERSIST_CHUNK)); await maybeYield(); } - for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) { - removedThisBatch += this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK)); + for (let i = 0; i < cleanup.failed.legacyKeys.length; i += PERSIST_CHUNK) { + removedThisBatch += this.queries.markReferencesFailed(cleanup.failed.legacyKeys.slice(i, i + PERSIST_CHUNK)); + await maybeYield(); + } + for (let i = 0; i < cleanup.external.byRowId.length; i += PERSIST_CHUNK) { + removedThisBatch += this.queries.markReferencesExternalByRowIds(cleanup.external.byRowId.slice(i, i + PERSIST_CHUNK)); + await maybeYield(); + } + for (let i = 0; i < cleanup.external.legacyKeys.length; i += PERSIST_CHUNK) { + removedThisBatch += this.queries.markReferencesExternal(cleanup.external.legacyKeys.slice(i, i + PERSIST_CHUNK)); await maybeYield(); } lp('marks', tLp); @@ -1976,6 +2057,14 @@ export class ReferenceResolver { */ private isBuiltInOrExternal(ref: UnresolvedRef): boolean { const name = ref.referenceName; + + // Gleam's `gleam/*` modules are compiler-provided and are not indexed as + // project files. Keep their bindings from falling through to global name + // matching when a project happens to define a same-named symbol. + if (isExternalGleamReference(ref, this.context) || isGleamPreludeReference(ref, this.context)) { + return true; + } + const isJsTs = ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts'; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 651051466..393a9d97f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -404,11 +404,23 @@ export function matchByExactName( // unresolved import refs each scored K same-named import candidates through // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on // large import-heavy (front-end + back-end) repos (#915). - const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) + let candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) .filter((n) => n.kind !== 'import') // Nested locals are only reachable from inside their container (#1230). .filter((n) => isLexicallyReachable(n, ref, context)); + if (ref.language === 'gleam' && ref.referenceKind === 'calls') { + candidates = candidates.filter( + (candidate) => candidate.kind !== 'enum' && candidate.kind !== 'type_alias', + ); + const localConstructors = candidates.filter( + (candidate) => candidate.kind === 'enum_member' && candidate.filePath === ref.filePath, + ); + candidates = localConstructors.length > 0 + ? localConstructors + : candidates.filter((candidate) => candidate.kind !== 'enum_member'); + } + if (candidates.length === 0) { return null; } diff --git a/src/resolution/types.ts b/src/resolution/types.ts index bc80e2fc7..5856f3005 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -26,6 +26,10 @@ export interface UnresolvedRef { language: Language; /** Possible qualified names it might resolve to */ candidates?: string[]; + /** Additive extraction metadata, including explicit foreign-function targets. */ + metadata?: Record; + /** Terminal reference supplied outside the indexed project; never retry by name. */ + disposition?: 'external'; /** `unresolved_refs.id` when loaded from the database — post-pass cleanup * targets exactly this row instead of every same-key sibling (#1269). */ rowId?: number; @@ -42,7 +46,7 @@ export interface ResolvedRef { /** Confidence score (0-1) */ confidence: number; /** How it was resolved */ - resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref'; + resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref' | 'foreign-function'; } /** diff --git a/src/types.ts b/src/types.ts index b0ebfe433..4b0719604 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,6 +100,7 @@ export const LANGUAGES = [ 'liquid', 'pascal', 'scala', + 'gleam', 'lua', 'luau', 'objc', @@ -336,6 +337,15 @@ export interface ExtractionError { */ export type ReferenceKind = EdgeKind | 'function_ref'; +/** Structured target information for an explicit foreign-function declaration. */ +export interface ForeignFunctionMetadata extends Record { + ffi: true; + targetLanguage: Language; + module: string; + function: string; + arity: number; +} + /** * A reference that couldn't be resolved during extraction */ @@ -362,6 +372,9 @@ export interface UnresolvedReference { /** Possible qualified names it might resolve to */ candidates?: string[]; + /** Additive metadata retained through persistence and copied to the resolved edge. */ + metadata?: Record; + /** * `unresolved_refs.id` when this ref was loaded from the database. Post-pass * cleanup (delete-on-resolve / park-as-failed) targets exactly this row.