diff --git a/.gitignore b/.gitignore index 692aa1e766..9853c332e7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,9 @@ docs/plans/ gitnexus/test/fixtures/mini-repo/*.md gitnexus/test/fixtures/mini-repo/.claude -gitnexus/test/fixtures/mini-repo/.gitignore \ No newline at end of file +gitnexus/test/fixtures/mini-repo/.gitignore + +# Ignore csharp generated obj and bin folders +gitnexus/test/fixtures/lang-resolution/**/obj +gitnexus/test/fixtures/lang-resolution/**/bin +GitNexus.sln \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f09ad8c1ac..afd54fc3ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1927 symbols, 4372 relationships, 143 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -97,25 +97,5 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (184 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Cli area (71 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Workers area (58 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Wiki area (50 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Kuzu area (45 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | -| Work in the Components area (40 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Embeddings area (32 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Local area (31 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Mcp area (31 symbols) | `.claude/skills/generated/mcp/SKILL.md` | -| Work in the Services area (25 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Resolvers area (15 symbols) | `.claude/skills/generated/resolvers/SKILL.md` | -| Work in the Eval area (15 symbols) | `.claude/skills/generated/eval/SKILL.md` | -| Work in the Llm area (14 symbols) | `.claude/skills/generated/llm/SKILL.md` | -| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | -| Work in the Bridge area (13 symbols) | `.claude/skills/generated/bridge/SKILL.md` | -| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | -| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | -| Work in the Server area (9 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Type-extractors area (8 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | -| Work in the Unit area (7 symbols) | `.claude/skills/generated/unit/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index f09ad8c1ac..afd54fc3ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1927 symbols, 4372 relationships, 143 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -97,25 +97,5 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (184 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Cli area (71 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Workers area (58 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Wiki area (50 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Kuzu area (45 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | -| Work in the Components area (40 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Embeddings area (32 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Local area (31 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Mcp area (31 symbols) | `.claude/skills/generated/mcp/SKILL.md` | -| Work in the Services area (25 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Resolvers area (15 symbols) | `.claude/skills/generated/resolvers/SKILL.md` | -| Work in the Eval area (15 symbols) | `.claude/skills/generated/eval/SKILL.md` | -| Work in the Llm area (14 symbols) | `.claude/skills/generated/llm/SKILL.md` | -| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | -| Work in the Bridge area (13 symbols) | `.claude/skills/generated/bridge/SKILL.md` | -| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | -| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | -| Work in the Server area (9 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Type-extractors area (8 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | -| Work in the Unit area (7 symbols) | `.claude/skills/generated/unit/SKILL.md` | diff --git a/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md b/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md deleted file mode 100644 index 68c73729ef..0000000000 --- a/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md +++ /dev/null @@ -1,259 +0,0 @@ ---- -title: "feat: Close type resolution gaps across all 12 languages" -type: feat -status: active -date: 2026-03-14 ---- - -# Close Type Resolution Gaps Across All 12 Languages - -## Overview - -The type resolution system (TypeEnv + call-processor) has been hardened through 6 rounds of review on `feat/type-resolution-constructor-inference`. This plan closes the remaining gaps identified by the comprehensive 4-agent gap analysis covering all 12 supported languages. - -Every task MUST include per-language integration tests following the established pattern: -- Fixture directory: `test/fixtures/lang-resolution/-/` -- Integration test: `test/integration/resolvers/.test.ts` -- Unit tests: `test/unit/type-env.test.ts` where applicable - -## Problem Statement - -The current TypeEnv extracts types from explicit annotations (Tier 0) and constructor calls (Tier 1). Many common patterns across all languages produce no type binding, causing receiver-type resolution to fail silently. The gap analysis identified 14 categories of missing patterns affecting call resolution accuracy. - -## Implementation Phases - -### Phase 1: Quick Wins (Independent — can run in parallel) - -Each task is self-contained. No dependencies between them. - -#### Task 1.1: Python walrus operator `:=` -- **File**: `src/core/ingestion/type-extractors/python.ts` -- **Change**: Add `named_expression` to `DECLARATION_NODE_TYPES` -- **Why**: `if (user := get_user()):` is common Python 3.8+ pattern; `user` gets no type binding -- **Pattern**: `named_expression` has `name` (identifier) and `value` (call) children -- **Tests**: - - Unit: `type-env.test.ts` — walrus with call, walrus with annotated type - - Integration fixture: `test/fixtures/lang-resolution/python-walrus-operator/` - - Integration test: `python.test.ts` — `user := User(); user.save()` resolves - -#### Task 1.2: PHP typed class properties -- **File**: `src/core/ingestion/type-extractors/php.ts` -- **Change**: Add `property_declaration` to `DECLARATION_NODE_TYPES`, implement `extractDeclaration` for it -- **Why**: `private User $repo;` is the primary PHP 7.4+ property declaration pattern -- **Pattern**: `property_declaration` has `type` (named_type) and child `property_element` with `name` (variable_name) -- **Tests**: - - Unit: `type-env.test.ts` — typed property extraction - - Integration fixture: `test/fixtures/lang-resolution/php-typed-properties/` - - Integration test: `php.test.ts` — `private UserRepo $repo; $repo->save()` resolves - -#### Task 1.3: Nullable receiver unwrapping -- **File**: `src/core/ingestion/utils.ts` — `extractReceiverName` function (~line 800) -- **Change**: Before returning `undefined` for non-simple receivers, check if the receiver node wraps a simple identifier with an optional chain operator -- **Patterns to handle**: - - TS/JS: `optional_chain_expression` wrapping `member_expression` - - Kotlin: `safe_navigation_expression` - - C#: `conditional_access_expression` - - Swift: `optional_chaining_expression` -- **Approach**: Add the wrapped expression types to `SIMPLE_RECEIVER_TYPES` or unwrap one level before checking -- **Tests**: - - Integration fixtures per language: `ts-nullable-receiver/`, `kotlin-nullable-receiver/`, etc. - - Integration tests: `user?.save()` resolves same as `user.save()` - -#### Task 1.4: Go `make()` builtin -- **File**: `src/core/ingestion/type-extractors/go.ts` -- **Change**: In `extractGoShortVarDeclaration`, add a `call_expression` branch for `make` (similar to existing `new` branch) -- **Pattern**: `make([]User, 0)` — first arg is `slice_type`/`map_type` with element type -- **Approach**: Extract element type from the first argument's type node -- **Tests**: - - Unit: `type-env.test.ts` — `make([]User, 0)`, `make(map[string]User)` - - Integration fixture: `test/fixtures/lang-resolution/go-make-builtin/` - - Integration test: `go.test.ts` - -#### Task 1.5: Go type assertions -- **File**: `src/core/ingestion/type-extractors/go.ts` -- **Change**: Handle `type_assertion_expression` in short var declarations -- **Pattern**: `user, ok := iface.(User)` — `type_assertion_expression` has a `type` field -- **Tests**: - - Unit: `type-env.test.ts` — type assertion single and comma-ok forms - - Integration fixture: `test/fixtures/lang-resolution/go-type-assertion/` - - Integration test: `go.test.ts` - ---- - -### Phase 2: Medium Effort (Some dependencies noted) - -#### Task 2.1: C++ range-for loop variables -- **File**: `src/core/ingestion/type-extractors/c-cpp.ts` -- **Change**: Add `for_range_loop` to `DECLARATION_NODE_TYPES`, extract the type from the declaration part -- **Pattern**: `for (auto& user : users)` — the `for_range_loop` has a `type` and `declarator` child -- **Note**: When type is `auto`, would need collection element type inference (deferred to Phase 3). For explicit types `for (User& u : users)` this works now. -- **Tests**: - - Unit: explicit type in range-for - - Integration fixture: `test/fixtures/lang-resolution/cpp-range-for/` - - Integration test: `cpp.test.ts` - -#### Task 2.2: Rust `if let` / `while let` bindings -- **File**: `src/core/ingestion/type-extractors/rust.ts` -- **Change**: Add `if_let_expression` and `while_let_expression` to `DECLARATION_NODE_TYPES` or handle in `extractDeclaration` -- **Pattern**: `if let Some(user) = opt { user.save() }` — pattern binding inside conditional -- **Approach**: Extract the pattern variable and the matched type from the source expression -- **Note**: Full generic unwrapping (Some → User) is Phase 3. Initial version extracts the binding variable with no type — still useful for scope tracking. -- **Tests**: - - Unit: if-let with annotated type, while-let - - Integration fixture: `test/fixtures/lang-resolution/rust-if-let/` - - Integration test: `rust.test.ts` - -#### Task 2.3: Swift `guard let` / `if let` bindings -- **File**: `src/core/ingestion/type-extractors/swift.ts` -- **Change**: Add `guard_statement` and `if_statement` with `optional_binding_condition` to declaration handling -- **Pattern**: `guard let user = fetchUser() else { return }` — `optional_binding_condition` has `pattern` and `value` -- **Note**: Swift parser availability varies (Node 22 issue). Tests must use `describe.skipIf(!swiftAvailable)`. -- **Tests**: - - Integration fixture: `test/fixtures/lang-resolution/swift-guard-let/` - - Integration test: `swift.test.ts` with skipIf guard - -#### Task 2.4: C# pattern matching `is Type variable` -- **File**: `src/core/ingestion/type-extractors/csharp.ts` -- **Change**: Handle `is_pattern_expression` or `declaration_pattern` in the type extractor -- **Pattern**: `if (obj is User user) { user.Save(); }` — `declaration_pattern` has `type` and `name` -- **Tests**: - - Unit: `type-env.test.ts` — is-pattern with type - - Integration fixture: `test/fixtures/lang-resolution/csharp-pattern-matching/` - - Integration test: `csharp.test.ts` - -#### Task 2.5: Python class-level type annotations -- **File**: `src/core/ingestion/type-extractors/python.ts` -- **Change**: Ensure `assignment` declarationNodeTypes also captures class body assignments with type annotations -- **Pattern**: `class User: name: str = "default"` — tree-sitter may use `expression_statement` > `assignment` inside class body -- **Note**: Check if this already works via existing `assignment` handling — may just need scope key fix -- **Tests**: - - Unit: class-level annotation - - Integration fixture: `test/fixtures/lang-resolution/python-class-annotations/` - ---- - -### Phase 3: Architecture Changes (Sequential — requires design decisions) - -#### Task 3.1: Return type inference -- **Files**: `src/core/ingestion/type-env.ts`, `src/core/ingestion/call-processor.ts` -- **Change**: When processing calls, look up the callee's return type from SymbolTable and bind the assignment target -- **Approach**: - 1. `extractMethodSignature` already stores `returnType` in symbol metadata - 2. In `buildTypeEnv` or as a post-processing step, for assignments like `let x = foo()`, look up `foo` in SymbolTable - 3. If `foo.returnType` exists, add binding `x → returnType` - 4. Must strip nullable wrappers and generic containers to get base type -- **Risk**: Circular dependencies if two functions return each other's types. Mitigate with depth limit. -- **Tests**: Per-language integration tests for return-type-inferred receiver resolution - -#### Task 3.2: Chained property access resolution -- **Files**: `src/core/ingestion/utils.ts` (`extractReceiverName`), `src/core/ingestion/call-processor.ts` -- **Change**: When receiver is `this.repo`, resolve `this` → class type, then look up `repo` property type on that class -- **Approach**: - 1. Extend `extractReceiverName` to return structured data: `{ chain: ['this', 'repo'] }` for multi-level access - 2. In call-processor, resolve chain iteratively: lookup first element, find property type, continue - 3. Requires property-type tracking: store class property types in TypeEnv or SymbolTable -- **Risk**: Performance impact from recursive lookups. Limit chain depth to 3. -- **Tests**: Per-language `this.repo.save()` / `self.db.query()` patterns - -#### Task 3.3: For-loop variable typing -- **Files**: Per-language type extractors + `src/core/ingestion/type-env.ts` -- **Change**: Extract loop variable type from explicit annotations or inferred from collection type -- **Patterns per language**: - - TS/JS: `for (const x of collection)` — `for_in_statement` with `left` and `right` - - Java/C#: `for (Type x : collection)` — explicit type already works - - Go: `for _, v := range slice` — `range_clause` with identifier - - Python: `for x in collection` — `for_statement` with `left` - - Rust: `for x in iter` — `for_expression` with `pattern` -- **Depends on**: Task 3.1 (return type inference) for inferring collection element types -- **Tests**: Per-language integration tests - -#### Task 3.4: Generic type parameter extraction -- **File**: `src/core/ingestion/type-extractors/shared.ts` — `extractSimpleTypeName` -- **Change**: Add optional `extractGenericArgs` mode that returns type parameters alongside base type -- **Current**: `List` → `List` (base only) -- **New**: `List` → `{ base: 'List', args: ['User'] }` when requested -- **Use case**: For-loop element type inference, collection method resolution -- **Tests**: Unit tests for each language's generic syntax - -#### Task 3.5: Block-level type narrowing -- **File**: `src/core/ingestion/type-env.ts` — scope handling -- **Change**: Add sub-function scope support for if/match/guard blocks -- **Current**: Scope keys are `funcName@startIndex` or `''` (file) -- **New**: Add block scope keys like `funcName@startIndex#if@lineN` -- **Risk**: Scope lookup complexity increases. Need to walk up scope chain on miss. -- **Tests**: Per-language pattern matching, instanceof, type guards - -#### Task 3.6: Ruby dedicated type extractor -- **File**: New `src/core/ingestion/type-extractors/ruby.ts` -- **Change**: Replace the stub in `index.ts` with a real type extractor -- **Features**: - - YARD annotation parsing (`@param name [Type]`, `@return [Type]`) - - Instance variable type inference from constructor assignments - - Block parameter typing heuristics -- **Tests**: Integration fixture: `test/fixtures/lang-resolution/ruby-typed-methods/` - ---- - -## Acceptance Criteria - -### Functional Requirements -- [ ] Each Phase 1 task has unit tests AND per-language integration tests with fixtures -- [ ] Each Phase 2 task has per-language integration tests with fixtures -- [ ] Each Phase 3 task has cross-language integration tests -- [ ] All existing 101 unit tests continue to pass -- [ ] All existing integration tests continue to pass -- [ ] No regressions in existing call resolution - -### Quality Gates -- [ ] `npx vitest run test/unit/type-env.test.ts` — all pass -- [ ] `npx vitest run test/integration/resolvers/ --no-file-parallelism` — all pass -- [ ] No new TypeScript compilation errors - -### Testing Pattern (MANDATORY for every task) -``` -1. Create fixture: test/fixtures/lang-resolution/-/ - - Source file with the pattern (e.g., main.py, models.py) - - Class/struct definitions with methods to resolve against -2. Add describe block: test/integration/resolvers/.test.ts - - beforeAll: runPipelineFromRepo(fixture_path) - - Tests: verify CALLS edges resolve to correct file/method -3. Add unit tests: test/unit/type-env.test.ts (where applicable) - - Parse code snippet, verify TypeEnv bindings -``` - -## Task Dependency Graph - -``` -Phase 1 (all parallel, no dependencies): - 1.1 Python walrus ─┐ - 1.2 PHP properties ├─ All independent - 1.3 Nullable unwrap ├─ Can run as parallel swarm agents - 1.4 Go make() │ - 1.5 Go type assert ─┘ - -Phase 2 (mostly parallel): - 2.1 C++ range-for ─┐ - 2.2 Rust if-let ├─ Independent of each other - 2.3 Swift guard-let ├─ Can run as parallel swarm agents - 2.4 C# pattern match │ - 2.5 Python class ann ─┘ - -Phase 3 (sequential dependencies): - 3.1 Return type inference ──→ 3.3 For-loop typing - ↑ - 3.4 Generic extraction ────────────┘ - - 3.2 Chained property access (independent) - 3.5 Block-level scoping (independent) - 3.6 Ruby type extractor (independent) -``` - -## Sources & References - -- PR #274: `feat/type-resolution-constructor-inference` — 6 rounds of review fixes -- Existing patterns: `test/fixtures/lang-resolution/` (150 fixture dirs) -- Integration tests: `test/integration/resolvers/*.test.ts` (12 languages) -- Type extractors: `src/core/ingestion/type-extractors/*.ts` (9 files + index) -- TypeEnv: `src/core/ingestion/type-env.ts` -- Call processor: `src/core/ingestion/call-processor.ts` -- Receiver extraction: `src/core/ingestion/utils.ts:extractReceiverName` diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index a06dc8f6ac..e76b6d4750 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -35,6 +35,7 @@ "tree-sitter-python": "^0.21.0", "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "^0.21.0", + "tree-sitter-swift": "^0.6.0", "tree-sitter-typescript": "^0.21.0", "uuid": "^13.0.0" }, diff --git a/gitnexus/package.json b/gitnexus/package.json index 3c113cfc69..5897fb865f 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -69,7 +69,6 @@ "tree-sitter-go": "^0.21.0", "tree-sitter-java": "^0.21.0", "tree-sitter-javascript": "^0.21.0", - "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", "tree-sitter-ruby": "^0.23.1", diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 12077f6b93..1e3925163f 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -20,6 +20,7 @@ import { findEnclosingClassId, } from './utils.js'; import { buildTypeEnv } from './type-env.js'; +import type { ConstructorBinding } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { callRouters } from './call-routing.js'; @@ -54,6 +55,65 @@ const findEnclosingFunction = ( return null; }; +/** + * Verify constructor bindings against SymbolTable and infer receiver types. + * Shared between sequential (processCalls) and worker (processCallsFromExtracted) paths. + */ +const verifyConstructorBindings = ( + bindings: readonly ConstructorBinding[], + filePath: string, + ctx: ResolutionContext, + graph?: KnowledgeGraph, +): Map => { + const verified = new Map(); + + for (const { scope, varName, calleeName, receiverClassName } of bindings) { + const tiered = ctx.resolve(calleeName, filePath); + const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false; + + if (isClass) { + verified.set(receiverKey(extractFuncNameFromScope(scope), varName), calleeName); + } else { + let callableDefs = tiered?.candidates.filter(d => + d.type === 'Function' || d.type === 'Method' + ); + + // When receiver class is known (e.g. $this->method() in PHP), narrow + // candidates to methods owned by that class to avoid false disambiguation failures. + if (callableDefs && callableDefs.length > 1 && receiverClassName) { + if (graph) { + // Worker path: use graph.getNode (fast, already in-memory) + const narrowed = callableDefs.filter(d => { + if (!d.ownerId) return false; + const owner = graph.getNode(d.ownerId); + return owner?.properties.name === receiverClassName; + }); + if (narrowed.length > 0) callableDefs = narrowed; + } else { + // Sequential path: use ctx.resolve (no graph available) + const classResolved = ctx.resolve(receiverClassName, filePath); + if (classResolved && classResolved.candidates.length > 0) { + const classNodeIds = new Set(classResolved.candidates.map(c => c.nodeId)); + const narrowed = callableDefs.filter(d => + d.ownerId && classNodeIds.has(d.ownerId) + ); + if (narrowed.length > 0) callableDefs = narrowed; + } + } + } + + if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) { + const typeName = extractReturnTypeName(callableDefs[0].returnType); + if (typeName) { + verified.set(receiverKey(extractFuncNameFromScope(scope), varName), typeName); + } + } + } + } + + return verified; +}; + export const processCalls = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -110,6 +170,10 @@ export const processCalls = async ( const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null; const callRouter = callRouters[language]; + const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0 + ? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx) + : new Map(); + ctx.enableCache(file.path); matches.forEach(match => { @@ -184,7 +248,14 @@ export const processCalls = async ( const callNode = captureMap['call']; const callForm = inferCallForm(callNode, nameNode); const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; - const receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + // Fall back to verified constructor bindings for return type inference + if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) { + const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx); + const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; + receiverTypeName = verifiedReceivers.get(receiverKey(funcName, receiverName)) + ?? verifiedReceivers.get(receiverKey('', receiverName)); + } const resolved = resolveCallTarget({ calledName, @@ -343,6 +414,135 @@ const resolveCallTarget = ( return toResolveResult(filteredCandidates[0], tiered.tier); }; +// ── Return type text helpers ───────────────────────────────────────────── +// extractSimpleTypeName works on AST nodes; this operates on raw return-type +// text already stored in SymbolDefinition (e.g. "User", "Promise", +// "User | null", "*User"). Extracts the base user-defined type name. + +/** Primitive / built-in types that should NOT produce a receiver binding. */ +const PRIMITIVE_TYPES = new Set([ + 'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long', + 'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64', + 'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize', + 'undefined', 'null', 'None', 'nil', +]); + +/** + * Extract a simple type name from raw return-type text. + * Handles common patterns: + * "User" → "User" + * "Promise" → "User" (unwrap wrapper generics) + * "Option" → "User" + * "Result" → "User" (first type arg) + * "User | null" → "User" (strip nullable union) + * "User?" → "User" (strip nullable suffix) + * "*User" → "User" (Go pointer) + * "&User" → "User" (Rust reference) + * Returns undefined for complex types or primitives. + */ +const WRAPPER_GENERICS = new Set([ + 'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers + 'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers + 'Result', 'Either', // result wrappers + // Rust smart pointers (Deref to inner type) + 'Rc', 'Arc', 'Weak', // pointer types + 'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types + 'Ref', 'RefMut', // RefCell guards + 'Cow', // copy-on-write + // Containers (List, Array, Vec, Set, etc.) are intentionally excluded — + // methods are called on the container, not the element type. + // Non-wrapper generics return the base type (e.g., List) via the else branch. +]); + +/** + * Extracts the first type argument from a comma-separated generic argument string, + * respecting nested angle brackets. For example: + * "Result" → "Result" (no top-level comma) + * "User, Error" → "User" + * "Map, string" → "Map" + */ +function extractFirstGenericArg(args: string): string { + let depth = 0; + for (let i = 0; i < args.length; i++) { + if (args[i] === '<') depth++; + else if (args[i] === '>') depth--; + else if (args[i] === ',' && depth === 0) return args.slice(0, i).trim(); + } + return args.trim(); +} + +/** + * Extract the first non-lifetime type argument from a generic argument string. + * Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type. + * "'_, User" → "User" + * "'a, User" → "User" + * "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg) + */ +function extractFirstTypeArg(args: string): string { + let remaining = args; + while (remaining) { + const first = extractFirstGenericArg(remaining); + if (!first.startsWith("'")) return first; + // Skip past this lifetime arg + the comma separator + const commaIdx = remaining.indexOf(',', first.length); + if (commaIdx < 0) return first; // only lifetimes — fall through + remaining = remaining.slice(commaIdx + 1).trim(); + } + return args.trim(); +} + +export const extractReturnTypeName = (raw: string): string | undefined => { + let text = raw.trim(); + if (!text) return undefined; + + // Strip pointer/reference prefixes: *User, &User, &mut User + text = text.replace(/^[&*]+\s*(mut\s+)?/, ''); + + // Strip nullable suffix: User? + text = text.replace(/\?$/, ''); + + // Handle union types: "User | null" → "User" + if (text.includes('|')) { + const parts = text.split('|').map(p => p.trim()).filter(p => + p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil' + ); + if (parts.length === 1) text = parts[0]; + else return undefined; // genuine union — too complex + } + + // Handle generics: Promise → unwrap if wrapper, else take base + const genericMatch = text.match(/^(\w+)\s*<(.+)>$/); + if (genericMatch) { + const [, base, args] = genericMatch; + if (WRAPPER_GENERICS.has(base)) { + // Take the first non-lifetime type argument, using bracket-balanced splitting + // so that nested generics like Result are not split at the inner + // comma. Lifetime parameters (Rust 'a, '_) are skipped. + const firstArg = extractFirstTypeArg(args); + return extractReturnTypeName(firstArg); + } + // Non-wrapper generic: return the base type (e.g., Map → Map) + return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base; + } + + // Bare wrapper type without generic argument (e.g. Task, Promise, Option) + // should not produce a binding — these are meaningless without a type parameter + if (WRAPPER_GENERICS.has(text)) return undefined; + + // Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User + if (text.includes('::') || text.includes('.') || text.includes('\\')) { + text = text.split(/::|[.\\]/).pop()!; + } + + // Final check: skip primitives + if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase())) return undefined; + + // Must start with uppercase (class/type convention) or be a valid identifier + if (!/^[A-Z_]\w*$/.test(text)) return undefined; + + return text; +}; + // ── Scope key helpers ──────────────────────────────────────────────────── // Scope keys use the format "funcName@startIndex" (produced by type-env.ts). // Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts). @@ -380,13 +580,9 @@ export const processCallsFromExtracted = async ( const fileReceiverTypes = new Map>(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { - for (const { scope, varName, calleeName } of bindings) { - const tiered = ctx.resolve(calleeName, filePath); - const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false; - if (isClass) { - if (!fileReceiverTypes.has(filePath)) fileReceiverTypes.set(filePath, new Map()); - fileReceiverTypes.get(filePath)!.set(receiverKey(extractFuncNameFromScope(scope), varName), calleeName); - } + const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); + if (verified.size > 0) { + fileReceiverTypes.set(filePath, verified); } } } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 557105e57f..c3ac9ec558 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -8,6 +8,7 @@ import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js'; import { isNodeExported } from './export-detection.js'; import { detectFrameworkFromAST } from './framework-detection.js'; +import { typeConfigs } from './type-extractors/index.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; @@ -79,6 +80,7 @@ const processParsingWithWorkers = async ( for (const sym of result.symbols) { symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, { parameterCount: sym.parameterCount, + returnType: sym.returnType, ownerId: sym.ownerId, }); } @@ -214,6 +216,14 @@ const processParsingSequential = async ( ? extractMethodSignature(definitionNode) : undefined; + // Language-specific return type fallback (e.g. Ruby YARD @return [Type]) + if (methodSig && !methodSig.returnType && definitionNode) { + const tc = typeConfigs[language as keyof typeof typeConfigs]; + if (tc?.extractReturnType) { + methodSig.returnType = tc.extractReturnType(definitionNode); + } + } + const node: GraphNode = { id: nodeId, label: nodeLabel as any, @@ -244,6 +254,7 @@ const processParsingSequential = async ( symbolTable.add(file.path, nodeName, nodeId, nodeLabel, { parameterCount: methodSig?.parameterCount, + returnType: methodSig?.returnType, ownerId: enclosingClassId ?? undefined, }); diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 3dbfc93ef6..8459113952 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -3,6 +3,8 @@ export interface SymbolDefinition { filePath: string; type: string; // 'Function', 'Class', etc. parameterCount?: number; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; /** Links Method/Constructor to owning Class/Struct/Trait nodeId */ ownerId?: string; } @@ -16,7 +18,7 @@ export interface SymbolTable { name: string, nodeId: string, type: string, - metadata?: { parameterCount?: number; ownerId?: string } + metadata?: { parameterCount?: number; returnType?: string; ownerId?: string } ) => void; /** @@ -62,13 +64,14 @@ export const createSymbolTable = (): SymbolTable => { name: string, nodeId: string, type: string, - metadata?: { parameterCount?: number; ownerId?: string } + metadata?: { parameterCount?: number; returnType?: string; ownerId?: string } ) => { const def: SymbolDefinition = { nodeId, filePath, type, ...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}), + ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), }; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d48d780d93..bedc50b2fd 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -365,6 +365,13 @@ export const CSHARP_QUERIES = ` (invocation_expression function: (identifier) @call.name) @call (invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call +; Null-conditional method calls: user?.Save() +; Parses as: invocation_expression → conditional_access_expression → member_binding_expression → identifier +(invocation_expression + function: (conditional_access_expression + (member_binding_expression + (identifier) @call.name))) @call + ; Constructor calls: new Foo() and new Foo { Props } (object_creation_expression type: (identifier) @call.name) @call diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index c1b4cf944e..10ccb849cb 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -270,20 +270,14 @@ const createClassNameLookup = ( }; /** - * Build a scoped TypeEnv from a tree-sitter AST for a given language. - * Single-pass: collects class/struct names AND type bindings in one walk. - * Class names are accumulated incrementally — this is safe because no - * language allows constructing a class before its definition. + * Build a TypeEnvironment from a tree-sitter AST for a given language. + * Single-pass: collects class/struct names, type bindings, AND constructor + * bindings that couldn't be resolved locally — all in one AST walk. * * When a symbolTable is provided (call-processor path), class names from across * the project are available for constructor inference in languages like Kotlin * where constructors are syntactically identical to function calls. */ -/** - * Build a TypeEnvironment from a tree-sitter AST for a given language. - * Single-pass: collects class/struct names, type bindings, AND constructor - * bindings that couldn't be resolved locally — all in one AST walk. - */ export const buildTypeEnv = ( tree: { rootNode: SyntaxNode }, language: SupportedLanguages, @@ -293,7 +287,6 @@ export const buildTypeEnv = ( const localClassNames = new Set(); const classNames = createClassNameLookup(localClassNames, symbolTable); const config = typeConfigs[language]; - const scanner = CONSTRUCTOR_BINDING_SCANNERS[language]; const bindings: ConstructorBinding[] = []; /** @@ -347,8 +340,8 @@ export const buildTypeEnv = ( // Scan for constructor bindings that couldn't be resolved locally. // Only collect if TypeEnv didn't already resolve this binding. - if (scanner) { - const result = scanner(node); + if (config.scanConstructorBinding) { + const result = config.scanConstructorBinding(node); if (result && !scopeEnv.has(result.varName)) { bindings.push({ scope, ...result }); } @@ -381,149 +374,8 @@ export interface ConstructorBinding { varName: string; /** Name of the callee (potential class constructor) */ calleeName: string; + /** Enclosing class name when callee is a method on a known receiver (e.g. $this) */ + receiverClassName?: string; } -/** C/C++: auto x = User() where function is an identifier (not type_identifier) */ -const extractCppConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => { - if (node.type !== 'declaration') return undefined; - const typeNode = node.childForFieldName('type'); - if (!typeNode) return undefined; - const typeText = typeNode.text; - if (typeText !== 'auto' && typeText !== 'decltype(auto)' && typeNode.type !== 'placeholder_type_specifier') return undefined; - const declarator = node.childForFieldName('declarator'); - if (!declarator || declarator.type !== 'init_declarator') return undefined; - const value = declarator.childForFieldName('value'); - if (!value || value.type !== 'call_expression') return undefined; - const func = value.childForFieldName('function'); - // Match plain identifiers (type_identifier is already resolved by extractInitializer) - // and qualified/scoped identifiers for namespaced calls like ns::HttpClient() - if (!func) return undefined; - if (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') { - // ns::HttpClient → extract "HttpClient" (last segment) - const last = func.lastNamedChild; - if (!last) return undefined; - const nameNode = declarator.childForFieldName('declarator'); - if (!nameNode) return undefined; - const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' - ? nameNode.firstNamedChild : nameNode; - if (!finalName) return undefined; - return { varName: finalName.text, calleeName: last.text }; - } - if (func.type !== 'identifier') return undefined; - const nameNode = declarator.childForFieldName('declarator'); - if (!nameNode) return undefined; - const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' - ? nameNode.firstNamedChild : nameNode; - if (!finalName) return undefined; - const varName = finalName.text; - if (!varName) return undefined; - return { varName, calleeName: func.text }; -}; - -/** Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant */ -const extractRubyConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => { - if (node.type !== 'assignment') return undefined; - const left = node.childForFieldName('left'); - const right = node.childForFieldName('right'); - if (!left || !right) return undefined; - // Support both local variables (identifier) and constants (USER = User.new) - if (left.type !== 'identifier' && left.type !== 'constant') return undefined; - if (right.type !== 'call') return undefined; - const method = right.childForFieldName('method'); - if (!method || method.text !== 'new') return undefined; - const receiver = right.childForFieldName('receiver'); - if (!receiver || receiver.type !== 'constant') return undefined; - return { varName: left.text, calleeName: receiver.text }; -}; - -/** Language-specific constructor-binding scanners. */ -const CONSTRUCTOR_BINDING_SCANNERS: Partial { varName: string; calleeName: string } | undefined>> = { - // Kotlin: val x = User(...) — property_declaration with call_expression - [SupportedLanguages.Kotlin]: (node) => { - if (node.type !== 'property_declaration') return undefined; - const varDecl = node.namedChildren.find(c => c.type === 'variable_declaration'); - if (!varDecl) return undefined; - if (varDecl.namedChildren.some(c => c.type === 'user_type')) return undefined; - const callExpr = node.namedChildren.find(c => c.type === 'call_expression'); - if (!callExpr) return undefined; - const callee = callExpr.firstNamedChild; - if (!callee || callee.type !== 'simple_identifier') return undefined; - const nameNode = varDecl.namedChildren.find(c => c.type === 'simple_identifier'); - if (!nameNode) return undefined; - return { varName: nameNode.text, calleeName: callee.text }; - }, - - // Python: user = User("alice") — assignment with call - // Also handles walrus operator: (user := User("alice")) - [SupportedLanguages.Python]: (node) => { - let left: SyntaxNode | null; - let right: SyntaxNode | null; - - if (node.type === 'named_expression') { - // Walrus operator: (user := User("alice")) - left = node.childForFieldName('name'); - right = node.childForFieldName('value'); - } else if (node.type === 'assignment') { - left = node.childForFieldName('left'); - right = node.childForFieldName('right'); - // Skip annotated assignments — extractDeclaration handles those - if (node.childForFieldName('type')) return undefined; - } else { - return undefined; - } - - if (!left || !right) return undefined; - if (left.type !== 'identifier') return undefined; - if (right.type !== 'call') return undefined; - const func = right.childForFieldName('function'); - if (!func) return undefined; - // Support both direct calls (User()) and qualified calls (models.User()) - const calleeName = extractSimpleTypeName(func); - if (!calleeName) return undefined; - return { varName: left.text, calleeName }; - }, - - // Swift: let user = User(name: "alice") — property_declaration with call_expression - [SupportedLanguages.Swift]: (node) => { - if (node.type !== 'property_declaration') return undefined; - // Skip if has type annotation - if (node.childForFieldName('type')) return undefined; - for (let i = 0; i < node.namedChildCount; i++) { - if (node.namedChild(i)?.type === 'type_annotation') return undefined; - } - const pattern = node.childForFieldName('pattern'); - if (!pattern) return undefined; - const varName = pattern.text; - if (!varName) return undefined; - // Find call_expression child - let callExpr: SyntaxNode | null = null; - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (child?.type === 'call_expression') { callExpr = child; break; } - } - if (!callExpr) return undefined; - const callee = callExpr.firstNamedChild; - if (!callee) return undefined; - // Direct call: User(name: "alice") — simple_identifier callee - if (callee.type === 'simple_identifier') { - return { varName, calleeName: callee.text }; - } - // Explicit init: User.init(name: "alice") — navigation_expression with .init suffix - if (callee.type === 'navigation_expression') { - const receiver = callee.firstNamedChild; - const suffix = callee.lastNamedChild; - if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') { - return { varName, calleeName: receiver.text }; - } - } - return undefined; - }, - - // C++: auto x = User() where User is parsed as identifier (cross-file) - // Note: C is excluded — C has no constructors and `auto` is a storage-class specifier, not type inference. - [SupportedLanguages.CPlusPlus]: extractCppConstructorBinding, - - // Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant - [SupportedLanguages.Ruby]: extractRubyConstructorBinding, -}; diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts index ede01b280b..4113b0eaca 100644 --- a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -126,9 +126,44 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'declaration') return undefined; + const typeNode = node.childForFieldName('type'); + if (!typeNode) return undefined; + const typeText = typeNode.text; + if (typeText !== 'auto' && typeText !== 'decltype(auto)' && typeNode.type !== 'placeholder_type_specifier') return undefined; + const declarator = node.childForFieldName('declarator'); + if (!declarator || declarator.type !== 'init_declarator') return undefined; + const value = declarator.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + const func = value.childForFieldName('function'); + if (!func) return undefined; + if (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') { + const last = func.lastNamedChild; + if (!last) return undefined; + const nameNode = declarator.childForFieldName('declarator'); + if (!nameNode) return undefined; + const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild : nameNode; + if (!finalName) return undefined; + return { varName: finalName.text, calleeName: last.text }; + } + if (func.type !== 'identifier') return undefined; + const nameNode = declarator.childForFieldName('declarator'); + if (!nameNode) return undefined; + const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild : nameNode; + if (!finalName) return undefined; + const varName = finalName.text; + if (!varName) return undefined; + return { varName, calleeName: func.text }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index af5f093453..9a2f3e8391 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; +import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'local_declaration_statement', @@ -103,8 +103,49 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'variable_declaration') return undefined; + // Find type and declarator children by iterating (C# grammar doesn't expose 'type' as a named field) + let typeNode: SyntaxNode | null = null; + let declarator: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'variable_declarator') { if (!declarator) declarator = child; } + else if (!typeNode) { typeNode = child; } + } + // Only handle implicit_type (var) — explicit types handled by extractDeclaration + if (!typeNode || typeNode.type !== 'implicit_type') return undefined; + if (!declarator) return undefined; + const nameNode = declarator.childForFieldName('name') ?? declarator.firstNamedChild; + if (!nameNode || nameNode.type !== 'identifier') return undefined; + // Find the initializer value: either inside equals_value_clause or as a direct child + // (tree-sitter-c-sharp puts invocation_expression directly inside variable_declarator) + let value: SyntaxNode | null = null; + for (let i = 0; i < declarator.namedChildCount; i++) { + const child = declarator.namedChild(i); + if (!child) continue; + if (child.type === 'equals_value_clause') { value = child.firstNamedChild; break; } + if (child.type === 'invocation_expression' || child.type === 'object_creation_expression' || child.type === 'await_expression') { value = child; break; } + } + if (!value) return undefined; + // Unwrap await: `var user = await svc.GetUserAsync()` → await_expression wraps invocation_expression + value = unwrapAwait(value); + if (!value) return undefined; + // Skip object_creation_expression (new User()) — handled by extractInitializer + if (value.type === 'object_creation_expression') return undefined; + if (value.type !== 'invocation_expression') return undefined; + const func = value.firstNamedChild; + if (!func) return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: nameNode.text, calleeName }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index 72090ab67b..6a5c13d9b1 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -141,8 +141,49 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'short_var_declaration') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + const leftIds = left.type === 'expression_list' ? left.namedChildren : [left]; + const rightExprs = right.type === 'expression_list' ? right.namedChildren : [right]; + + // Multi-return: user, err := NewUser() — bind first var when second is err/ok/_ + if (leftIds.length === 2 && rightExprs.length === 1) { + const secondVar = leftIds[1]; + const isErrorOrDiscard = + secondVar.text === '_' || + secondVar.text === 'err' || + secondVar.text === 'ok' || + secondVar.text === 'error'; + if (isErrorOrDiscard && leftIds[0].type === 'identifier') { + if (rightExprs[0].type !== 'call_expression') return undefined; + const func = rightExprs[0].childForFieldName('function'); + if (!func) return undefined; + if (func.text === 'new' || func.text === 'make') return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: leftIds[0].text, calleeName }; + } + } + + // Single assignment only + if (leftIds.length !== 1 || leftIds[0].type !== 'identifier') return undefined; + if (rightExprs.length !== 1 || rightExprs[0].type !== 'call_expression') return undefined; + const func = rightExprs[0].childForFieldName('function'); + if (!func) return undefined; + // Skip new() and make() — already handled by extractDeclaration + if (func.text === 'new' || func.text === 'make') return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: leftIds[0].text, calleeName }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index 7fb30b0bc8..98f62bdf7e 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -15,6 +15,7 @@ import { typeConfig as pythonConfig } from './python.js'; import { typeConfig as swiftConfig } from './swift.js'; import { typeConfig as cCppConfig } from './c-cpp.js'; import { typeConfig as phpConfig } from './php.js'; +import { typeConfig as rubyConfig } from './ruby.js'; export const typeConfigs = { [SupportedLanguages.JavaScript]: typescriptConfig, @@ -29,12 +30,15 @@ export const typeConfigs = { [SupportedLanguages.C]: cCppConfig, [SupportedLanguages.CPlusPlus]: cCppConfig, [SupportedLanguages.PHP]: phpConfig, - [SupportedLanguages.Ruby]: { - declarationNodeTypes: new Set(), - extractDeclaration: () => {}, - extractParameter: () => {}, - } as LanguageTypeConfig, + [SupportedLanguages.Ruby]: rubyConfig, } satisfies Record; -export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js'; -export { TYPED_PARAMETER_TYPES, extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; +export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor, ConstructorBindingScanner } from './types.js'; +export { + TYPED_PARAMETER_TYPES, + extractSimpleTypeName, + extractGenericTypeArgs, + extractVarName, + findChildByType, + extractRubyConstructorAssignment +} from './shared.js'; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index bc8281ca67..05e9168be8 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -67,11 +67,30 @@ const extractJavaParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'local_variable_declaration') return undefined; + const typeNode = node.childForFieldName('type'); + if (!typeNode) return undefined; + if (typeNode.text !== 'var') return undefined; + const declarator = node.namedChildren.find((c: SyntaxNode) => c.type === 'variable_declarator'); + if (!declarator) return undefined; + const nameNode = declarator.childForFieldName('name'); + const value = declarator.childForFieldName('value'); + if (!nameNode || !value) return undefined; + if (value.type === 'object_creation_expression') return undefined; + if (value.type !== 'method_invocation') return undefined; + const methodName = value.childForFieldName('name'); + if (!methodName) return undefined; + return { varName: nameNode.text, calleeName: methodName.text }; +}; + export const javaTypeConfig: LanguageTypeConfig = { declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES, extractDeclaration: extractJavaDeclaration, extractParameter: extractJavaParameter, extractInitializer: extractJavaInitializer, + scanConstructorBinding: scanJavaConstructorBinding, }; // ── Kotlin ──────────────────────────────────────────────────────────────── @@ -166,9 +185,40 @@ const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: M if (varName) env.set(varName, calleeName); }; +/** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */ +const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => { + if (node.type !== 'property_declaration') return undefined; + const varDecl = node.namedChildren.find(c => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + if (varDecl.namedChildren.some(c => c.type === 'user_type')) return undefined; + const callExpr = node.namedChildren.find(c => c.type === 'call_expression'); + if (!callExpr) return undefined; + const callee = callExpr.firstNamedChild; + if (!callee) return undefined; + + let calleeName: string | undefined; + if (callee.type === 'simple_identifier') { + calleeName = callee.text; + } else if (callee.type === 'navigation_expression') { + // Extract method name from qualified call: service.getUser() → getUser + const suffix = callee.lastNamedChild; + if (suffix?.type === 'navigation_suffix') { + const methodName = suffix.lastNamedChild; + if (methodName?.type === 'simple_identifier') { + calleeName = methodName.text; + } + } + } + if (!calleeName) return undefined; + const nameNode = varDecl.namedChildren.find(c => c.type === 'simple_identifier'); + if (!nameNode) return undefined; + return { varName: nameNode.text, calleeName }; +}; + export const kotlinTypeConfig: LanguageTypeConfig = { declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES, extractDeclaration: extractKotlinDeclaration, extractParameter: extractKotlinParameter, extractInitializer: extractKotlinInitializer, + scanConstructorBinding: scanKotlinConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index d07ce1706f..f96298e66a 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -1,10 +1,12 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; -import { extractSimpleTypeName, extractVarName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, extractCalleeName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ - 'assignment_expression', // For constructor inference: $x = new User() - 'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo; + 'assignment_expression', // For constructor inference: $x = new User() + 'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo; + 'method_declaration', // PHPDoc @param on class methods + 'function_definition', // PHPDoc @param on top-level functions ]); /** Walk up the AST to find the enclosing class declaration. */ @@ -45,8 +47,90 @@ const resolvePhpKeyword = (keyword: string, node: SyntaxNode): string | undefine return undefined; }; -/** PHP: typed class properties (PHP 7.4+): private UserRepo $repo; */ +const normalizePhpType = (raw: string): string | undefined => { + // Strip nullable prefix: ?User → User + let type = raw.startsWith('?') ? raw.slice(1) : raw; + // Strip array suffix: User[] → User + type = type.replace(/\[\]$/, ''); + // Strip union with null/false/void: User|null → User + const parts = type.split('|').filter(p => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed'); + if (parts.length !== 1) return undefined; + type = parts[0]; + // Strip namespace: \App\Models\User → User + const segments = type.split('\\'); + type = segments[segments.length - 1]; + // Skip uninformative types + if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object') return undefined; + if (/^\w+$/.test(type)) return type; + return undefined; +}; + +/** Node types to skip when walking backwards to find doc-comments. + * PHP 8+ attributes (#[Route(...)]) appear as named siblings between PHPDoc and method. */ +const SKIP_NODE_TYPES: ReadonlySet = new Set(['attribute_list', 'attribute']); + +/** Regex to extract PHPDoc @param annotations: `@param Type $name` (standard order) */ +const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g; +/** Alternate PHPDoc order: `@param $name Type` (name first) */ +const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g; + +/** + * Collect PHPDoc @param type bindings from comment nodes preceding a method/function. + * Returns a map of paramName → typeName (without $ prefix). + */ +const collectPhpDocParams = (methodNode: SyntaxNode): Map => { + const commentTexts: string[] = []; + let sibling = methodNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + commentTexts.unshift(sibling.text); + } else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) { + break; + } + sibling = sibling.previousSibling; + } + if (commentTexts.length === 0) return new Map(); + + const params = new Map(); + const commentBlock = commentTexts.join('\n'); + PHPDOC_PARAM_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) { + const typeName = normalizePhpType(match[1]); + const paramName = match[2]; // without $ prefix + if (typeName) { + // Store with $ prefix to match how PHP variables appear in the env + params.set('$' + paramName, typeName); + } + } + + // Also check alternate PHPDoc order: @param $name Type + PHPDOC_PARAM_ALT_RE.lastIndex = 0; + while ((match = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const paramName = match[1]; + if (params.has('$' + paramName)) continue; // standard format takes priority + const typeName = normalizePhpType(match[2]); + if (typeName) { + params.set('$' + paramName, typeName); + } + } + return params; +}; + +/** + * PHP: typed class properties (PHP 7.4+): private UserRepo $repo; + * Also: PHPDoc @param annotations on method/function definitions. + */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // PHPDoc @param on methods/functions — pre-populate env with param types + if (node.type === 'method_declaration' || node.type === 'function_definition') { + const phpDocParams = collectPhpDocParams(node); + for (const [paramName, typeName] of phpDocParams) { + if (!env.has(paramName)) env.set(paramName, typeName); + } + return; + } + if (node.type !== 'property_declaration') return; const typeNode = node.childForFieldName('type'); @@ -110,9 +194,62 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: MapgetUser() — bind variable to call return type */ +const scanConstructorBinding: ConstructorBindingScanner = (node) => { + if (node.type !== 'assignment_expression') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + if (left.type !== 'variable_name') return undefined; + // Skip object_creation_expression (new User()) — handled by extractInitializer + if (right.type === 'object_creation_expression') return undefined; + // Handle both standalone function calls and method calls ($this->getUser()) + if (right.type === 'function_call_expression') { + const calleeName = extractCalleeName(right); + if (!calleeName) return undefined; + return { varName: left.text, calleeName }; + } + if (right.type === 'member_call_expression') { + const methodName = right.childForFieldName('name'); + if (!methodName) return undefined; + // When receiver is $this/self/static, qualify with enclosing class for disambiguation + const receiver = right.childForFieldName('object'); + const receiverText = receiver?.text; + let receiverClassName: string | undefined; + if (receiverText === '$this' || receiverText === 'self' || receiverText === 'static') { + const cls = findEnclosingClass(node); + const clsName = cls?.childForFieldName('name'); + if (clsName) receiverClassName = clsName.text; + } + return { varName: left.text, calleeName: methodName.text, receiverClassName }; + } + return undefined; +}; + +/** Regex to extract PHPDoc @return annotations: `@return User` */ +const PHPDOC_RETURN_RE = /@return\s+(\S+)/; + +/** + * Extract return type from PHPDoc `@return Type` annotation preceding a method. + * Walks backwards through preceding siblings looking for comment nodes. + */ +const extractReturnType: ReturnTypeExtractor = (node) => { + let sibling = node.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = PHPDOC_RETURN_RE.exec(sibling.text); + if (match) return normalizePhpType(match[1]); + } else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break; + sibling = sibling.previousSibling; + } + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, + scanConstructorBinding, + extractReturnType, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index 55e399bf48..e44c3f715f 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -75,9 +75,37 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map { + let left: SyntaxNode | null; + let right: SyntaxNode | null; + + if (node.type === 'named_expression') { + left = node.childForFieldName('name'); + right = node.childForFieldName('value'); + } else if (node.type === 'assignment') { + left = node.childForFieldName('left'); + right = node.childForFieldName('right'); + if (node.childForFieldName('type')) return undefined; + } else { + return undefined; + } + + if (!left || !right) return undefined; + if (left.type !== 'identifier') return undefined; + if (right.type !== 'call') return undefined; + const func = right.childForFieldName('function'); + if (!func) return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: left.text, calleeName }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts new file mode 100644 index 0000000000..e2c8907608 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -0,0 +1,271 @@ +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js'; +import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js'; +import { SyntaxNode } from '../utils.js'; + +/** + * Ruby type extractor — YARD annotation parsing. + * + * Ruby has no static type system, but the YARD documentation convention + * provides de facto type annotations via comments: + * + * # @param name [String] the user's name + * # @param repo [UserRepo] the repository + * # @return [User] + * def create(name, repo) + * repo.save + * end + * + * This extractor parses `@param name [Type]` patterns from comment nodes + * preceding method definitions and binds parameter names to their types. + * + * Resolution tiers: + * - Tier 0: YARD @param annotations (extractDeclaration pre-populates env) + * - Tier 1: Constructor inference via `user = User.new` (handled by scanConstructorBinding in typeConfig) + */ + +/** Regex to extract @param annotations: `@param name [Type]` */ +const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g; +/** Alternate YARD order: `@param [Type] name` */ +const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g; + +/** Regex to extract @return annotations: `@return [Type]` */ +const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/; + +/** + * Extract the simple type name from a YARD type string. + * Handles: + * - Simple types: "String" → "String" + * - Qualified types: "Models::User" → "User" + * - Generic types: "Array" → "Array" + * - Nullable types: "String, nil" → "String" + * - Union types: "String, Integer" → undefined (ambiguous) + */ +const extractYardTypeName = (yardType: string): string | undefined => { + const trimmed = yardType.trim(); + + // Handle nullable: "Type, nil" or "nil, Type" + // Use bracket-balanced split to avoid breaking on commas inside generics like Hash + const parts: string[] = []; + let depth = 0, start = 0; + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i] === '<') depth++; + else if (trimmed[i] === '>') depth--; + else if (trimmed[i] === ',' && depth === 0) { + parts.push(trimmed.slice(start, i).trim()); + start = i + 1; + } + } + parts.push(trimmed.slice(start).trim()); + const filtered = parts.filter(p => p !== '' && p !== 'nil'); + if (filtered.length !== 1) return undefined; // ambiguous union + + const typePart = filtered[0]; + + // Handle qualified: "Models::User" → "User" + const segments = typePart.split('::'); + const last = segments[segments.length - 1]; + + // Handle generic: "Array" → "Array" + const genericMatch = last.match(/^(\w+)\s*[<{(]/); + if (genericMatch) return genericMatch[1]; + + // Simple identifier check + if (/^\w+$/.test(last)) return last; + + return undefined; +}; + +/** + * Collect YARD @param annotations from comment nodes preceding a method definition. + * Returns a map of paramName → typeName. + * + * In tree-sitter-ruby, comments are sibling nodes that appear before the method node. + * We walk backwards through preceding siblings collecting consecutive comment nodes. + */ +const collectYardParams = (methodNode: SyntaxNode): Map => { + const params = new Map(); + + // In tree-sitter-ruby, YARD comments preceding a method inside a class body + // are placed as children of the `class` node, NOT as siblings of the `method` + // inside `body_statement`. The AST structure is: + // + // class + // constant = "ClassName" + // comment = "# @param ..." ← sibling of body_statement + // comment = "# @param ..." ← sibling of body_statement + // body_statement + // method ← method is here, no preceding siblings + // + // For top-level methods (outside classes), comments ARE direct siblings. + // We handle both by checking: if method has no preceding comment siblings, + // look at parent (body_statement) siblings instead. + const commentTexts: string[] = []; + + const collectComments = (startNode: SyntaxNode): void => { + let sibling = startNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + commentTexts.unshift(sibling.text); + } else if (sibling.isNamed) { + break; + } + sibling = sibling.previousSibling; + } + }; + + // Try method's own siblings first (top-level methods) + collectComments(methodNode); + + // If no comments found and parent is body_statement, check parent's siblings + if (commentTexts.length === 0 && methodNode.parent?.type === 'body_statement') { + collectComments(methodNode.parent); + } + + // Parse all comment lines for @param annotations + const commentBlock = commentTexts.join('\n'); + let match: RegExpExecArray | null; + + // Reset regex state + YARD_PARAM_RE.lastIndex = 0; + while ((match = YARD_PARAM_RE.exec(commentBlock)) !== null) { + const paramName = match[1]; + const rawType = match[2]; + const typeName = extractYardTypeName(rawType); + if (typeName) { + params.set(paramName, typeName); + } + } + + // Also check alternate YARD order: @param [Type] name + YARD_PARAM_ALT_RE.lastIndex = 0; + while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const rawType = match[1]; + const paramName = match[2]; + if (params.has(paramName)) continue; // standard format takes priority + const typeName = extractYardTypeName(rawType); + if (typeName) { + params.set(paramName, typeName); + } + } + + return params; +}; + +/** + * Ruby node types that may carry type bindings. + * - `method`/`singleton_method`: YARD @param annotations (via extractDeclaration) + * - `assignment`: Constructor inference like `user = User.new` (via extractInitializer; + * extractDeclaration returns early for these nodes) + */ +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'method', + 'singleton_method', + 'assignment', +]); + +/** + * Extract YARD annotations from method definitions. + * Pre-populates the scope env with parameter types before the + * standard parameter walk (which won't find types since Ruby has none). + */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + if (node.type !== 'method' && node.type !== 'singleton_method') return; + + const yardParams = collectYardParams(node); + if (yardParams.size === 0) return; + + // Pre-populate env with YARD type bindings for each parameter + for (const [paramName, typeName] of yardParams) { + env.set(paramName, typeName); + } +}; + +/** + * Ruby parameter extraction. + * Ruby parameters (identifiers inside method_parameters) have no inline + * type annotations. YARD types are already populated by extractDeclaration, + * so this is a no-op — the bindings are already in the env. + * + * We still register this to maintain the LanguageTypeConfig contract. + */ +const extractParameter: ParameterExtractor = (_node: SyntaxNode, _env: Map): void => { + // Ruby parameters have no type annotations. + // YARD types are pre-populated by extractDeclaration. +}; + +/** + * Ruby constructor inference: user = User.new or service = Models::User.new + * Uses the shared extractRubyConstructorAssignment helper for AST matching, + * then resolves against locally-known class names. + */ +const extractInitializer: InitializerExtractor = (node, env, classNames): void => { + const result = extractRubyConstructorAssignment(node); + if (!result) return; + if (env.has(result.varName)) return; + if (classNames.has(result.calleeName)) { + env.set(result.varName, result.calleeName); + } +}; + +/** + * Extract return type from YARD `@return [Type]` annotation preceding a method. + * Reuses the same comment-walking strategy as collectYardParams: try direct + * siblings first, fall back to parent (body_statement) siblings for class methods. + */ +const extractReturnType: ReturnTypeExtractor = (node) => { + const search = (startNode: SyntaxNode): string | undefined => { + let sibling = startNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = YARD_RETURN_RE.exec(sibling.text); + if (match) return extractYardTypeName(match[1]); + } else if (sibling.isNamed) { + break; + } + sibling = sibling.previousSibling; + } + return undefined; + }; + + const result = search(node); + if (result) return result; + + if (node.parent?.type === 'body_statement') { + return search(node.parent); + } + return undefined; +}; + +/** + * Ruby constructor binding scanner: captures both `user = User.new` and + * plain call assignments like `user = get_user()`. + * The `.new` pattern returns the class name directly; plain calls return the + * callee name for return-type inference via SymbolTable lookup. + */ +const scanConstructorBinding: ConstructorBindingScanner = (node) => { + // Try the .new pattern first (returns class name directly) + const newResult = extractRubyConstructorAssignment(node); + if (newResult) return newResult; + + // Plain call assignment: user = get_user() / user = Models.create() + if (node.type !== 'assignment') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + if (left.type !== 'identifier' && left.type !== 'constant') return undefined; + if (right.type !== 'call') return undefined; + const method = right.childForFieldName('method'); + if (!method) return undefined; + const calleeName = extractSimpleTypeName(method); + if (!calleeName) return undefined; + return { varName: left.text, calleeName }; +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, + extractInitializer, + scanConstructorBinding, + extractReturnType, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 7380037a77..746c7ea30e 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; -import { extractSimpleTypeName, extractVarName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; +import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'let_declaration', @@ -152,9 +152,39 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'let_declaration') return undefined; + if (hasTypeAnnotation(node)) return undefined; + let patternNode = node.childForFieldName('pattern'); + if (!patternNode) return undefined; + if (patternNode.type === 'mut_pattern') { + patternNode = patternNode.firstNamedChild; + if (!patternNode) return undefined; + } + if (patternNode.type !== 'identifier') return undefined; + // Unwrap `.await`: `let user = get_user().await` → await_expression wraps call_expression + const value = unwrapAwait(node.childForFieldName('value')); + if (!value || value.type !== 'call_expression') return undefined; + const func = value.childForFieldName('function'); + if (!func) return undefined; + if (func.type === 'scoped_identifier') { + const methodName = func.lastNamedChild; + if (methodName?.text === 'new' || methodName?.text === 'default') return undefined; + } + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: patternNode.text, calleeName }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractInitializer, extractParameter, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index f6f3770a4b..e01aee02c1 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -7,20 +7,25 @@ import type { SyntaxNode } from '../utils.js'; * Returns undefined for complex types (unions, intersections, function types). */ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined => { - // Direct type identifier + // Direct type identifier (includes Ruby 'constant' for class names) if (typeNode.type === 'type_identifier' || typeNode.type === 'identifier' - || typeNode.type === 'simple_identifier') { + || typeNode.type === 'simple_identifier' || typeNode.type === 'constant') { return typeNode.text; } - // Qualified/scoped names: take the last segment (e.g., models.User → User) + // Qualified/scoped names: take the last segment (e.g., models.User → User, Models::User → User) if (typeNode.type === 'scoped_identifier' || typeNode.type === 'qualified_identifier' || typeNode.type === 'scoped_type_identifier' || typeNode.type === 'qualified_name' || typeNode.type === 'qualified_type' - || typeNode.type === 'member_expression' || typeNode.type === 'attribute') { + || typeNode.type === 'member_expression' || typeNode.type === 'member_access_expression' + || typeNode.type === 'attribute' + || typeNode.type === 'scope_resolution' + || typeNode.type === 'selector_expression') { const last = typeNode.lastNamedChild; if (last && (last.type === 'type_identifier' || last.type === 'identifier' - || last.type === 'simple_identifier' || last.type === 'name')) { + || last.type === 'simple_identifier' || last.type === 'name' + || last.type === 'constant' || last.type === 'property_identifier' + || last.type === 'field_identifier')) { return last.text; } } @@ -95,7 +100,8 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined */ export const extractVarName = (node: SyntaxNode): string | undefined => { if (node.type === 'identifier' || node.type === 'simple_identifier' - || node.type === 'variable_name' || node.type === 'name') { + || node.type === 'variable_name' || node.type === 'name' + || node.type === 'constant') { return node.text; } // variable_declarator (Java/C#): has a 'name' field @@ -122,6 +128,130 @@ export const TYPED_PARAMETER_TYPES = new Set([ 'property_promotion_parameter', // PHP 8.0+ constructor promotion: __construct(private Foo $x) ]); +/** + * Extract type arguments from a generic type node. + * e.g., List → ['User', 'String'], Vec → ['User'] + * + * Handles language-specific AST structures: + * - TS/Java/Rust/Go: generic_type > type_arguments > type nodes + * - C#: generic_type > type_argument_list > type nodes + * - Kotlin: generic_type > type_arguments > type_projection > type nodes + * + * Note: Go slices/maps use slice_type/map_type, not generic_type — those are + * NOT handled here. Use language-specific extractors for Go container types. + * + * @param typeNode A generic_type or parameterized_type AST node (or any node — + * returns [] for non-generic types). + * @returns Array of resolved type argument names. Unresolvable arguments are omitted. + */ +export const extractGenericTypeArgs = (typeNode: SyntaxNode): string[] => { + // Unwrap wrapper nodes that may sit above the generic_type + if (typeNode.type === 'type_annotation' || typeNode.type === 'type' + || typeNode.type === 'user_type' || typeNode.type === 'nullable_type' + || typeNode.type === 'optional_type') { + const inner = typeNode.firstNamedChild; + if (inner) return extractGenericTypeArgs(inner); + return []; + } + + // Only process generic/parameterized type nodes + if (typeNode.type !== 'generic_type' && typeNode.type !== 'parameterized_type') { + return []; + } + + // Find the type_arguments / type_argument_list child + let argsNode: SyntaxNode | null = null; + for (let i = 0; i < typeNode.namedChildCount; i++) { + const child = typeNode.namedChild(i); + if (child && (child.type === 'type_arguments' || child.type === 'type_argument_list')) { + argsNode = child; + break; + } + } + if (!argsNode) return []; + + const result: string[] = []; + for (let i = 0; i < argsNode.namedChildCount; i++) { + let argNode = argsNode.namedChild(i); + if (!argNode) continue; + + // Kotlin: type_arguments > type_projection > user_type > type_identifier + if (argNode.type === 'type_projection') { + argNode = argNode.firstNamedChild; + if (!argNode) continue; + } + + const name = extractSimpleTypeName(argNode); + if (name) result.push(name); + } + + return result; +}; + +/** + * Match Ruby constructor assignment: `user = User.new` or `service = Models::User.new`. + * Returns { varName, calleeName } or undefined if the node is not a Ruby constructor assignment. + * Handles both simple constants and scope_resolution (namespaced) receivers. + */ +export const extractRubyConstructorAssignment = ( + node: SyntaxNode, +): { varName: string; calleeName: string } | undefined => { + if (node.type !== 'assignment') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + if (left.type !== 'identifier' && left.type !== 'constant') return undefined; + if (right.type !== 'call') return undefined; + const method = right.childForFieldName('method'); + if (!method || method.text !== 'new') return undefined; + const receiver = right.childForFieldName('receiver'); + if (!receiver) return undefined; + let calleeName: string; + if (receiver.type === 'constant') { + calleeName = receiver.text; + } else if (receiver.type === 'scope_resolution') { + // Models::User → extract last segment "User" + const last = receiver.lastNamedChild; + if (!last || last.type !== 'constant') return undefined; + calleeName = last.text; + } else { + return undefined; + } + return { varName: left.text, calleeName }; +}; + +/** + * Check if an AST node has an explicit type annotation. + * Checks both named fields ('type') and child nodes ('type_annotation'). + * Used by constructor binding scanners to skip annotated declarations. + */ +export const hasTypeAnnotation = (node: SyntaxNode): boolean => { + if (node.childForFieldName('type')) return true; + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'type_annotation') return true; + } + return false; +}; + +/** + * Unwrap an await_expression to get the inner value. + * Returns the node itself if not an await_expression, or null if input is null. + */ +export const unwrapAwait = (node: SyntaxNode | null): SyntaxNode | null => { + if (!node) return null; + return node.type === 'await_expression' ? node.firstNamedChild : node; +}; + +/** + * Extract the callee name from a call_expression node. + * Navigates to the 'function' field (or first named child) and extracts a simple type name. + */ +export const extractCalleeName = (callNode: SyntaxNode): string | undefined => { + const func = callNode.childForFieldName('function') ?? callNode.firstNamedChild; + if (!func) return undefined; + return extractSimpleTypeName(func); +}; + /** Find the first named child with the given node type */ export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => { for (let i = 0; i < node.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/type-extractors/swift.ts b/gitnexus/src/core/ingestion/type-extractors/swift.ts index 41f2f78184..3d3dbe7297 100644 --- a/gitnexus/src/core/ingestion/type-extractors/swift.ts +++ b/gitnexus/src/core/ingestion/type-extractors/swift.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; +import { extractSimpleTypeName, extractVarName, findChildByType, hasTypeAnnotation } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'property_declaration', @@ -77,9 +77,51 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'property_declaration') return undefined; + if (hasTypeAnnotation(node)) return undefined; + const pattern = node.childForFieldName('pattern'); + if (!pattern) return undefined; + const varName = pattern.text; + if (!varName) return undefined; + let callExpr: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'call_expression') { callExpr = child; break; } + } + if (!callExpr) return undefined; + const callee = callExpr.firstNamedChild; + if (!callee) return undefined; + if (callee.type === 'simple_identifier') { + return { varName, calleeName: callee.text }; + } + if (callee.type === 'navigation_expression') { + const receiver = callee.firstNamedChild; + const suffix = callee.lastNamedChild; + if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') { + return { varName, calleeName: receiver.text }; + } + // General qualified call: service.getUser() → extract method name. + // tree-sitter-swift may wrap the identifier in navigation_suffix, so + // check both direct simple_identifier and navigation_suffix > simple_identifier. + if (suffix?.type === 'simple_identifier') { + return { varName, calleeName: suffix.text }; + } + if (suffix?.type === 'navigation_suffix') { + const inner = suffix.lastNamedChild; + if (inner?.type === 'simple_identifier') { + return { varName, calleeName: inner.text }; + } + } + } + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, + scanConstructorBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index e874292cf9..29c76e7dcd 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -13,6 +13,17 @@ export type ClassNameLookup = { has(name: string): boolean }; /** Extracts type bindings from a constructor-call initializer, with access to known class names */ export type InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup) => void; +/** Scans an AST node for untyped `var = callee()` patterns for return-type inference. + * Returns { varName, calleeName } if the node matches, undefined otherwise. + * `receiverClassName` — optional hint for method calls on known receivers + * (e.g. $this->getUser() in PHP provides the enclosing class name). */ +export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string; calleeName: string; receiverClassName?: string } | undefined; + +/** Extracts a return type string from a method/function definition node. + * Used for languages where return types are expressed in comments (e.g. YARD @return [Type]) + * rather than in AST fields. Returns undefined if no return type can be determined. */ +export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined; + /** Per-language type extraction configuration */ export interface LanguageTypeConfig { /** Node types that represent typed declarations for this language */ @@ -26,4 +37,11 @@ export interface LanguageTypeConfig { * Only for languages with syntactic constructor markers (new, composite_literal, ::new). * Receives classNames — the set of class/struct names visible in the current file's AST. */ extractInitializer?: InitializerExtractor; + /** Scan for untyped `var = callee()` assignments for return-type inference. + * Called on every AST node during buildTypeEnv walk; returns undefined for non-matches. + * The callee binding is unverified — the caller must confirm against the SymbolTable. */ + scanConstructorBinding?: ConstructorBindingScanner; + /** Extract return type from comment-based annotations (e.g. YARD @return [Type]). + * Called as fallback when extractMethodSignature finds no AST-based return type. */ + extractReturnType?: ReturnTypeExtractor; } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 49e2f97234..86b357b057 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -1,14 +1,85 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; -import { extractSimpleTypeName, extractVarName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'lexical_declaration', 'variable_declaration', + 'function_declaration', // JSDoc @param on function declarations + 'method_definition', // JSDoc @param on class methods ]); -/** TypeScript: const x: Foo = ..., let x: Foo */ +const normalizeJsDocType = (raw: string): string | undefined => { + let type = raw.trim(); + // Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User + if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1); + // Strip union with null/undefined/void: User|null → User + const parts = type.split('|').map(p => p.trim()).filter(p => + p !== 'null' && p !== 'undefined' && p !== 'void' + ); + if (parts.length !== 1) return undefined; // ambiguous union + type = parts[0]; + // Strip module: prefix — module:models.User → models.User + if (type.startsWith('module:')) type = type.slice(7); + // Take last segment of dotted path: models.User → User + const segments = type.split('.'); + type = segments[segments.length - 1]; + // Strip generic wrapper: Promise → Promise (base type, not inner) + const genericMatch = type.match(/^(\w+)\s* => { + const commentTexts: string[] = []; + let sibling = funcNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + commentTexts.unshift(sibling.text); + } else if (sibling.isNamed && sibling.type !== 'decorator') { + break; + } + sibling = sibling.previousSibling; + } + if (commentTexts.length === 0) return new Map(); + + const params = new Map(); + const commentBlock = commentTexts.join('\n'); + JSDOC_PARAM_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = JSDOC_PARAM_RE.exec(commentBlock)) !== null) { + const typeName = normalizeJsDocType(match[1]); + const paramName = match[2]; + if (typeName) { + params.set(paramName, typeName); + } + } + return params; +}; + +/** + * TypeScript: const x: Foo = ..., let x: Foo + * Also: JSDoc @param annotations on function/method definitions (for .js files). + */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // JSDoc @param on functions/methods — pre-populate env with param types + if (node.type === 'function_declaration' || node.type === 'method_definition') { + const jsDocParams = collectJsDocParams(node); + for (const [paramName, typeName] of jsDocParams) { + if (!env.has(paramName)) env.set(paramName, typeName); + } + return; + } + for (let i = 0; i < node.namedChildCount; i++) { const declarator = node.namedChild(i); if (declarator?.type !== 'variable_declarator') continue; @@ -65,9 +136,66 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'variable_declarator') return undefined; + if (hasTypeAnnotation(node)) return undefined; + const nameNode = node.childForFieldName('name'); + if (!nameNode || nameNode.type !== 'identifier') return undefined; + const value = unwrapAwait(node.childForFieldName('value')); + if (!value || value.type !== 'call_expression') return undefined; + const calleeName = extractCalleeName(value); + if (!calleeName) return undefined; + return { varName: nameNode.text, calleeName }; +}; + +/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */ +const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/; + +/** + * Minimal sanitization for JSDoc return types — preserves generic wrappers + * (e.g. `Promise`) so that extractReturnTypeName in call-processor + * can apply WRAPPER_GENERICS unwrapping. Unlike normalizeJsDocType (which + * strips generics), this only strips JSDoc-specific syntax markers. + */ +const sanitizeReturnType = (raw: string): string | undefined => { + let type = raw.trim(); + // Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User + if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1); + // Strip module: prefix — module:models.User → models.User + if (type.startsWith('module:')) type = type.slice(7); + // Reject unions (ambiguous) + if (type.includes('|')) return undefined; + if (!type) return undefined; + return type; +}; + +/** + * Extract return type from JSDoc `@returns {Type}` or `@return {Type}` annotation + * preceding a function/method definition. Walks backwards through preceding siblings + * looking for comment nodes containing the annotation. + */ +const extractReturnType: ReturnTypeExtractor = (node) => { + let sibling = node.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = JSDOC_RETURN_RE.exec(sibling.text); + if (match) return sanitizeReturnType(match[1]); + } else if (sibling.isNamed && sibling.type !== 'decorator') break; + sibling = sibling.previousSibling; + } + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, + scanConstructorBinding, + extractReturnType, }; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 039024e18e..b0da07588e 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -618,9 +618,19 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met // Go: 'result' field is either a type_identifier or parameter_list (multi-return) const goResult = node.childForFieldName?.('result'); if (goResult) { - returnType = goResult.type === 'parameter_list' - ? goResult.text // multi-return: "(string, error)" - : goResult.text; // single return: "int" + if (goResult.type === 'parameter_list') { + // Multi-return: extract first parameter's type only (e.g. (*User, error) → *User) + const firstParam = goResult.firstNamedChild; + if (firstParam?.type === 'parameter_declaration') { + const typeNode = firstParam.childForFieldName('type'); + if (typeNode) returnType = typeNode.text; + } else if (firstParam) { + // Unnamed return types: (string, error) — first child is a bare type node + returnType = firstParam.text; + } + } else { + returnType = goResult.text; + } } // Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier). @@ -640,6 +650,14 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met } } + // C#: 'returns' field on method_declaration + if (!returnType) { + const csReturn = node.childForFieldName?.('returns'); + if (csReturn && csReturn.text !== 'void') { + returnType = csReturn.text; + } + } + // TS/Rust/Python/C#/Kotlin: type_annotation or return_type child if (!returnType) { for (const child of node.children) { @@ -701,6 +719,7 @@ const MEMBER_ACCESS_NODE_TYPES = new Set([ 'field_expression', // Rust/C++: obj.method() / ptr->method() 'selector_expression', // Go: obj.Method() 'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix + 'member_binding_expression', // C#: user?.Method() — null-conditional access ]); /** @@ -796,6 +815,7 @@ const SIMPLE_RECEIVER_TYPES = new Set([ 'super_expression', // Kotlin wraps super in super_expression 'base', // C# base.Method() 'parent', // PHP parent::method() + 'constant', // Ruby CONSTANT.method() (uppercase identifiers) ]); export const extractReceiverName = ( @@ -844,6 +864,14 @@ export const extractReceiverName = ( } } + // C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression + if (!receiver && parent.type === 'member_binding_expression') { + const condAccess = parent.parent; + if (condAccess?.type === 'conditional_access_expression') { + receiver = condAccess.firstNamedChild; + } + } + // Kotlin/Swift: navigation_expression target is the first child if (!receiver && parent.type === 'navigation_suffix') { const navExpr = parent.parent; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 768fdc9910..c74032ef51 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -37,6 +37,7 @@ import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { isNodeExported } from '../export-detection.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; +import { typeConfigs } from '../type-extractors/index.js'; import { generateId } from '../../../lib/utils.js'; import { extractNamedBindings } from '../named-binding-extraction.js'; import { appendKotlinWildcard } from '../resolvers/index.js'; @@ -79,6 +80,7 @@ interface ParsedSymbol { nodeId: string; type: string; parameterCount?: number; + returnType?: string; ownerId?: string; } @@ -1045,6 +1047,14 @@ const processFileGroup = ( const sig = extractMethodSignature(definitionNode); parameterCount = sig.parameterCount; returnType = sig.returnType; + + // Language-specific return type fallback (e.g. Ruby YARD @return [Type]) + if (!returnType && definitionNode) { + const tc = typeConfigs[language as keyof typeof typeConfigs]; + if (tc?.extractReturnType) { + returnType = tc.extractReturnType(definitionNode); + } + } } result.nodes.push({ @@ -1078,6 +1088,7 @@ const processFileGroup = ( nodeId, type: nodeLabel, ...(parameterCount !== undefined ? { parameterCount } : {}), + ...(returnType !== undefined ? { returnType } : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), }); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 57e120a481..e46c03c583 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -304,10 +304,11 @@ const fallbackRelationshipInserts = async ( const confidence = parseFloat(confidenceStr) || 1.0; const step = parseInt(stepStr) || 0; + const esc = (s: string) => s.replace(/'/g, "''").replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); await conn.query(` - MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}' }), - (b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}' }) - CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}', step: ${step}}]->(b) + MATCH (a:${escapeLabel(fromLabel)} {id: '${esc(fromId)}' }), + (b:${escapeLabel(toLabel)} {id: '${esc(toId)}' }) + CREATE (a)-[:${REL_TABLE_NAME} {type: '${esc(relType)}', confidence: ${confidence}, reason: '${esc(reason)}', step: ${step}}]->(b) `); } catch { // skip @@ -365,7 +366,7 @@ export const insertNodeToLbug = async ( if (v === null || v === undefined) return 'NULL'; if (typeof v === 'number') return String(v); // Escape backslashes first (for Windows paths), then single quotes - return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`; }; // Build INSERT query based on node type @@ -425,8 +426,8 @@ export const batchInsertNodesToLbug = async ( const escapeValue = (v: any): string => { if (v === null || v === undefined) return 'NULL'; if (typeof v === 'number') return String(v); - // Escape backslashes first (for Windows paths), then single quotes - return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + // Escape backslashes first (for Windows paths), then single quotes, then newlines + return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`; }; // Open a single connection for all inserts diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/app.cpp new file mode 100644 index 0000000000..60080a3495 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/app.cpp @@ -0,0 +1,20 @@ +#include "user.h" +#include "repo.h" + +User getUser(const char* name) { + return User(name); +} + +Repo getRepo(const char* name) { + return Repo(name); +} + +void processUser() { + auto user = getUser("alice"); + user.save(); +} + +void processRepo() { + auto repo = getRepo("main"); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/repo.h new file mode 100644 index 0000000000..6331d23eb5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/repo.h @@ -0,0 +1,9 @@ +#pragma once + +class Repo { +public: + Repo(const char* name) : name_(name) {} + bool save() { return true; } +private: + const char* name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/user.h new file mode 100644 index 0000000000..b1fb5928cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type-inference/user.h @@ -0,0 +1,9 @@ +#pragma once + +class User { +public: + User(const char* name) : name_(name) {} + bool save() { return true; } +private: + const char* name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp new file mode 100644 index 0000000000..6925e024e1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp @@ -0,0 +1,10 @@ +#include "user.h" + +User getUser(const char* name) { + return User(name); +} + +void processUser() { + auto user = getUser("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h new file mode 100644 index 0000000000..df2ca19188 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h @@ -0,0 +1,11 @@ +#pragma once + +class User { +public: + User(const char* name) : name_(name) {} + void save() {} +private: + const char* name_; +}; + +User getUser(const char* name); diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Order.cs b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Order.cs new file mode 100644 index 0000000000..0c5cfab310 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Order.cs @@ -0,0 +1,11 @@ +namespace CSharpAsyncBinding; + +public class Order +{ + public string Name { get; set; } + + public void Save() + { + // persist order + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/OrderService.cs new file mode 100644 index 0000000000..14eb7ad5bd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/OrderService.cs @@ -0,0 +1,9 @@ +namespace CSharpAsyncBinding; + +public class OrderService +{ + public async Task GetOrderAsync(string name) + { + return new Order { Name = name }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Program.cs new file mode 100644 index 0000000000..4da8b1505a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/Program.cs @@ -0,0 +1,24 @@ +namespace CSharpAsyncBinding; + +public class Program +{ + public static async Task Main(string[] args) + { + var userSvc = new UserService(); + var orderSvc = new OrderService(); + await ProcessUser(userSvc); + await ProcessOrder(orderSvc); + } + + public static async Task ProcessUser(UserService userSvc) + { + var user = await userSvc.GetUserAsync("alice"); + user.Save(); + } + + public static async Task ProcessOrder(OrderService orderSvc) + { + var order = await orderSvc.GetOrderAsync("bob"); + order.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/User.cs new file mode 100644 index 0000000000..218a5e8473 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/User.cs @@ -0,0 +1,11 @@ +namespace CSharpAsyncBinding; + +public class User +{ + public string Name { get; set; } + + public void Save() + { + // persist user + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/UserService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/UserService.cs new file mode 100644 index 0000000000..bf221e6517 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-async-binding/UserService.cs @@ -0,0 +1,9 @@ +namespace CSharpAsyncBinding; + +public class UserService +{ + public async Task GetUserAsync(string name) + { + return new User { Name = name }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs new file mode 100644 index 0000000000..8869ba372e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs @@ -0,0 +1,16 @@ +using Models; + +namespace App; + +public class AppService +{ + public void Process() + { + User user = new User(); + Repo repo = new Repo(); + + // Null-conditional calls — should disambiguate via receiver type + user?.Save(); + repo?.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs new file mode 100644 index 0000000000..4b19312d8f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Repo +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs new file mode 100644 index 0000000000..2d2ffe30a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/Repo.cs new file mode 100644 index 0000000000..3a5528cb10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace ReturnType.Models; + +public class Repo +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs new file mode 100644 index 0000000000..453c83460d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs @@ -0,0 +1,24 @@ +namespace ReturnType.Models; + +public class User +{ + private string _name; + + public User(string name) + { + _name = name; + } + + public bool Save() + { + return true; + } +} + +public class UserService +{ + public User GetUser(string name) + { + return new User(name); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/ReturnType.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/ReturnType.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/ReturnType.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs new file mode 100644 index 0000000000..c3ccadac7b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs @@ -0,0 +1,13 @@ +using ReturnType.Models; + +namespace ReturnType.Services; + +public class App +{ + public void Run() + { + var svc = new UserService(); + var user = svc.GetUser("alice"); + user.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/cmd/main.go new file mode 100644 index 0000000000..5d88f68c99 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/cmd/main.go @@ -0,0 +1,24 @@ +package main + +import "example.com/multireturn/models" + +func NewUser(name string) (*models.User, error) { + return &models.User{Name: name}, nil +} + +func NewRepo(name string) (*models.Repo, error) { + return &models.Repo{Name: name}, nil +} + +func processUser() { + user, err := NewUser("alice") + if err != nil { + return + } + user.Save() +} + +func processRepo() { + repo, _ := NewRepo("main") + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/go.mod b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/go.mod new file mode 100644 index 0000000000..31de69be1a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/go.mod @@ -0,0 +1,3 @@ +module example.com/multireturn + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/repo.go new file mode 100644 index 0000000000..5abb2e7d82 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/repo.go @@ -0,0 +1,9 @@ +package models + +type Repo struct { + Name string +} + +func (r *Repo) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/user.go new file mode 100644 index 0000000000..0e78a30a83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-return-inference/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go new file mode 100644 index 0000000000..fda782be15 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go @@ -0,0 +1,27 @@ +package main + +import "example.com/returntype/models" + +func GetUser(name string) *models.User { + return &models.User{Name: name} +} + +func processUser() { + user := GetUser("alice") + user.Save() +} + +// Cross-package factory call: models.NewUser() uses selector_expression in the AST +func processUserCrossPackage() { + user := models.NewUser("bob") + user.Save() +} + +func GetRepo(name string) *models.Repo { + return &models.Repo{Name: name} +} + +func processRepo() { + repo := GetRepo("main") + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/go.mod b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/go.mod new file mode 100644 index 0000000000..9cdbfe303a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/go.mod @@ -0,0 +1,3 @@ +module example.com/returntype + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/repo.go new file mode 100644 index 0000000000..69bbe0895c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/repo.go @@ -0,0 +1,13 @@ +package models + +type Repo struct { + Name string +} + +func (r *Repo) Save() bool { + return true +} + +func GetRepo(name string) *Repo { + return &Repo{Name: name} +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go new file mode 100644 index 0000000000..c3714ff74d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go @@ -0,0 +1,13 @@ +package models + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} + +func NewUser(name string) *User { + return &User{Name: name} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/App.java b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/App.java new file mode 100644 index 0000000000..135a511d4e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/App.java @@ -0,0 +1,9 @@ +import services.UserService; + +public class App { + public static void processUser() { + UserService svc = new UserService(); + var user = svc.getUser("alice"); + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/models/User.java new file mode 100644 index 0000000000..9d605797e3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/models/User.java @@ -0,0 +1,13 @@ +package models; + +public class User { + private String name; + + public User(String name) { + this.name = name; + } + + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/services/UserService.java new file mode 100644 index 0000000000..56975c53e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-return-type-inference/services/UserService.java @@ -0,0 +1,9 @@ +package services; + +import models.User; + +public class UserService { + public User getUser(String name) { + return new User(name); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js new file mode 100644 index 0000000000..01b8de1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js @@ -0,0 +1,26 @@ +const { User } = require('./user'); +const { Repo } = require('./repo'); + +/** + * @returns {Promise} + */ +async function fetchUser(name) { + return new User(name); +} + +/** + * @returns {Promise} + */ +async function fetchRepo(path) { + return new Repo(path); +} + +async function processUser() { + const user = await fetchUser('alice'); + user.save(); +} + +async function processRepo() { + const repo = await fetchRepo('/data'); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js new file mode 100644 index 0000000000..a85e9c39a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js @@ -0,0 +1,11 @@ +class Repo { + constructor(path) { + this.path = path; + } + + save() { + return true; + } +} + +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js new file mode 100644 index 0000000000..7f19a622d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js new file mode 100644 index 0000000000..8b1473dabe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js @@ -0,0 +1,13 @@ +const { User } = require('./user'); + +/** + * @returns {Promise} + */ +async function fetchUser(name) { + return new User(name); +} + +async function processUser() { + const user = await fetchUser('alice'); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js new file mode 100644 index 0000000000..7f19a622d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js new file mode 100644 index 0000000000..fc60b05ca8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js @@ -0,0 +1,40 @@ +const { User } = require('./user'); +const { Repo } = require('./repo'); + +/** + * @returns {User} + */ +function getUser(name) { + return new User(name); +} + +/** + * @returns {Repo} + */ +function getRepo(path) { + return new Repo(path); +} + +function processUser() { + const user = getUser('alice'); + user.save(); +} + +function processRepo() { + const repo = getRepo('/data'); + repo.save(); +} + +/** + * @param {User} user the user to handle + */ +function handleUser(user) { + user.save(); +} + +/** + * @param {Repo} repo the repo to handle + */ +function handleRepo(repo) { + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/repo.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/repo.js new file mode 100644 index 0000000000..a85e9c39a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/repo.js @@ -0,0 +1,11 @@ +class Repo { + constructor(path) { + this.path = path; + } + + save() { + return true; + } +} + +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/user.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/user.js new file mode 100644 index 0000000000..7f19a622d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/user.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/Repo.kt new file mode 100644 index 0000000000..23159e2cc1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/Repo.kt @@ -0,0 +1,9 @@ +package models + +class Repo(val name: String) { + fun save() {} +} + +fun getRepo(name: String): Repo { + return Repo(name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt new file mode 100644 index 0000000000..e3a3e71eca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt @@ -0,0 +1,9 @@ +package models + +class User(val name: String) { + fun save() {} +} + +fun getUser(name: String): User { + return User(name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt new file mode 100644 index 0000000000..ecd4bb264c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt @@ -0,0 +1,14 @@ +package services + +import models.getUser +import models.getRepo + +fun processUser() { + val user = getUser("alice") + user.save() +} + +fun processRepo() { + val repo = getRepo("main") + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-phpdoc-attribute-return-type/Models.php b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-attribute-return-type/Models.php new file mode 100644 index 0000000000..997516ec83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-attribute-return-type/Models.php @@ -0,0 +1,8 @@ +getUser("alice"); + $user->save(); + } + + public function processRepo() { + $repo = $this->getRepo("/data"); + $repo->save(); + } + + /** + * @param User $user the user to handle + */ + #[Validate] + public function handleUser($user) { + $user->save(); + } + + /** + * @param Repo $repo the repo to handle + */ + #[Validate] + public function handleRepo($repo) { + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php new file mode 100644 index 0000000000..997516ec83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php @@ -0,0 +1,8 @@ +getUser("alice"); + $user->save(); + } + + public function processRepo() { + $repo = $this->getRepo("/data"); + $repo->save(); + } + + /** + * @param User $user the user to handle + */ + public function handleUser($user) { + $user->save(); + } + + /** + * @param Repo $repo the repo to handle + */ + public function handleRepo($repo) { + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/Repo.php new file mode 100644 index 0000000000..7094638a7a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/Repo.php @@ -0,0 +1,9 @@ +name = $name; + } + + public function save(): bool { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php new file mode 100644 index 0000000000..6f366dbceb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php @@ -0,0 +1,16 @@ +getUser("alice"); + $user->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/AdminService.php b/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/AdminService.php new file mode 100644 index 0000000000..5d4be53fbd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/AdminService.php @@ -0,0 +1,14 @@ +getUser("admin"); + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/Models.php b/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/Models.php new file mode 100644 index 0000000000..997516ec83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-this-receiver-disambiguation/Models.php @@ -0,0 +1,8 @@ +getUser("alice"); + $user->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/app.py b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/app.py new file mode 100644 index 0000000000..73f8b4764e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/app.py @@ -0,0 +1,5 @@ +from service import get_user + +def process_user(): + user = get_user('alice') + user.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/models.py b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/models.py new file mode 100644 index 0000000000..c0ddac39f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/models.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, name: str): + self.name = name + + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/service.py b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/service.py new file mode 100644 index 0000000000..7bf2641bd3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-return-type-inference/service.py @@ -0,0 +1,4 @@ +from models import User + +def get_user(name: str) -> User: + return User(name) diff --git a/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/app.py b/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/app.py new file mode 100644 index 0000000000..36beddf0c1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/app.py @@ -0,0 +1,10 @@ +from service import UserService, AdminService + + +def process(): + user = UserService.find_user("alice") + UserService.create_user("bob") + svc = UserService.from_config({}) + + AdminService.find_user("charlie") + AdminService.delete_user("charlie") diff --git a/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/service.py b/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/service.py new file mode 100644 index 0000000000..88add535ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-static-class-methods/service.py @@ -0,0 +1,22 @@ +class UserService: + @staticmethod + def find_user(name: str) -> str: + return name + + @staticmethod + def create_user(name: str) -> str: + return name + + @classmethod + def from_config(cls, config: dict) -> "UserService": + return cls() + + +class AdminService: + @staticmethod + def find_user(name: str) -> str: + return name + + @staticmethod + def delete_user(name: str) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/admin_service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/admin_service.rb new file mode 100644 index 0000000000..257322f788 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/admin_service.rb @@ -0,0 +1,9 @@ +class AdminService + def process + true + end + + def validate + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb new file mode 100644 index 0000000000..c87e32d6f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb @@ -0,0 +1,11 @@ +require_relative 'user_service' +require_relative 'admin_service' + +# @return [UserService] +def build_service + UserService.new +end + +SERVICE = build_service() +SERVICE.process +SERVICE.validate diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb new file mode 100644 index 0000000000..0880185717 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb @@ -0,0 +1,9 @@ +class UserService + def process + true + end + + def validate + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/app.rb new file mode 100644 index 0000000000..3f4a70dc69 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/app.rb @@ -0,0 +1,5 @@ +require_relative 'models/user_service' + +svc = Models::UserService.new +svc.process('alice') +svc.validate diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/models/user_service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/models/user_service.rb new file mode 100644 index 0000000000..472fd0f53e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/models/user_service.rb @@ -0,0 +1,11 @@ +module Models + class UserService + def process(name) + name.upcase + end + + def validate + true + end + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb new file mode 100644 index 0000000000..a092e4fc25 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb @@ -0,0 +1,12 @@ +require_relative 'models' +require_relative 'repo' + +def process_user + user = get_user('alice') + user.save +end + +def process_repo + repo = get_repo('/data') + repo.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb new file mode 100644 index 0000000000..e06220f3b5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb @@ -0,0 +1,14 @@ +class User + def initialize(name) + @name = name + end + + def save + true + end +end + +# @return [User] +def get_user(name) + User.new(name) +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb new file mode 100644 index 0000000000..7268cc96f4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb @@ -0,0 +1,14 @@ +class Repo + def initialize(path) + @path = path + end + + def save + true + end +end + +# @return [Repo] +def get_repo(path) + Repo.new(path) +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/models.rb new file mode 100644 index 0000000000..df48a59bd5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/models.rb @@ -0,0 +1,15 @@ +class UserRepo + def save + true + end + + def find_by_name(name) + true + end +end + +class User + def greet + "hello" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/service.rb new file mode 100644 index 0000000000..28c3c9abaa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-annotations/service.rb @@ -0,0 +1,11 @@ +require_relative './models' + +class UserService + # @param repo [UserRepo] the repository + # @param user [User] the user to create + # @return [Boolean] + def create(repo, user) + repo.save + user.greet + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb new file mode 100644 index 0000000000..a2082d0f9e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb @@ -0,0 +1,19 @@ +class UserRepo + def save + true + end + + def find_all + [] + end +end + +class AdminRepo + def save + true + end + + def find_all + [] + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb new file mode 100644 index 0000000000..5d18dda1d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb @@ -0,0 +1,16 @@ +require_relative './models' + +class DataService + # @param repo [UserRepo] the user repository + # @param cache [Hash] cache of repos by symbol key + def sync(repo, cache) + repo.save + repo.find_all + end + + # @param [AdminRepo] admin_repo the admin repository (alternate YARD order) + def audit(admin_repo) + admin_repo.save + admin_repo.find_all + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/main.rs new file mode 100644 index 0000000000..7e853beab3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/main.rs @@ -0,0 +1,23 @@ +mod user; +mod repo; + +use user::User; +use repo::Repo; + +async fn get_user() -> User { + User { name: String::from("alice") } +} + +async fn get_repo() -> Repo { + Repo { name: String::from("main") } +} + +async fn process_user() { + let user = get_user().await; + user.save(); +} + +async fn process_repo() { + let repo = get_repo().await; + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/repo.rs new file mode 100644 index 0000000000..76156e15df --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/repo.rs @@ -0,0 +1,9 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/user.rs new file mode 100644 index 0000000000..31e047e0e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-async-binding/src/user.rs @@ -0,0 +1,9 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/main.rs new file mode 100644 index 0000000000..f9bb13366b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/main.rs @@ -0,0 +1,20 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process_with_new() { + let user = User::new(); + let repo = Repo::new(); + user.save(); + repo.save(); +} + +fn process_with_default() { + let user = User::default(); + let repo = Repo::default(); + user.save(); + repo.save(); +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/repo.rs new file mode 100644 index 0000000000..30093cee05 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/repo.rs @@ -0,0 +1,17 @@ +pub struct Repo; + +impl Repo { + pub fn new() -> Self { + Repo + } + + pub fn save(&self) -> bool { + true + } +} + +impl Default for Repo { + fn default() -> Self { + Repo + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/user.rs new file mode 100644 index 0000000000..8c9e99fb32 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-default-constructor/src/user.rs @@ -0,0 +1,17 @@ +pub struct User; + +impl User { + pub fn new() -> Self { + User + } + + pub fn save(&self) -> bool { + true + } +} + +impl Default for User { + fn default() -> Self { + User + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/main.rs new file mode 100644 index 0000000000..984621603e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/main.rs @@ -0,0 +1,21 @@ +mod models; + +use models::{User, Repo}; + +fn get_user() -> User { + User { name: String::from("alice") } +} + +fn get_repo() -> Repo { + Repo { name: String::from("main") } +} + +fn process_user() { + let user = get_user(); + user.save(); +} + +fn process_repo() { + let repo = get_repo(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/models.rs new file mode 100644 index 0000000000..05659ee6c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type-inference/src/models.rs @@ -0,0 +1,19 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } +} + +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs new file mode 100644 index 0000000000..f4f40ec5a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs @@ -0,0 +1,7 @@ +mod models; +use crate::models::get_user; + +fn main() { + let user = get_user("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs new file mode 100644 index 0000000000..85ce57013f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs @@ -0,0 +1,11 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +pub fn get_user(name: &str) -> User { + User { name: name.to_string() } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/App.swift new file mode 100644 index 0000000000..5893a35c3f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/App.swift @@ -0,0 +1,17 @@ +func getUser() -> User { + return User(name: "alice") +} + +func getRepo() -> Repo { + return Repo(name: "main") +} + +func processUser() { + let user = getUser() + user.save() +} + +func processRepo() { + let repo = getRepo() + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/Models.swift new file mode 100644 index 0000000000..a849549902 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type-inference/Models.swift @@ -0,0 +1,11 @@ +class User { + var name: String + init(name: String) { self.name = name } + func save() -> Bool { return true } +} + +class Repo { + var name: String + init(name: String) { self.name = name } + func save() -> Bool { return true } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift new file mode 100644 index 0000000000..edf1600d96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift @@ -0,0 +1,4 @@ +func processUser() { + let user = getUser(name: "alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift new file mode 100644 index 0000000000..69a9139c41 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift @@ -0,0 +1,13 @@ +class User { + let name: String + + init(name: String) { + self.name = name + } + + func save() {} +} + +func getUser(name: String) -> User { + return User(name: name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/app.ts new file mode 100644 index 0000000000..f43cda0884 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/app.ts @@ -0,0 +1,11 @@ +import { getUser, fetchUserAsync } from './service'; + +function processUser() { + const user = getUser('alice'); + user.save(); +} + +async function processUserAsync() { + const user = await fetchUserAsync('bob'); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/models.ts new file mode 100644 index 0000000000..6ba96088c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/models.ts @@ -0,0 +1,15 @@ +export class User { + name: string; + + constructor(name: string) { + this.name = name; + } + + save(): boolean { + return true; + } + + getName(): string { + return this.name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/service.ts new file mode 100644 index 0000000000..25c9d27db5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-return-type-inference/service.ts @@ -0,0 +1,9 @@ +import { User } from './models'; + +export function getUser(name: string): User { + return new User(name); +} + +export function fetchUserAsync(name: string): Promise { + return Promise.resolve(new User(name)); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 41eaa5c5e6..09e5ed50b8 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -477,3 +477,81 @@ describe('C++ range-for explicit type resolution', () => { expect(edge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: auto user = getUser("alice"); user.save() +// C++'s CONSTRUCTOR_BINDING_SCANNER captures auto declarations with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe('C++ return type inference via auto + function call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and getUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('detects save method on User', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + }); + + it('resolves user.save() to User#save via return type of getUser(): User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.h'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Return-type inference with competing methods: +// Two classes both have save(), factory functions disambiguate via return type +// --------------------------------------------------------------------------- + +describe('C++ return-type inference via function return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-return-type-inference'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via return type of getUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.h') + ); + expect(saveCall).toBeDefined(); + }); + + it('user.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'processUser' + ); + // Should resolve to exactly one target — if it resolves at all, check it's the right one + if (wrongSave) { + expect(wrongSave.targetFilePath).toContain('user.h'); + } + }); + + it('resolves repo.save() to Repo#save via return type of getRepo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.h') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index db6345cc6f..5c7abdc1f9 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -558,3 +558,164 @@ describe('C# is pattern matching resolution', () => { expect(catExtends!.target).toBe('Animal'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: var user = svc.GetUser("alice"); user.Save() +// C#'s CONSTRUCTOR_BINDING_SCANNER handles `var` declarations with +// invocation_expression values, enabling end-to-end return type inference. +// --------------------------------------------------------------------------- + +describe('C# return type inference via var + invocation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-return-type'), + () => {}, + ); + }, 60000); + + it('detects User, UserService, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('detects Save on both User and Repo, plus GetUser', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('Save'); + expect(methods).toContain('GetUser'); + // Repo.Save is also detected, proving the disambiguation test is meaningful + expect(methods.filter((m: string) => m === 'Save').length).toBe(2); + }); + + it('resolves user.Save() to User#Save (not Repo#Save) via return type of GetUser(): User', () => { + // scanConstructorBinding binds `var user = svc.GetUser()` → calleeName "GetUser". + // processCallsFromExtracted verifies GetUser's returnType is "User" via + // PackageMap resolution of `using ReturnType.Models;`, then receiver filtering + // resolves user.Save() to User#Save (not Repo#Save). + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'Run' && c.targetFilePath.includes('User.cs'), + ); + expect(saveCall).toBeDefined(); + // Must NOT resolve to Repo.Save — that would mean disambiguation failed + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'Run' && c.targetFilePath.includes('Repo.cs'), + ); + expect(repoSave).toBeUndefined(); + }); +}); + +describe('C# null-conditional call resolution (user?.Save())', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-null-conditional'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing Save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('captures null-conditional user?.Save() call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'Process'); + expect(saveCalls.length).toBeGreaterThan(0); + }); + + it('resolves user?.Save() to User#Save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'Process' && c.targetFilePath.includes('User.cs'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo?.Save() to Repo#Save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'Process' && c.targetFilePath.includes('Repo.cs'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (exactly 1 Save per receiver file)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'Process'); + const userTargeted = saveCalls.filter(c => c.targetFilePath.includes('User.cs')); + const repoTargeted = saveCalls.filter(c => c.targetFilePath.includes('Repo.cs')); + expect(userTargeted.length).toBe(1); + expect(repoTargeted.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// C# async/await constructor binding resolution +// Verifies that `var user = await svc.GetUserAsync()` correctly unwraps the +// await_expression to find the invocation_expression underneath, producing a +// constructor binding that enables receiver-based disambiguation of user.Save(). +// --------------------------------------------------------------------------- + +describe('C# async await constructor binding resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-async-binding'), + () => {}, + ); + }, 60000); + + it('detects User, UserService, and OrderService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('UserService'); + expect(classes).toContain('OrderService'); + }); + + it('detects competing Save methods on User and Order', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('Save'); + expect(methods).toContain('GetUserAsync'); + expect(methods).toContain('GetOrderAsync'); + }); + + it('resolves user.Save() after await to User#Save via return type inference', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessUser' && c.targetFilePath.includes('User.cs'), + ); + expect(userSave).toBeDefined(); + }); + + it('user.Save() does NOT resolve to Order#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessUser' && c.targetFilePath.includes('Order.cs'), + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves order.Save() after await to Order#Save via return type inference', () => { + const calls = getRelationships(result, 'CALLS'); + const orderSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessOrder' && c.targetFilePath.includes('Order.cs'), + ); + expect(orderSave).toBeDefined(); + }); + + it('order.Save() does NOT resolve to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessOrder' && c.targetFilePath.includes('User.cs'), + ); + expect(wrongSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index f90f420d83..f2be3460c9 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -541,3 +541,120 @@ describe('Go type assertion type inference', () => { expect(greetCall!.source).toBe('process'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: user := GetUser("alice"); user.Save() +// Go now has a CONSTRUCTOR_BINDING_SCANNER for short_var_declaration, so +// return type inference works end-to-end for `user := GetUser()`. +// --------------------------------------------------------------------------- + +describe('Go return type inference via explicit function return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-return-type-inference'), + () => {}, + ); + }, 60000); + + it('detects GetUser, GetRepo, and competing Save methods', () => { + const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')]; + expect(allSymbols).toContain('GetUser'); + expect(allSymbols).toContain('GetRepo'); + const saveMethods = allSymbols.filter(s => s === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() to models/user.go via return type of GetUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('user.go') + ); + expect(saveCall).toBeDefined(); + }); + + it('user.Save() does NOT resolve to models/repo.go (negative disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('repo.go') + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves repo.Save() to models/repo.go via return type of GetRepo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.go') + ); + expect(saveCall).toBeDefined(); + }); + + it('repo.Save() does NOT resolve to models/user.go (negative disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'processRepo' && c.targetFilePath.includes('user.go') + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves user.Save() via cross-package factory call models.NewUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'processUserCrossPackage' && c.targetFilePath.includes('user.go') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Go multi-return factory inference: user, err := NewUser("alice"); user.Save() +// --------------------------------------------------------------------------- + +describe('Go multi-return factory type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-multi-return-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs with competing Save methods', () => { + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() to models/user.go via multi-return inference (user, err := NewUser())', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('user.go') + ); + expect(userSave).toBeDefined(); + }); + + it('user.Save() does NOT resolve to models/repo.go', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('repo.go') + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves repo.Save() to models/repo.go via blank discard (repo, _ := NewRepo())', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.go') + ); + expect(repoSave).toBeDefined(); + }); + + it('repo.Save() does NOT resolve to models/user.go', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'Save' && c.source === 'processRepo' && c.targetFilePath.includes('user.go') + ); + expect(wrongSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 371382c0e6..2b0fd622f7 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -526,3 +526,42 @@ describe('Java generic parent super resolution', () => { expect(repoSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: var user = svc.getUser("alice"); user.save() +// Java's CONSTRUCTOR_BINDING_SCANNER handles `var` declarations with +// method_invocation values, enabling end-to-end return type inference. +// --------------------------------------------------------------------------- + +describe('Java return type inference via explicit method return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-return-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('detects save and getUser methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('getUser'); + }); + + it('resolves user.save() to User#save via return type of getUser(): User', () => { + // Java's CONSTRUCTOR_BINDING_SCANNER binds `var user = svc.getUser()` to the + // return type of getUser (User), so the subsequent user.save() call resolves + // to User#save rather than an unresolved target. + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 4ee89971b7..fe61412da2 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -458,6 +458,61 @@ describe('Kotlin this resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Return type inference: val user = getUser("alice"); user.save() +// Kotlin's CONSTRUCTOR_BINDING_SCANNER captures property_declaration with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe('Kotlin return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save'); + expect(saveFns.length).toBe(2); + }); + + // Known gap: Kotlin return-type disambiguation does not yet resolve competing + // same-named methods. With two save() functions (User#save, Repo#save), the + // resolver correctly refuses to emit an ambiguous edge — but it also cannot + // narrow to the correct target via return type inference. This gap needs + // investigation into whether Kotlin import resolution + scanConstructorBinding + // produces verified receiver bindings end-to-end. + it('does not emit spurious save() edges when disambiguation fails', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processUser', + ); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processRepo', + ); + // With two competing save() methods and no working disambiguation, + // the resolver should refuse to emit edges (no false positives). + // When Kotlin return-type inference is fixed, update these to expect + // the edges to be defined and point to the correct files. + if (!userSave) { + expect(userSave).toBeUndefined(); + } else { + // If disambiguation starts working, verify it points to the right file + expect(userSave.targetFilePath).toContain('User.kt'); + } + if (!repoSave) { + expect(repoSave).toBeUndefined(); + } else { + expect(repoSave.targetFilePath).toContain('Repo.kt'); + } + }); +}); + // --------------------------------------------------------------------------- // Parent class resolution: EXTENDS + IMPLEMENTS edges // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 2803f8d143..28b91f3d40 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -695,3 +695,190 @@ describe('PHP typed class property resolution', () => { expect(saveCall!.targetFilePath).toBe('app/Models/UserRepo.php'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: $user = $this->getUser("alice"); $user->save() +// PHP's scanConstructorBinding captures assignment_expression with both +// function_call_expression and member_call_expression values, enabling +// return type inference for method calls on objects. +// --------------------------------------------------------------------------- + +describe('PHP return type inference via member call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-return-type'), + () => {}, + ); + }, 60000); + + it('detects User, UserService, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('detects save on both User and Repo, and getUser method', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('getUser'); + // save exists on both User and Repo — disambiguation required + expect(methods.filter((m: string) => m === 'save').length).toBe(2); + }); + + it('resolves $user->save() to User#save (not Repo#save) via return type of getUser(): User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User.php'), + ); + expect(saveCall).toBeDefined(); + // Must NOT resolve to Repo.save — that would mean disambiguation failed + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Repo.php'), + ); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PHPDoc @return annotation: return type inference without native type hints +// --------------------------------------------------------------------------- + +describe('PHP return type inference via PHPDoc @return annotation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-phpdoc-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves $user->save() to User#save via PHPDoc @return User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $repo->save() to Repo#save via PHPDoc @return Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $user->save() via PHPDoc @param User $user in handleUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $repo->save() via PHPDoc @param Repo $repo in handleRepo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PHPDoc @return with PHP 8+ attributes (#[Route]) between doc-comment and method +// --------------------------------------------------------------------------- + +describe('PHP PHPDoc @return with attributes between comment and method', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-phpdoc-attribute-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves $user->save() to User#save despite #[Route] attribute between PHPDoc and method', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $repo->save() to Repo#save despite #[Route] attribute between PHPDoc and method', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $user->save() via PHPDoc @param despite #[Validate] attribute', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $repo->save() via PHPDoc @param despite #[Validate] attribute', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// $this->method() receiver disambiguation: two classes with same method name +// --------------------------------------------------------------------------- + +describe('PHP $this->method() receiver disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-this-receiver-disambiguation'), + () => {}, + ); + }, 60000); + + it('detects UserService and AdminService classes, both with getUser methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); + const getUserMethods = getNodesByLabel(result, 'Method').filter(m => m === 'getUser'); + expect(getUserMethods.length).toBe(2); + }); + + it('resolves $user->save() in UserService to User#save via $this->getUser() disambiguation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves $repo->save() in AdminService to Repo#save via $this->getUser() disambiguation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processAdmin' && c.targetFilePath.includes('Models.php'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index c366700704..c2ce2dd463 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -616,3 +616,107 @@ describe('Python class-level annotation resolution', () => { expect(saveCalls.length).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: user = get_user('alice'); user.save() +// Python's scanner captures ALL call assignments, enabling return type inference. +// --------------------------------------------------------------------------- + +describe('Python return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-return-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('detects get_user and save symbols', () => { + // Python methods inside classes may be labeled Method or Function depending on nesting + const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')]; + expect(allSymbols).toContain('get_user'); + expect(allSymbols).toContain('save'); + }); + + it('resolves user.save() to User#save via return type inference from get_user() -> User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' + ); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toContain('models.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #289: static/classmethod classes must have HAS_METHOD edges +// --------------------------------------------------------------------------- + +describe('Python static/classmethod class resolution (issue #289)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-static-class-methods'), + () => {}, + ); + }, 60000); + + it('detects UserService and AdminService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); + }); + + it('detects all static/class methods as symbols', () => { + const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')]; + expect(allSymbols).toContain('find_user'); + expect(allSymbols).toContain('create_user'); + expect(allSymbols).toContain('from_config'); + expect(allSymbols).toContain('delete_user'); + }); + + it('emits HAS_METHOD edges linking static methods to their enclosing class', () => { + // This is the core of issue #289: without HAS_METHOD, context() and impact() + // return empty for classes whose methods are all @staticmethod/@classmethod + const hasMethod = getRelationships(result, 'HAS_METHOD'); + + const userServiceMethods = hasMethod.filter(e => e.source === 'UserService'); + expect(userServiceMethods.length).toBeGreaterThanOrEqual(3); // find_user, create_user, from_config + + const adminServiceMethods = hasMethod.filter(e => e.source === 'AdminService'); + expect(adminServiceMethods.length).toBeGreaterThanOrEqual(2); // find_user, delete_user + }); + + it('resolves unique static method calls (create_user, delete_user, from_config)', () => { + const calls = getRelationships(result, 'CALLS'); + // delete_user is unique to AdminService — should resolve + const deleteCall = calls.find(c => + c.target === 'delete_user' && c.source === 'process' && c.targetFilePath.includes('service.py'), + ); + expect(deleteCall).toBeDefined(); + + // create_user is unique to UserService — should resolve + const createCall = calls.find(c => + c.target === 'create_user' && c.source === 'process' && c.targetFilePath.includes('service.py'), + ); + expect(createCall).toBeDefined(); + }); + + it('does not emit ambiguous find_user() when both classes define it (known limitation)', () => { + // UserService.find_user() and AdminService.find_user() are ambiguous — the pipeline + // refuses to guess. Static method calls like ClassName.method() don't have a typed + // receiver variable, so receiver-constrained disambiguation doesn't apply. + // This is expected: no false edges is better than wrong edges. + const calls = getRelationships(result, 'CALLS'); + const findCalls = calls.filter(c => + c.target === 'find_user' && c.source === 'process', + ); + // Either 0 (refused ambiguous) or 2 (both resolved) — not 1 (wrong guess) + expect(findCalls.length === 0 || findCalls.length === 2).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 360289bd87..96eba10209 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -505,3 +505,253 @@ describe('Ruby constant constructor binding resolution', () => { expect(validateCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// YARD annotation type resolution: @param repo [UserRepo] → repo.save resolves +// --------------------------------------------------------------------------- + +describe('Ruby YARD annotation type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-yard-annotations'), + () => {}, + ); + }, 60000); + + it('detects UserRepo, User, and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('detects save, find_by_name, greet, and create methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('find_by_name'); + expect(methods).toContain('greet'); + expect(methods).toContain('create'); + }); + + it('resolves repo.save to UserRepo#save via YARD @param annotation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'create'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toContain('models.rb'); + }); + + it('resolves user.greet to User#greet via YARD @param annotation', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'greet' && c.source === 'create'); + expect(greetCall).toBeDefined(); + expect(greetCall!.targetFilePath).toContain('models.rb'); + }); +}); + +// --------------------------------------------------------------------------- +// Namespaced constructor: svc = Models::UserService.new; svc.process() +// Tests scope_resolution receiver handling for Ruby namespaced classes. +// --------------------------------------------------------------------------- + +describe('Ruby namespaced constructor resolution (Models::UserService.new)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-namespaced-constructor'), + () => {}, + ); + }, 60000); + + it('detects UserService class with process and validate methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('process'); + expect(methods).toContain('validate'); + }); + + it('resolves svc.process() via namespaced constructor Models::UserService.new', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find(c => + c.target === 'process' && c.targetFilePath.includes('user_service.rb') + ); + expect(processCall).toBeDefined(); + }); + + it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => + c.target === 'validate' && c.targetFilePath.includes('user_service.rb') + ); + expect(validateCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Return type inference: user = get_user('alice'); user.save +// Ruby's scanConstructorBinding captures assignment nodes with call RHS. +// Combined with YARD @return annotation parsing, the pipeline resolves +// `user.save` to User#save (not Repo#save) via return type disambiguation. +// The fixture has BOTH User#save and Repo#save — fuzzy matching alone +// cannot disambiguate, so return type inference must be working. +// --------------------------------------------------------------------------- + +describe('Ruby return type inference via function call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('detects get_user and get_repo methods', () => { + expect(getNodesByLabel(result, 'Method')).toContain('get_user'); + expect(getNodesByLabel(result, 'Method')).toContain('get_repo'); + }); + + it('detects save method on both User and Repo (disambiguation required)', () => { + const methods = getNodesByLabel(result, 'Method'); + // Both classes have save — fuzzy match alone cannot resolve this + expect(methods.filter(m => m === 'save').length).toBeGreaterThanOrEqual(2); + }); + + it('resolves user.save to User#save via YARD @return [User] on get_user()', () => { + // With both User#save and Repo#save in scope, resolving user.save + // requires return type inference: get_user() → @return [User] → user is User + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models.rb'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('repo.rb'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby constant LHS factory call: SERVICE = build_service() with YARD @return +// Verifies that constant assignments (uppercase LHS) from plain function calls +// are captured by scanConstructorBinding, not just identifier assignments. +// --------------------------------------------------------------------------- + +describe('Ruby constant factory call resolution (SERVICE = build_service())', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-constant-factory-call'), + () => {}, + ); + }, 60000); + + it('detects UserService and AdminService classes with process and validate methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); + expect(getNodesByLabel(result, 'Method')).toContain('process'); + expect(getNodesByLabel(result, 'Method')).toContain('validate'); + }); + + it('resolves SERVICE.process() to UserService#process via constant factory call', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find(c => + c.target === 'process' && c.targetFilePath.includes('user_service.rb'), + ); + expect(processCall).toBeDefined(); + const wrongCall = calls.find(c => + c.target === 'process' && + c.sourceFilePath?.includes('app.rb') && + c.targetFilePath.includes('admin_service.rb'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => + c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), + ); + expect(validateCall).toBeDefined(); + const wrongCall = calls.find(c => + c.target === 'validate' && + c.sourceFilePath?.includes('app.rb') && + c.targetFilePath.includes('admin_service.rb'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); + +describe('Ruby YARD generic type annotations (Hash)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-yard-generics'), + () => {}, + ); + }, 60000); + + it('detects UserRepo, AdminRepo, and DataService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('DataService'); + }); + + it('detects save and find_all on both repos, plus sync and audit methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('find_all'); + expect(methods).toContain('sync'); + expect(methods).toContain('audit'); + }); + + it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'), + ); + expect(saveCall).toBeDefined(); + }); + + it('does NOT resolve cache param to a class (Hash is a generic container)', () => { + // The @param cache [Hash] should extract type "Hash" — not "UserRepo". + // Since Hash is not a class in the fixture, no type binding is created for cache. + // This verifies the bracket-balanced split doesn't break on the inner comma. + const calls = getRelationships(result, 'CALLS'); + // No calls should originate from cache.* since cache has no resolved type + const cacheCall = calls.find(c => + c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), + ); + expect(cacheCall).toBeUndefined(); + }); + + it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => { + const calls = getRelationships(result, 'CALLS'); + // audit() calls admin_repo.save — should resolve via the alternate YARD format + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'audit', + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { + const calls = getRelationships(result, 'CALLS'); + const findCall = calls.find(c => + c.target === 'find_all' && c.source === 'audit', + ); + expect(findCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index c14adcc1ac..6af948514e 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -600,3 +600,212 @@ describe('Rust if-let captured_pattern type resolution', () => { expect(validateCall!.targetFilePath).toBe('models.rs'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: let user = get_user("alice"); user.save() +// Plain function call (no ::new) with no type annotation +// --------------------------------------------------------------------------- + +describe('Rust return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-return-type'), + () => {}, + ); + }, 60000); + + it('detects User struct and get_user + save functions', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves main → get_user as a CALLS edge to src/models.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const getUserCall = calls.find(c => c.target === 'get_user' && c.source === 'main'); + expect(getUserCall).toBeDefined(); + expect(getUserCall!.targetFilePath).toBe('src/models.rs'); + }); + + it('resolves user.save() to src/models.rs via return-type-inferred binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/models.rs'); + }); +}); + +// --------------------------------------------------------------------------- +// Return-type inference with competing methods: +// Two structs both have save(), factory functions disambiguate via return type +// --------------------------------------------------------------------------- + +describe('Rust return-type inference via function return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-return-type-inference'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to models.rs User#save via return type of get_user()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); + + it('user.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'process_user' + ); + // Should resolve to exactly one target — if it resolves at all, check it's the right one + if (wrongSave) { + expect(wrongSave.targetFilePath).toContain('models'); + } + }); + + it('resolves repo.save() to models.rs Repo#save via return type of get_repo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Rust ::default() constructor resolution — scanner exclusion +// --------------------------------------------------------------------------- + +describe('Rust ::default() constructor resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-default-constructor'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('User'); + expect(structs).toContain('Repo'); + }); + + it('detects save methods on both structs', () => { + const methods = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')]; + expect(methods.filter((m: string) => m === 'save').length).toBeGreaterThanOrEqual(2); + }); + + it('resolves user.save() in process_with_new() via User::new() constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_with_new' && c.targetFilePath.includes('user.rs'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.save() in process_with_default() via User::default() constructor', () => { + // User::default() should be resolved by extractInitializer (Tier 1), + // NOT by the scanner — the scanner excludes ::default() to avoid + // wasted cross-file lookups on the broadly-implemented Default trait + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_with_default' && c.targetFilePath.includes('user.rs'), + ); + expect(saveCall).toBeDefined(); + }); + + it('disambiguates repo.save() in process_with_default() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'process_with_default' && c.targetFilePath.includes('repo.rs'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (user.save() does not resolve to Repo#save)', () => { + const calls = getRelationships(result, 'CALLS'); + // In process_with_new: user.save() should go to user.rs, not repo.rs + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'process_with_new' && c.targetFilePath.includes('repo.rs'), + ); + // Either undefined (correctly disambiguated) or present (both resolved) — no single wrong one + if (wrongCall) { + // If both are present, there should also be a correct one + const correctCall = calls.find(c => + c.target === 'save' && c.source === 'process_with_new' && c.targetFilePath.includes('user.rs'), + ); + expect(correctCall).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Rust async .await constructor binding resolution +// Verifies that `let user = create_user().await` correctly unwraps the +// await_expression to find the call_expression underneath, producing a +// constructor binding that enables receiver-based disambiguation of user.save(). +// --------------------------------------------------------------------------- + +describe('Rust async .await constructor binding resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-async-binding'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('User'); + expect(structs).toContain('Repo'); + }); + + it('detects save methods in separate files', () => { + const methods = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')]; + expect(methods.filter((m: string) => m === 'save').length).toBeGreaterThanOrEqual(2); + }); + + it('resolves user.save() after .await to user.rs via return type of get_user()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('user'), + ); + expect(saveCall).toBeDefined(); + }); + + it('user.save() does NOT resolve to Repo#save in repo.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('repo'), + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves repo.save() after .await to repo.rs via return type of get_repo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('repo'), + ); + expect(saveCall).toBeDefined(); + }); + + it('repo.save() does NOT resolve to User#save in user.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('user'), + ); + expect(wrongSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index cca1a3459e..6ab8c28b6b 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -145,3 +145,80 @@ describe.skipIf(!swiftAvailable)('Swift cross-file User.init() inference', () => expect(greetCall!.source).toBe('main'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: let user = getUser(name: "alice"); user.save() +// Swift's CONSTRUCTOR_BINDING_SCANNER captures property_declaration with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and getUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('detects save function on User (Swift class methods are Function nodes)', () => { + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves user.save() to User#save via return type of getUser() -> User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.swift'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Return-type inference with competing methods: +// Two classes both have save(), factory functions disambiguate via return type +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift return-type inference via function return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-return-type-inference'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via return type of getUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.swift') + ); + expect(saveCall).toBeDefined(); + }); + + it('user.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'processUser' + ); + // Should resolve to exactly one target — if it resolves at all, check it's the right one + if (wrongSave) { + expect(wrongSave.targetFilePath).toContain('Models.swift'); + } + }); + + it('resolves repo.save() to Repo#save via return type of getRepo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('Models.swift') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 7d14488db3..8c355f8b56 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -852,3 +852,187 @@ describe('TypeScript nullable receiver resolution (optional chaining)', () => { }); }); +// --------------------------------------------------------------------------- +// Return type inference: const user = getUser('alice'); user.save() +// The TS/JS CONSTRUCTOR_BINDING_SCANNER captures variable_declarator nodes +// with plain call_expression values, enabling end-to-end return type inference. +// --------------------------------------------------------------------------- + +describe('TypeScript return type inference via explicit function return type', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-return-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('getName'); + }); + + it('detects getUser and fetchUserAsync functions', () => { + const functions = getNodesByLabel(result, 'Function'); + expect(functions).toContain('getUser'); + expect(functions).toContain('fetchUserAsync'); + }); + + it('resolves user.save() to User#save via return type of getUser(): User', () => { + // TS has explicit return types in the source, so extractMethodSignature captures + // the return type. The TS extractInitializer handles `const user = getUser()` + // via the variable_declarator path, enabling save() to resolve to User#save. + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript return type inference via JSDoc @returns annotation +// --------------------------------------------------------------------------- + +describe('JavaScript return type inference via JSDoc @returns annotation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-jsdoc-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves user.save() to User#save via JSDoc @returns {User}', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to Repo#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('repo.js'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves repo.save() to Repo#save via JSDoc @returns {Repo}', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to User#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('user.js'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves user.save() via JSDoc @param {User} in handleUser()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('user.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to Repo#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('repo.js'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves repo.save() via JSDoc @param {Repo} in handleRepo()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('repo.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to User#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('user.js'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript async return type inference via JSDoc @returns {Promise} +// Verifies that wrapper generics (Promise) are unwrapped to the inner type. +// --------------------------------------------------------------------------- + +describe('JavaScript async return type inference via JSDoc @returns {Promise}', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-jsdoc-async-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves user.save() to User#save via @returns {Promise} unwrapping', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to Repo#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('repo.js'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves repo.save() to Repo#save via @returns {Promise} unwrapping', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to User#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('user.js'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript qualified return type: @returns {Promise} +// Verifies that dot-qualified names inside generics are not corrupted. +// --------------------------------------------------------------------------- + +describe('JavaScript qualified return type via JSDoc @returns {Promise}', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-jsdoc-qualified-return-type'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save despite qualified return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.js'), + ); + expect(saveCall).toBeDefined(); + }); +}); + diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 7467c1d7c8..8db43d96f9 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -387,14 +387,15 @@ describe('extractReceiverName', () => { expect(extractReceiverName(match!.nameNode)).toBe('user'); }); - it('does not capture null-conditional user?.Save() with current queries', () => { + it('captures null-conditional user?.Save() and extracts receiver', () => { parser.setLanguage(CSharp); const code = `class Foo { void Run() { user?.Save(); } }`; const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); const match = captures.find(c => c.calledName === 'Save'); - // C# conditional_access_expression uses member_binding_expression, not member_access_expression - // The tree-sitter query doesn't match — this documents the gap for future work - expect(match).toBeUndefined(); + // C# conditional_access_expression (user?.Save()) is now captured via member_binding_expression + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + expect(extractReceiverName(match!.nameNode)).toBe('user'); }); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index db11d471c1..36d5e9eb91 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; +import { processCallsFromExtracted, extractReturnTypeName } from '../../src/core/ingestion/call-processor.js'; import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import type { ExtractedCall, FileConstructorBindings } from '../../src/core/ingestion/workers/parse-worker.js'; @@ -333,6 +333,147 @@ describe('processCallsFromExtracted', () => { expect(rels).toHaveLength(0); }); + // ---- Return type inference (Phase 4) ---- + + it('return type inference: binds variable to return type of callee', async () => { + // getUser() returns User, and User has a save() method + ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { returnType: 'User' }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User' }); + ctx.importMap.set('src/index.ts', new Set(['src/utils.ts', 'src/models.ts'])); + + // Binding: user = getUser() — getUser is not a class, so constructor path fails, + // but return type inference should kick in + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'main@0', varName: 'user', calleeName: 'getUser' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:main', + receiverName: 'user', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('return type inference: unwraps Promise to User', async () => { + ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise' }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User' }); + ctx.importMap.set('src/index.ts', new Set(['src/api.ts', 'src/models.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'main@0', varName: 'user', calleeName: 'fetchUser' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:main', + receiverName: 'user', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('return type inference: skips when return type is primitive', async () => { + ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', { returnType: 'number' }); + ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'main@0', varName: 'count', calleeName: 'getCount' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'toString', + sourceId: 'Function:src/index.ts:main', + receiverName: 'count', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + // No binding should be created for primitive return types + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('return type inference: skips ambiguous callees (multiple definitions)', async () => { + ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { returnType: 'User' }); + ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { returnType: 'Repo' }); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'main@0', varName: 'data', calleeName: 'getData' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:main', + receiverName: 'data', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + // Ambiguous callee — don't guess + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('return type inference: prefers constructor binding over return type', async () => { + // If the callee IS a class, constructor binding wins (existing behavior) + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User' }); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'main@0', varName: 'user', calleeName: 'User' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:main', + receiverName: 'user', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + // ---- Scope-aware constructor bindings (Phase 3) ---- it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { @@ -377,3 +518,193 @@ describe('processCallsFromExtracted', () => { expect(rels[1].sourceId).toBe('Function:src/index.ts:processRepo'); }); }); + +describe('extractReturnTypeName', () => { + it('extracts simple type name', () => { + expect(extractReturnTypeName('User')).toBe('User'); + }); + + it('unwraps Promise', () => { + expect(extractReturnTypeName('Promise')).toBe('User'); + }); + + it('unwraps Option', () => { + expect(extractReturnTypeName('Option')).toBe('User'); + }); + + it('unwraps Result to first type arg', () => { + expect(extractReturnTypeName('Result')).toBe('User'); + }); + + it('strips nullable union: User | null', () => { + expect(extractReturnTypeName('User | null')).toBe('User'); + }); + + it('strips nullable union: User | undefined', () => { + expect(extractReturnTypeName('User | undefined')).toBe('User'); + }); + + it('strips nullable suffix: User?', () => { + expect(extractReturnTypeName('User?')).toBe('User'); + }); + + it('strips Go pointer: *User', () => { + expect(extractReturnTypeName('*User')).toBe('User'); + }); + + it('strips Rust reference: &User', () => { + expect(extractReturnTypeName('&User')).toBe('User'); + }); + + it('strips Rust mutable reference: &mut User', () => { + expect(extractReturnTypeName('&mut User')).toBe('User'); + }); + + it('returns undefined for primitives', () => { + expect(extractReturnTypeName('string')).toBeUndefined(); + expect(extractReturnTypeName('number')).toBeUndefined(); + expect(extractReturnTypeName('boolean')).toBeUndefined(); + expect(extractReturnTypeName('void')).toBeUndefined(); + expect(extractReturnTypeName('int')).toBeUndefined(); + }); + + it('returns undefined for genuine union types', () => { + expect(extractReturnTypeName('User | Repo')).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(extractReturnTypeName('')).toBeUndefined(); + }); + + it('extracts qualified type: models.User → User', () => { + expect(extractReturnTypeName('models.User')).toBe('User'); + }); + + it('handles non-wrapper generics: Map → Map', () => { + expect(extractReturnTypeName('Map')).toBe('Map'); + }); + + it('handles nested wrapper: Promise>', () => { + // Promise> → unwrap Promise → Option → unwrap Option → User + expect(extractReturnTypeName('Promise>')).toBe('User'); + }); + + it('returns base type for collection generics (not unwrapped)', () => { + expect(extractReturnTypeName('Vec')).toBe('Vec'); + expect(extractReturnTypeName('List')).toBe('List'); + expect(extractReturnTypeName('Array')).toBe('Array'); + expect(extractReturnTypeName('Set')).toBe('Set'); + expect(extractReturnTypeName('ArrayList')).toBe('ArrayList'); + }); + + it('unwraps Optional', () => { + expect(extractReturnTypeName('Optional')).toBe('User'); + }); + + it('extracts Ruby :: qualified type: Models::User → User', () => { + expect(extractReturnTypeName('Models::User')).toBe('User'); + }); + + it('extracts C++ :: qualified type: ns::HttpClient → HttpClient', () => { + expect(extractReturnTypeName('ns::HttpClient')).toBe('HttpClient'); + }); + + it('extracts deep :: qualified type: crate::models::User → User', () => { + expect(extractReturnTypeName('crate::models::User')).toBe('User'); + }); + + it('extracts mixed qualifier: ns.module::User → User', () => { + expect(extractReturnTypeName('ns.module::User')).toBe('User'); + }); + + it('returns undefined for lowercase :: qualified: std::vector', () => { + expect(extractReturnTypeName('std::vector')).toBeUndefined(); + }); + + it('extracts deep dot-qualified: com.example.models.User → User', () => { + expect(extractReturnTypeName('com.example.models.User')).toBe('User'); + }); + + it('unwraps wrapper over non-wrapper generic: Promise> → Map', () => { + // Promise is a wrapper — unwrap it to get Map. + // Map is not a wrapper, so return its base type: Map. + expect(extractReturnTypeName('Promise>')).toBe('Map'); + }); + + it('unwraps doubly-nested wrapper: Future> → User', () => { + // Future → unwrap → Result; Result → unwrap first arg → User + expect(extractReturnTypeName('Future>')).toBe('User'); + }); + + it('unwraps CompletableFuture> → User', () => { + // CompletableFuture → unwrap → Optional; Optional → unwrap → User + expect(extractReturnTypeName('CompletableFuture>')).toBe('User'); + }); + + // Rust smart pointer unwrapping + it('unwraps Rc → User', () => { + expect(extractReturnTypeName('Rc')).toBe('User'); + }); + it('unwraps Arc → User', () => { + expect(extractReturnTypeName('Arc')).toBe('User'); + }); + it('unwraps Weak → User', () => { + expect(extractReturnTypeName('Weak')).toBe('User'); + }); + it('unwraps MutexGuard → User', () => { + expect(extractReturnTypeName('MutexGuard')).toBe('User'); + }); + it('unwraps RwLockReadGuard → User', () => { + expect(extractReturnTypeName('RwLockReadGuard')).toBe('User'); + }); + it('unwraps Cow → User', () => { + expect(extractReturnTypeName('Cow')).toBe('User'); + }); + // Nested: Arc> → User (double unwrap) + it('unwraps Arc> → User', () => { + expect(extractReturnTypeName('Arc>')).toBe('User'); + }); + // NOT unwrapped (containers/wrappers not in set) + it('does not unwrap Mutex (not a Deref wrapper)', () => { + expect(extractReturnTypeName('Mutex')).toBe('Mutex'); + }); + + // Rust lifetime parameters in wrapper generics + it("skips lifetime in Ref<'_, User> → User", () => { + expect(extractReturnTypeName("Ref<'_, User>")).toBe('User'); + }); + it("skips lifetime in RefMut<'a, User> → User", () => { + expect(extractReturnTypeName("RefMut<'a, User>")).toBe('User'); + }); + it("skips lifetime in MutexGuard<'_, User> → User", () => { + expect(extractReturnTypeName("MutexGuard<'_, User>")).toBe('User'); + }); + + it('returns undefined for lowercase non-class types', () => { + expect(extractReturnTypeName('error')).toBeUndefined(); + }); + + it('extracts PHP backslash-namespaced type: \\App\\Models\\User → User', () => { + expect(extractReturnTypeName('\\App\\Models\\User')).toBe('User'); + }); + + it('extracts PHP single-segment namespace: \\User → User', () => { + expect(extractReturnTypeName('\\User')).toBe('User'); + }); + + it('extracts PHP deep namespace: \\Vendor\\Package\\Sub\\Client → Client', () => { + expect(extractReturnTypeName('\\Vendor\\Package\\Sub\\Client')).toBe('Client'); + }); + + it('returns undefined for bare wrapper type names without generic arguments', () => { + expect(extractReturnTypeName('Task')).toBeUndefined(); + expect(extractReturnTypeName('Promise')).toBeUndefined(); + expect(extractReturnTypeName('Future')).toBeUndefined(); + expect(extractReturnTypeName('Option')).toBeUndefined(); + expect(extractReturnTypeName('Result')).toBeUndefined(); + expect(extractReturnTypeName('Observable')).toBeUndefined(); + expect(extractReturnTypeName('ValueTask')).toBeUndefined(); + expect(extractReturnTypeName('CompletableFuture')).toBeUndefined(); + expect(extractReturnTypeName('Optional')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/extract-generic-type-args.test.ts b/gitnexus/test/unit/extract-generic-type-args.test.ts new file mode 100644 index 0000000000..1c994ee12e --- /dev/null +++ b/gitnexus/test/unit/extract-generic-type-args.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { extractGenericTypeArgs } from '../../src/core/ingestion/type-extractors/shared.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils.js'; + +/** + * Create a minimal mock SyntaxNode for testing type extraction. + * Only the properties used by extractSimpleTypeName / extractGenericTypeArgs + * are populated — everything else is left as stubs. + */ +function mockNode( + type: string, + opts: { + text?: string; + namedChildren?: SyntaxNode[]; + fields?: Record; + } = {}, +): SyntaxNode { + const children = opts.namedChildren ?? []; + const fields = opts.fields ?? {}; + const text = opts.text ?? children.map((c) => c.text).join(', '); + + return { + type, + text, + namedChildCount: children.length, + namedChild: (i: number) => children[i] ?? null, + firstNamedChild: children[0] ?? null, + lastNamedChild: children[children.length - 1] ?? null, + childForFieldName: (name: string) => fields[name] ?? null, + } as unknown as SyntaxNode; +} + +// Helper: build a generic_type node with type_arguments +function genericType( + baseName: string, + typeArgNames: string[], + opts?: { argsNodeType?: string; wrapInProjection?: boolean }, +): SyntaxNode { + const argsNodeType = opts?.argsNodeType ?? 'type_arguments'; + + const baseNode = mockNode('type_identifier', { text: baseName }); + + let argChildren = typeArgNames.map((name) => + mockNode('type_identifier', { text: name }), + ); + + // Kotlin wraps each arg in type_projection > user_type > type_identifier + if (opts?.wrapInProjection) { + argChildren = typeArgNames.map((name) => { + const typeId = mockNode('type_identifier', { text: name }); + const userType = mockNode('user_type', { namedChildren: [typeId] }); + return mockNode('type_projection', { namedChildren: [userType] }); + }) as unknown as SyntaxNode[]; + } + + const typeArgsNode = mockNode(argsNodeType, { + namedChildren: argChildren, + }); + + return mockNode('generic_type', { + namedChildren: [baseNode, typeArgsNode], + fields: { name: baseNode }, + }); +} + +describe('extractGenericTypeArgs', () => { + describe('single type argument', () => { + it('extracts from TypeScript Array', () => { + const node = genericType('Array', ['User']); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + + it('extracts from Java List', () => { + const node = genericType('List', ['User']); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + + it('extracts from Rust Vec', () => { + const node = genericType('Vec', ['User']); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + + it('extracts from C# List (type_argument_list)', () => { + const node = genericType('List', ['User'], { + argsNodeType: 'type_argument_list', + }); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + }); + + describe('multiple type arguments', () => { + it('extracts from Java Map', () => { + const node = genericType('Map', ['String', 'User']); + expect(extractGenericTypeArgs(node)).toEqual(['String', 'User']); + }); + + it('extracts from TS Map', () => { + const node = genericType('Map', ['string', 'number']); + expect(extractGenericTypeArgs(node)).toEqual(['string', 'number']); + }); + }); + + describe('Kotlin type_projection wrapping', () => { + it('extracts from Kotlin List through type_projection', () => { + const node = genericType('List', ['User'], { wrapInProjection: true }); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + + it('extracts from Kotlin Map through type_projection', () => { + const node = genericType('Map', ['String', 'User'], { + wrapInProjection: true, + }); + expect(extractGenericTypeArgs(node)).toEqual(['String', 'User']); + }); + }); + + describe('parameterized_type (Java/Kotlin alternate node type)', () => { + it('extracts type arguments from parameterized_type', () => { + const baseNode = mockNode('type_identifier', { text: 'List' }); + const argNode = mockNode('type_identifier', { text: 'User' }); + const typeArgsNode = mockNode('type_arguments', { + namedChildren: [argNode], + }); + const node = mockNode('parameterized_type', { + namedChildren: [baseNode, typeArgsNode], + fields: { name: baseNode }, + }); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + }); + + describe('wrapper node unwrapping', () => { + it('unwraps type_annotation before extracting', () => { + const inner = genericType('Array', ['User']); + const wrapper = mockNode('type_annotation', { namedChildren: [inner] }); + expect(extractGenericTypeArgs(wrapper)).toEqual(['User']); + }); + + it('unwraps nullable_type before extracting', () => { + const inner = genericType('List', ['User']); + const wrapper = mockNode('nullable_type', { namedChildren: [inner] }); + expect(extractGenericTypeArgs(wrapper)).toEqual(['User']); + }); + + it('unwraps user_type before extracting (Kotlin)', () => { + const inner = genericType('MutableList', ['String']); + const wrapper = mockNode('user_type', { namedChildren: [inner] }); + expect(extractGenericTypeArgs(wrapper)).toEqual(['String']); + }); + }); + + describe('non-generic types return empty array', () => { + it('returns [] for plain type_identifier', () => { + const node = mockNode('type_identifier', { text: 'User' }); + expect(extractGenericTypeArgs(node)).toEqual([]); + }); + + it('returns [] for identifier', () => { + const node = mockNode('identifier', { text: 'foo' }); + expect(extractGenericTypeArgs(node)).toEqual([]); + }); + + it('returns [] for union_type', () => { + const node = mockNode('union_type', { + namedChildren: [ + mockNode('type_identifier', { text: 'string' }), + mockNode('type_identifier', { text: 'number' }), + ], + }); + expect(extractGenericTypeArgs(node)).toEqual([]); + }); + }); + + describe('nested generic types as arguments', () => { + it('extracts outer type arg names for nested generics', () => { + // Map> — the second arg is itself a generic_type + // extractGenericTypeArgs should extract 'List' (via extractSimpleTypeName) + const innerGeneric = genericType('List', ['User']); + const stringNode = mockNode('type_identifier', { text: 'String' }); + const typeArgsNode = mockNode('type_arguments', { + namedChildren: [stringNode, innerGeneric], + }); + const baseNode = mockNode('type_identifier', { text: 'Map' }); + const node = mockNode('generic_type', { + namedChildren: [baseNode, typeArgsNode], + fields: { name: baseNode }, + }); + + // extractSimpleTypeName on a generic_type returns the base name + expect(extractGenericTypeArgs(node)).toEqual(['String', 'List']); + }); + }); + + describe('edge cases', () => { + it('returns [] for generic_type with no type_arguments child', () => { + const baseNode = mockNode('type_identifier', { text: 'List' }); + const node = mockNode('generic_type', { + namedChildren: [baseNode], + fields: { name: baseNode }, + }); + expect(extractGenericTypeArgs(node)).toEqual([]); + }); + + it('skips unresolvable type arguments', () => { + // If a child can't be resolved by extractSimpleTypeName, it is omitted + const baseNode = mockNode('type_identifier', { text: 'Fn' }); + const unresolvedArg = mockNode('function_type', { text: '() => void' }); + const resolvedArg = mockNode('type_identifier', { text: 'User' }); + const typeArgsNode = mockNode('type_arguments', { + namedChildren: [unresolvedArg, resolvedArg], + }); + const node = mockNode('generic_type', { + namedChildren: [baseNode, typeArgsNode], + fields: { name: baseNode }, + }); + expect(extractGenericTypeArgs(node)).toEqual(['User']); + }); + }); +}); diff --git a/gitnexus/test/unit/method-signature.test.ts b/gitnexus/test/unit/method-signature.test.ts index c39bb57586..ccd03b4ba4 100644 --- a/gitnexus/test/unit/method-signature.test.ts +++ b/gitnexus/test/unit/method-signature.test.ts @@ -230,6 +230,20 @@ describe('extractMethodSignature', () => { const sig = extractMethodSignature(methodNode); expect(sig.parameterCount).toBe(0); }); + + it('extracts return type from C# method', () => { + parser.setLanguage(CSharp); + const code = `class Svc { + public User GetUser(string name) { return null; } +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.returnType).toBe('User'); + }); }); describe('Go', () => { @@ -254,7 +268,7 @@ func parse(s string) (string, error) { return s, nil }`; const sig = extractMethodSignature(funcNode); expect(sig.parameterCount).toBe(1); - expect(sig.returnType).toBe('(string, error)'); + expect(sig.returnType).toBe('string'); }); it('handles no return type', () => { diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 6bc4e2696c..acf204ada7 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -101,6 +101,42 @@ describe('SymbolTable', () => { }); }); + describe('returnType metadata', () => { + it('stores returnType in SymbolDefinition', () => { + table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'User' }); + const def = table.lookupExactFull('src/utils.ts', 'getUser'); + expect(def).toBeDefined(); + expect(def!.returnType).toBe('User'); + }); + + it('returnType is available via lookupFuzzy', () => { + table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'Promise' }); + const results = table.lookupFuzzy('getUser'); + expect(results).toHaveLength(1); + expect(results[0].returnType).toBe('Promise'); + }); + + it('omits returnType when not provided', () => { + table.add('src/utils.ts', 'helper', 'func:helper', 'Function'); + const def = table.lookupExactFull('src/utils.ts', 'helper'); + expect(def).toBeDefined(); + expect(def!.returnType).toBeUndefined(); + }); + + it('stores returnType alongside parameterCount and ownerId', () => { + table.add('src/models.ts', 'save', 'method:save', 'Method', { + parameterCount: 1, + returnType: 'boolean', + ownerId: 'class:User', + }); + const def = table.lookupExactFull('src/models.ts', 'save'); + expect(def).toBeDefined(); + expect(def!.parameterCount).toBe(1); + expect(def!.returnType).toBe('boolean'); + expect(def!.ownerId).toBe('class:User'); + }); + }); + describe('clear', () => { it('resets all state', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 8e563af620..ff0c8cf1d5 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -518,6 +518,116 @@ class UserService { const { env } = buildTypeEnv(tree, 'php'); expect(flatGet(env, '$name')).toBe('string'); }); + + it('extracts PHPDoc @param with standard order: @param Type $name', () => { + const tree = parse(`save(); +} + `, PHP.php); + const { env } = buildTypeEnv(tree, 'php'); + expect(flatGet(env, '$repo')).toBe('UserRepo'); + expect(flatGet(env, '$name')).toBe('string'); + }); + + it('extracts PHPDoc @param with alternate order: @param $name Type', () => { + const tree = parse(`save(); +} + `, PHP.php); + const { env } = buildTypeEnv(tree, 'php'); + expect(flatGet(env, '$repo')).toBe('UserRepo'); + expect(flatGet(env, '$name')).toBe('string'); + }); + }); + + describe('Ruby YARD annotations', () => { + it('extracts @param type bindings from YARD comments', () => { + const tree = parse(` +class UserService + # @param repo [UserRepo] the repository + # @param name [String] the user's name + def create(repo, name) + repo.save + end +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'repo')).toBe('UserRepo'); + expect(flatGet(env, 'name')).toBe('String'); + }); + + it('handles qualified YARD types (Models::User → User)', () => { + const tree = parse(` +# @param user [Models::User] the user +def process(user) +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('handles nullable YARD types (String, nil → String)', () => { + const tree = parse(` +# @param name [String, nil] optional name +def greet(name) +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'name')).toBe('String'); + }); + + it('skips ambiguous union YARD types (String, Integer → undefined)', () => { + const tree = parse(` +# @param value [String, Integer] mixed type +def process(value) +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'value')).toBeUndefined(); + }); + + it('extracts no types when no YARD comments present', () => { + const tree = parse(` +def create(repo, name) + repo.save +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatSize(env)).toBe(0); + }); + + it('extracts types from singleton method YARD comments', () => { + const tree = parse(` +class UserService + # @param name [String] the user's name + def self.find(name) + name + end +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'name')).toBe('String'); + }); + + it('handles generic YARD types (Array → Array)', () => { + const tree = parse(` +# @param users [Array] list of users +def process(users) +end +`, Ruby); + const { env } = buildTypeEnv(tree, 'ruby'); + expect(flatGet(env, 'users')).toBe('Array'); + }); }); describe('super/base/parent resolution', () => { @@ -797,12 +907,11 @@ class RepoService { expect(flatGet(env, 'user')).toBe('BaseUser'); }); - it('does not infer from namespaced constructor (known limitation)', () => { - // extractSimpleTypeName only handles simple identifiers, not member expressions + it('infers from namespaced constructor: new ns.Service()', () => { + // extractSimpleTypeName handles member_expression via property_identifier const tree = parse('const svc = new ns.Service();', TypeScript.typescript); const { env } = buildTypeEnv(tree, 'typescript'); - // member_expression as constructor → extractSimpleTypeName returns undefined - expect(flatGet(env, 'svc')).toBeUndefined(); + expect(flatGet(env, 'svc')).toBe('Service'); }); it('infers type from new expression with as cast', () => { @@ -899,6 +1008,30 @@ class RepoService { expect(flatGet(env, 'config')).toBe('Config'); }); + it('does NOT emit scanner binding for Type::default() (handled by extractInitializer)', () => { + const tree = parse(` + fn main() { + let config = Config::default(); + } + `, Rust); + const { constructorBindings } = buildTypeEnv(tree, 'rust'); + // ::default() should be excluded from scanConstructorBinding just like ::new() + // extractInitializer already resolves it, so a scanner binding would be redundant + const defaultBinding = constructorBindings.find(b => b.calleeName === 'default'); + expect(defaultBinding).toBeUndefined(); + }); + + it('does NOT emit scanner binding for Type::new() (handled by extractInitializer)', () => { + const tree = parse(` + fn main() { + let user = User::new(); + } + `, Rust); + const { constructorBindings } = buildTypeEnv(tree, 'rust'); + const newBinding = constructorBindings.find(b => b.calleeName === 'new'); + expect(newBinding).toBeUndefined(); + }); + it('prefers explicit annotation over constructor inference', () => { // Uses DIFFERENT types to catch Tier 0 overwrite bugs const tree = parse(` @@ -1634,6 +1767,26 @@ REPO = Repo.new expect(constructorBindings[0].calleeName).toBe('Repo'); }); + it('returns constructor bindings for Ruby namespaced constructor (service = Models::UserService.new)', () => { + const tree = parse(` +service = Models::UserService.new +`, Ruby); + const { constructorBindings } = buildTypeEnv(tree, 'ruby'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('service'); + expect(constructorBindings[0].calleeName).toBe('UserService'); + }); + + it('returns constructor bindings for deeply namespaced Ruby constructor (svc = App::Models::Service.new)', () => { + const tree = parse(` +svc = App::Models::Service.new +`, Ruby); + const { constructorBindings } = buildTypeEnv(tree, 'ruby'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('svc'); + expect(constructorBindings[0].calleeName).toBe('Service'); + }); + it('includes scope key in constructor bindings', () => { const tree = parse(` fun process() { @@ -1644,5 +1797,112 @@ REPO = Repo.new expect(constructorBindings.length).toBe(1); expect(constructorBindings[0].scope).toMatch(/^process@\d+$/); }); + + it('returns constructor bindings for TypeScript const user = getUser()', () => { + const tree = parse('const user = getUser();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBeUndefined(); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('getUser'); + }); + + it('does NOT emit constructor binding when TypeScript var has explicit type annotation', () => { + const tree = parse('const user: User = getUser();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); + + it('skips destructuring patterns (array_pattern) for TypeScript', () => { + const tree = parse('const [a, b] = getPair();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings).toEqual([]); + }); + + it('skips destructuring patterns (object_pattern) for TypeScript', () => { + const tree = parse('const { name, age } = getUser();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings).toEqual([]); + }); + + it('unwraps await in TypeScript: const user = await fetchUser()', () => { + const tree = parse('async function f() { const user = await fetchUser(); }', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('fetchUser'); + }); + + it('handles qualified callee in TypeScript: const user = repo.getUser()', () => { + const tree = parse('const user = repo.getUser();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('getUser'); + }); + + it('does not emit binding for TypeScript new expression (handled by extractInitializer)', () => { + const tree = parse('const user = new User();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); + + it('returns constructor binding for C# var user = svc.GetUser()', () => { + const tree = parse(` + class App { + void Run() { + var svc = new UserService(); + var user = svc.GetUser("alice"); + } + } + `, CSharp); + const { constructorBindings } = buildTypeEnv(tree, 'csharp'); + const binding = constructorBindings.find(b => b.varName === 'user'); + expect(binding).toBeDefined(); + expect(binding!.calleeName).toBe('GetUser'); + }); + + it('unwraps .await in Rust: let user = get_user().await', () => { + const tree = parse(` + async fn process() { + let user = get_user().await; + } + `, Rust); + const { constructorBindings } = buildTypeEnv(tree, 'rust'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('get_user'); + }); + + it('unwraps await in C#: var user = await svc.GetUserAsync()', () => { + const tree = parse(` + class App { + async void Run() { + var svc = new UserService(); + var user = await svc.GetUserAsync("alice"); + } + } + `, CSharp); + const { constructorBindings } = buildTypeEnv(tree, 'csharp'); + const binding = constructorBindings.find(b => b.varName === 'user'); + expect(binding).toBeDefined(); + expect(binding!.calleeName).toBe('GetUserAsync'); + }); + + it('returns constructor binding for C# var user = GetUser() (standalone call)', () => { + const tree = parse(` + class App { + void Run() { + var user = GetUser("alice"); + } + } + `, CSharp); + const { constructorBindings } = buildTypeEnv(tree, 'csharp'); + const binding = constructorBindings.find(b => b.varName === 'user'); + expect(binding).toBeDefined(); + expect(binding!.calleeName).toBe('GetUser'); + }); }); });