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 000000000..4abc79023 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-gleam.wasm differ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a32a97916..71a87bfaa 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Language, Node } from '../types'; +import { Language, Node, ReferenceKind } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; @@ -46,6 +46,7 @@ const EXTENSION_RESOLUTION: Record = { 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.