diff --git a/gitnexus/src/core/ingestion/languages/julia.ts b/gitnexus/src/core/ingestion/languages/julia.ts index 6c22d0b4c..a66474d88 100644 --- a/gitnexus/src/core/ingestion/languages/julia.ts +++ b/gitnexus/src/core/ingestion/languages/julia.ts @@ -30,6 +30,30 @@ import { juliaVariableConfig } from '../variable-extractors/configs/julia.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { juliaCallConfig } from '../call-extractors/configs/julia.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; + +const JULIA_CALL_RESULT = { kind: 'call' } as const; +const JULIA_SKIP_RESULT = { kind: 'skip' } as const; + +function routeJuliaCall(calledName: string, callNode: SyntaxNode) { + // Skip signature-shape calls (e.g., `function run()` parsed as call_expression in signature). + if (callNode.parent?.type === 'signature') return JULIA_SKIP_RESULT; + + if (calledName === 'include') { + const args = + callNode.childForFieldName('arguments') ?? + callNode.namedChildren.find((n) => n.type === 'argument_list'); + const literal = args?.namedChildren.find((n) => n.type === 'string_literal'); + const content = literal?.namedChildren.find((n) => n.type === 'content'); + const importPath = content?.text?.trim(); + if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) { + return JULIA_SKIP_RESULT; + } + return { kind: 'import', importPath, isRelative: true } as const; + } + + return JULIA_CALL_RESULT; +} const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -97,6 +121,7 @@ export const juliaProvider = defineLanguage({ typeConfig: juliaTypeConfig, exportChecker: juliaExportChecker, importResolver: createImportResolver(juliaImportConfig), + callRouter: routeJuliaCall, importSemantics: 'wildcard-leaf', callExtractor: createCallExtractor(juliaCallConfig), fieldExtractor: createFieldExtractor(juliaFieldConfig), diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts b/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts index 3d3315fd1..f548f509e 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts @@ -2,9 +2,24 @@ // Verified against tree-sitter-julia grammar import { SupportedLanguages } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; +function getJuliaParameterList(node: SyntaxNode): SyntaxNode | undefined { + const direct = node.childForFieldName('parameters'); + if (direct) return direct; + + const signature = + node.childForFieldName('signature') ?? node.namedChildren.find((n) => n.type === 'signature'); + if (!signature) return undefined; + const headerCall = signature.namedChildren.find((n) => n.type === 'call_expression'); + if (!headerCall) return undefined; + const args = headerCall.childForFieldName('arguments'); + if (args) return args; + return headerCall.namedChildren.find((n) => n.type === 'argument_list'); +} + /** * Extract parameters from a Julia function definition. * @@ -17,7 +32,7 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; * - Keyword: `; kw=default` */ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] { - const paramList = node.childForFieldName('parameters'); + const paramList = getJuliaParameterList(node); if (!paramList) return []; const params: ParameterInfo[] = []; @@ -28,7 +43,13 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] { switch (param.type) { case 'identifier': { - params.push({ name: param.text, type: null, rawType: null, isOptional: false, isVariadic: false }); + params.push({ + name: param.text, + type: null, + rawType: null, + isOptional: false, + isVariadic: false, + }); break; } case 'typed_parameter': { @@ -62,7 +83,13 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] { case 'splat_parameter': { const nameNode = param.firstNamedChild; if (nameNode) { - params.push({ name: nameNode.text, type: null, rawType: null, isOptional: false, isVariadic: true }); + params.push({ + name: nameNode.text, + type: null, + rawType: null, + isOptional: false, + isVariadic: true, + }); } break; } @@ -78,18 +105,56 @@ function extractJuliaReturnType(node: SyntaxNode): string | undefined { return retType?.text?.trim(); } +/** + * Extract function name from a Julia function node during parent-walk. + * + * Julia function_definition stores the name inside signature → call_expression: + * function run(x) ... end + * → (function_definition (signature (call_expression (identifier "run") ...)) ...) + * + * Short-form assignment functions are handled separately (assignment is not in + * FUNCTION_NODE_TYPES, so they don't appear in the parent-walk). + */ +function juliaExtractFunctionName( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null { + if (node.type !== 'function_definition' && node.type !== 'macro_definition') return null; + + // Navigate: function_definition → signature (first named child) → call_expression → identifier + const sig = node.namedChild(0); + if (sig?.type !== 'signature') return { funcName: null, label: 'Function' }; + + const callExpr = sig.namedChild(0); + if (callExpr?.type !== 'call_expression') return { funcName: null, label: 'Function' }; + + // The function name is the first child of the call_expression (an identifier) + const nameNode = callExpr.child(0); + const funcName = nameNode?.type === 'identifier' ? nameNode.text : null; + return { funcName, label: 'Function' }; +} + export const juliaMethodConfig: MethodExtractionConfig = { language: SupportedLanguages.Julia, // Julia methods are defined outside structs — no struct body method extraction typeDeclarationNodes: ['struct_definition'], - methodNodeTypes: ['function_definition', 'short_function_definition'], + methodNodeTypes: ['function_definition'], bodyNodeTypes: ['field_declaration_list'], extractName(node) { - const nameNode = node.childForFieldName('name'); - return nameNode?.text; + // Navigate: function_definition → signature → call_expression → identifier + const sig = node.namedChild(0); + if (sig?.type === 'signature') { + const callExpr = sig.namedChild(0); + if (callExpr?.type === 'call_expression') { + const nameNode = callExpr.child(0); + if (nameNode?.type === 'identifier') return nameNode.text; + } + } + return undefined; }, + extractFunctionName: juliaExtractFunctionName, + extractReturnType: extractJuliaReturnType, extractParameters: extractJuliaParameters, diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 7483e2d11..0d5bd6254 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1512,59 +1512,81 @@ export const DART_QUERIES = ` (type_identifier) @heritage.trait))) @heritage `; -// Julia queries - works with tree-sitter-julia +// Julia queries — verified against tree-sitter-julia grammar. +// +// Key structural facts (no named fields for struct/abstract type children): +// struct Foo <: Bar → (struct_definition (type_head (binary_expression (identifier "Foo") ...))) +// struct Simple → (struct_definition (type_head (identifier "Simple"))) +// abstract type A <: B → (abstract_definition (type_head (binary_expression ...))) +// module M → (module_definition name: (identifier "M")) ← has named field +// function f(x) → (function_definition (signature (call_expression . (identifier "f") ...))) +// f(x) = expr → (assignment . (call_expression . (identifier "f") ...) ...) +// macro @m(x) → (macro_definition (signature (call_expression . (identifier "m") ...))) export const JULIA_QUERIES = ` ; ── Structs (class-like types) ─────────────────────────────────────────────── +; Simple struct (no supertype): (type_head (identifier)) (struct_definition - name: (identifier) @name) @definition.class + (type_head (identifier) @name)) @definition.class + +; Struct with supertype: (type_head (binary_expression . identifier <: identifier)) +(struct_definition + (type_head (binary_expression . (identifier) @name))) @definition.class ; ── Abstract types ──────────────────────────────────────────────────────────── (abstract_definition - name: (identifier) @name) @definition.class + (type_head (identifier) @name)) @definition.class + +(abstract_definition + (type_head (binary_expression . (identifier) @name))) @definition.class ; ── Modules ─────────────────────────────────────────────────────────────────── (module_definition name: (identifier) @name) @definition.module ; ── Function definitions ────────────────────────────────────────────────────── +; Full form: function f(args...) ... end (function_definition - name: (identifier) @name) @definition.function + (signature (call_expression . (identifier) @name))) @definition.function -; ── Short-form function definitions: f(x) = expr ───────────────────────────── -(short_function_definition - name: (identifier) @name) @definition.function +; Short form: f(args...) = expr +; The leading . anchors call_expression as the first child (LHS), preventing +; false matches on the RHS call expression in the assignment. +(assignment . + (call_expression . (identifier) @name)) @definition.function ; ── Macro definitions ───────────────────────────────────────────────────────── (macro_definition - name: (identifier) @name) @definition.function + (signature (call_expression . (identifier) @name))) @definition.function ; ── Import statements ───────────────────────────────────────────────────────── -(import_statement) @import +(import_statement (identifier) @import.source) @import -(using_statement) @import +(using_statement (identifier) @import.source) @import ; ── Call expressions ────────────────────────────────────────────────────────── -(call_expression - (identifier) @call.name) @call +; Free calls: f(args...) +(call_expression . (identifier) @call.name) @call +; Member calls: obj.method(args...) — (field_expression value: receiver (identifier method)) (call_expression - (field_expression - (identifier) @call.name)) @call + (field_expression value: (_) (identifier) @call.name)) @call -; ── Heritage: struct Foo <: Bar ─────────────────────────────────────────────── +; ── Heritage: struct/abstract Foo <: Bar ────────────────────────────────────── (struct_definition - name: (identifier) @heritage.class - supertype: (type_clause - (identifier) @heritage.extends)) @heritage + (type_head + (binary_expression + (identifier) @heritage.class + (identifier) @heritage.extends))) @heritage (abstract_definition - name: (identifier) @heritage.class - supertype: (type_clause - (identifier) @heritage.extends)) @heritage + (type_head + (binary_expression + (identifier) @heritage.class + (identifier) @heritage.extends))) @heritage -; ── Variable assignments at module scope ────────────────────────────────────── -(assignment - left: (identifier) @name) @definition.variable +; ── Variable assignments ────────────────────────────────────────────────────── +; Simple assignment: x = expr (LHS is a plain identifier, not a call) +(assignment . (identifier) @name) @definition.variable `; import { SupportedLanguages } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/type-extractors/julia.ts b/gitnexus/src/core/ingestion/type-extractors/julia.ts index 7f12d7f06..aff5d2a62 100644 --- a/gitnexus/src/core/ingestion/type-extractors/julia.ts +++ b/gitnexus/src/core/ingestion/type-extractors/julia.ts @@ -25,14 +25,31 @@ const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'assignment', ]); +function getJuliaParameterList(node: SyntaxNode): SyntaxNode | undefined { + const direct = node.childForFieldName('parameters'); + if (direct) return direct; + + const signature = + node.childForFieldName('signature') ?? node.namedChildren.find((n) => n.type === 'signature'); + if (!signature) return undefined; + const headerCall = signature.namedChildren.find((n) => n.type === 'call_expression'); + if (!headerCall) return undefined; + const args = headerCall.childForFieldName('arguments'); + if (args) return args; + return headerCall.namedChildren.find((n) => n.type === 'argument_list'); +} + /** * Extract type annotations from function parameter lists into env. * Julia: `function foo(x::Int, y::String)` → env.set("x", "Int") */ -const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { +const extractDeclaration: TypeBindingExtractor = ( + node: SyntaxNode, + env: Map, +): void => { if (node.type !== 'function_definition' && node.type !== 'short_function_definition') return; - const paramList = node.childForFieldName('parameters'); + const paramList = getJuliaParameterList(node); if (!paramList) return; for (let i = 0; i < paramList.namedChildCount; i++) { @@ -51,7 +68,10 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { +const extractParameter: ParameterExtractor = ( + _node: SyntaxNode, + _env: Map, +): void => { // Parameter types are handled by extractDeclaration above }; diff --git a/gitnexus/test/fixtures/lang-resolution/julia-calls/app.jl b/gitnexus/test/fixtures/lang-resolution/julia-calls/app.jl new file mode 100644 index 000000000..5df634541 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-calls/app.jl @@ -0,0 +1,7 @@ +function write_audit(x) + return x +end + +function run() + write_audit(42) +end diff --git a/gitnexus/test/fixtures/lang-resolution/julia-export-visibility/app.jl b/gitnexus/test/fixtures/lang-resolution/julia-export-visibility/app.jl new file mode 100644 index 000000000..336032772 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-export-visibility/app.jl @@ -0,0 +1,7 @@ +function _internal() + return 1 +end + +function public_api() + return _internal() +end diff --git a/gitnexus/test/fixtures/lang-resolution/julia-heritage/types.jl b/gitnexus/test/fixtures/lang-resolution/julia-heritage/types.jl new file mode 100644 index 000000000..166446a96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-heritage/types.jl @@ -0,0 +1,15 @@ +abstract type AbstractAnimal end + +abstract type Animal <: AbstractAnimal end + +struct Dog <: Animal + name::String +end + +struct Cat <: Animal + name::String +end + +function speak(a::Animal) + println("...") +end diff --git a/gitnexus/test/fixtures/lang-resolution/julia-include-import/app.jl b/gitnexus/test/fixtures/lang-resolution/julia-include-import/app.jl new file mode 100644 index 000000000..abc4fd61e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-include-import/app.jl @@ -0,0 +1,5 @@ +include("utils.jl") + +function run() + write_audit(42) +end diff --git a/gitnexus/test/fixtures/lang-resolution/julia-include-import/utils.jl b/gitnexus/test/fixtures/lang-resolution/julia-include-import/utils.jl new file mode 100644 index 000000000..7ded9e7ad --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-include-import/utils.jl @@ -0,0 +1,3 @@ +function write_audit(x) + return x +end diff --git a/gitnexus/test/fixtures/lang-resolution/julia-typed-params/app.jl b/gitnexus/test/fixtures/lang-resolution/julia-typed-params/app.jl new file mode 100644 index 000000000..e9dec8d10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-typed-params/app.jl @@ -0,0 +1,7 @@ +struct User + id::Int +end + +function process(user::User, limit::Int) + return limit +end diff --git a/gitnexus/test/integration/resolvers/julia-debug.test.ts b/gitnexus/test/integration/resolvers/julia-debug.test.ts new file mode 100644 index 000000000..b4b2e7cc4 --- /dev/null +++ b/gitnexus/test/integration/resolvers/julia-debug.test.ts @@ -0,0 +1,26 @@ +import { describe, it, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('Julia debug', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-calls'), () => {}); + }, 60000); + + it('shows all edges', () => { + const calls = getRelationships(result, 'CALLS'); + console.log( + 'CALLS:', + calls.map((c) => `${c.source} → ${c.target}`), + ); + const fns = getNodesByLabel(result, 'Function'); + console.log('Functions:', fns); + }); +}); diff --git a/gitnexus/test/integration/resolvers/julia.test.ts b/gitnexus/test/integration/resolvers/julia.test.ts new file mode 100644 index 000000000..6154da83a --- /dev/null +++ b/gitnexus/test/integration/resolvers/julia.test.ts @@ -0,0 +1,175 @@ +/** + * Julia: function definitions, struct/abstract type detection, heritage (<:), + * and function call resolution. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + getNodesByLabelFull, + edgeSet, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Function definitions and call resolution (single file) +// --------------------------------------------------------------------------- + +describe('Julia function detection and call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-calls'), () => {}); + }, 60000); + + it('detects both functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('write_audit'); + expect(fns).toContain('run'); + }); + + it('emits CALLS edge: run → write_audit', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'run' && c.target === 'write_audit'); + expect(edge).toBeDefined(); + }); + + it('does not emit signature self-call edges', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'run' && c.target === 'run')).toBeUndefined(); + expect( + calls.find((c) => c.source === 'write_audit' && c.target === 'write_audit'), + ).toBeUndefined(); + }); + + it('does not emit spurious CALLS edges from built-in calls', () => { + const calls = getRelationships(result, 'CALLS'); + const builtinCalls = calls.filter((c) => c.target === 'println'); + expect(builtinCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Struct and abstract type detection + heritage (<:) +// --------------------------------------------------------------------------- + +describe('Julia struct and abstract type detection with heritage', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-heritage'), () => {}); + }, 60000); + + it('detects all class-like types (structs and abstract types)', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('AbstractAnimal'); + expect(classes).toContain('Animal'); + expect(classes).toContain('Dog'); + expect(classes).toContain('Cat'); + }); + + it('detects the speak function', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('speak'); + }); + + it('emits EXTENDS edge: Animal <: AbstractAnimal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Animal' && e.target === 'AbstractAnimal'); + expect(edge).toBeDefined(); + }); + + it('emits EXTENDS edge: Dog <: Animal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); + expect(edge).toBeDefined(); + }); + + it('emits EXTENDS edge: Cat <: Animal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Cat' && e.target === 'Animal'); + expect(edge).toBeDefined(); + }); + + it('emits exactly 3 EXTENDS edges', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_).sort()).toEqual([ + 'Animal → AbstractAnimal', + 'Cat → Animal', + 'Dog → Animal', + ]); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of getRelationships(result, 'EXTENDS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + } + }); +}); + +describe('Julia include() import routing', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-include-import'), () => {}); + }, 60000); + + it('emits IMPORTS edge: app.jl → utils.jl from include()', () => { + const imports = getRelationships(result, 'IMPORTS'); + const includeEdge = imports.find( + (e) => e.sourceFilePath === 'app.jl' && e.targetFilePath === 'utils.jl', + ); + expect(includeEdge).toBeDefined(); + }); + + it('resolves run → write_audit across include target', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find( + (c) => c.source === 'run' && c.target === 'write_audit' && c.targetFilePath === 'utils.jl', + ); + expect(edge).toBeDefined(); + }); +}); + +describe('Julia typed parameter extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-typed-params'), () => {}); + }, 60000); + + it('extracts parameter metadata for process(user::User, limit::Int)', () => { + const fns = getNodesByLabelFull(result, 'Function'); + const process = fns.find((n) => n.name === 'process'); + expect(process).toBeDefined(); + expect(process?.properties.parameterCount).toBe(2); + expect(process?.properties.requiredParameterCount).toBe(2); + expect(process?.properties.parameterTypes).toEqual(['User', 'Int']); + }); +}); + +describe('Julia export visibility in top-level scripts', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-export-visibility'), () => {}); + }, 60000); + + it('marks underscore-prefixed function as internal', () => { + const fns = getNodesByLabelFull(result, 'Function'); + const internal = fns.find((n) => n.name === '_internal'); + expect(internal).toBeDefined(); + expect(internal?.properties.isExported).toBe(false); + }); + + it('marks non-underscore function as exported', () => { + const fns = getNodesByLabelFull(result, 'Function'); + const api = fns.find((n) => n.name === 'public_api'); + expect(api).toBeDefined(); + expect(api?.properties.isExported).toBe(true); + }); +});