GitNexus/gitnexus/test/unit/call-processor.test.ts
Gergő Magyar 6c18ae08f7
feat: return type inference, doc-comment parsing, and per-language type extractors (#284)
* feat: Phase 3 — return type inference, generic args extraction, Ruby YARD type extractor

Three architectural improvements to the type resolution system:

1. Return type inference — wire extractMethodSignature returnType through
   SymbolDefinition into call-processor. When var = callee() and callee
   has a known return type, bind var to that type. Handles Promise<T>
   unwrapping, nullable stripping, pointer/reference removal.

2. Generic type argument extraction — new extractGenericTypeArgs() utility
   that extracts type parameters from List<User> → ['User']. Handles
   TS/Java/Kotlin/C#/Rust generic syntax. Building block for for-loop
   variable typing.

3. Ruby dedicated type extractor — replaces the stub with YARD annotation
   parsing (@param name [Type]), handling qualified types, nullable types,
   and singleton methods. Ruby now has real type resolution.

Unit tests: 127 → 192+ (type-env) + 65 (symbol-table, call-processor) + 18 (generics)
Integration tests: 8+ new test cases with fixtures across TS/Python/Go/Java/Ruby

* fix: Phase 3 gaps — WRAPPER_GENERICS correctness, Ruby :: qualifier, namespaced constructors

- Remove collection types (List, Array, Vec, Set) from WRAPPER_GENERICS to prevent
  false CALLS edges (e.g. List<User> no longer unwraps to User)
- Add :: qualifier handling in extractReturnTypeName for Ruby/C++/Rust namespaced types
- Add Ruby `constant` and `scope_resolution` node types to shared extractors
- Extract shared extractRubyConstructorAssignment helper (dedup type-env.ts + ruby.ts)
- Add integration tests for return type inference: Python, TypeScript, Go, Java, Ruby
- Add Ruby namespaced constructor fixture (Models::UserService.new)
- Add unit tests for collection reclassification and :: qualifiers

* feat: Phase 4 — CONSTRUCTOR_BINDING_SCANNERS for all languages + return type inference tests

Add CONSTRUCTOR_BINDING_SCANNERS for 6 missing languages, completing
return type inference coverage across all 11 supported languages:

- TypeScript/JS: variable_declarator with call_expression, unwraps await
- Go: short_var_declaration single-assignment (skips multi-return, new/make)
- Java: local_variable_declaration with `var` type + method_invocation
- C#: variable_declaration with implicit_type (var) + invocation_expression
- Rust: let_declaration without type annotation, handles mut_pattern
- PHP: assignment_expression with function_call_expression

Also adds property_identifier to extractSimpleTypeName for qualified
member calls (repo.getUser → getUser), fixing namespaced constructor
inference that was previously a known limitation.

Integration tests added for all 11 languages with correct label
assertions (Function vs Method per language's tree-sitter queries).

* refactor: merge CONSTRUCTOR_BINDING_SCANNERS into per-language LanguageTypeConfig

Eliminates the parallel dispatch map in type-env.ts by moving all 11
constructor binding scanners into their respective type-extractors/*.ts
files as `scanConstructorBinding` on LanguageTypeConfig.

- Add ConstructorBindingScanner type to types.ts
- Add shared helpers: hasTypeAnnotation, unwrapAwait, extractCalleeName
- Move scanners to typescript.ts, jvm.ts, python.ts, php.ts, go.ts,
  rust.ts, swift.ts, c-cpp.ts, csharp.ts, ruby.ts
- Fix `any` types in C# scanner → SyntaxNode | null
- Delete ~300 lines from type-env.ts (CONSTRUCTOR_BINDING_SCANNERS map)
- Update buildTypeEnv to use config.scanConstructorBinding

All 143 type-env unit tests and all 10 language integration suites pass.

* fix: remove unused import, fix any type in Java scanner, update stale comment

- Remove unused extractCalleeName import from jvm.ts
- Fix (c: any) → (c: SyntaxNode) in Java scanner
- Update stale CONSTRUCTOR_BINDING_SCANNERS reference in ruby.ts comment

* fix: C# and PHP return type inference — scanner fixes, method signature extraction, and cross-file resolution

Addresses code review findings on PR #284:

C# scanner (csharp.ts):
- Fix type node lookup: iterate children instead of childForFieldName('type')
  which returns undefined in tree-sitter-c-sharp
- Fix initializer lookup: handle direct invocation_expression children
  (no equals_value_clause wrapper in tree-sitter-c-sharp)

C# return type extraction (utils.ts):
- Add 'returns' field check to extractMethodSignature — tree-sitter-c-sharp
  uses 'returns', not 'type', for method return types

C# cross-file resolution (call-processor.ts + fixture):
- Add constructor binding verification to sequential processCalls path
  (was only in the worker processCallsFromExtracted path)
- Add ReturnType.csproj to csharp-return-type fixture
- Update fixture namespaces to use ReturnType.Models/ReturnType.Services
  prefix (matches real C# project conventions)

PHP scanner (php.ts):
- Extend scanConstructorBinding to handle member_call_expression
  ($this->getUser() patterns), not just function_call_expression

Shared (shared.ts):
- Add member_access_expression to extractSimpleTypeName qualified-names
  block (C# method calls like svc.GetUser())

Tests:
- Add Repo.cs/Repo.php disambiguation fixtures (two Save methods)
- Strengthen C# and PHP return type tests with hard disambiguation assertions
- Add C# scanner unit tests and return type extraction test

* feat: per-language ReturnTypeExtractor + doc-comment @param parsing for PHP, JS, Ruby

Add ReturnTypeExtractor to LanguageTypeConfig interface with implementations
for Ruby (YARD @return), PHP (PHPDoc @return), and JS/TS (JSDoc @returns).
The fallback is wired in both parsing-processor and parse-worker paths,
activating only when extractMethodSignature finds no AST-based return type.

Also add doc-comment @param type extraction for PHP and JS/TS, following
Ruby's existing collectYardParams pattern. This enables parameter.method()
resolution in loosely-typed codebases using PHPDoc @param or JSDoc @param.

Additional fixes from PR #284 code review:
- Go: add selector_expression + field_identifier to extractSimpleTypeName
  (enables package-qualified factory calls like models.NewUser())
- Ruby: broaden scanConstructorBinding to capture plain call assignments
  (user = get_user()) in addition to Class.new patterns
- Ruby: harden return-type fixture with disambiguation (two save methods)

Test coverage: +14 new integration tests across Go, Ruby, PHP, JS/TS

* fix: JSDoc async return type, PHP attribute walkers, and $this receiver disambiguation

Three fixes from fourth-pass code review on PR #284:

1. JSDoc `@returns {Promise<User>}` no longer stripped to `Promise` — extractReturnType
   now uses sanitizeReturnType (preserves generics) instead of normalizeJsDocType
   (which stripped them before extractReturnTypeName could unwrap WRAPPER_GENERICS).

2. PHP 8+ `#[Attribute]` and JS `@decorator` nodes no longer break doc-comment walkers.
   Both extractReturnType and collect*Params functions now skip attribute_list/decorator
   nodes instead of breaking on them as named siblings.

3. PHP `$this->method()` now provides receiverClassName for disambiguation.
   When two classes define the same method, the enclosing class narrows candidates
   via ownerId matching in call-processor, preventing false no-binding results.

* fix: sanitizeReturnType dot corruption, JS test assertions, Ruby constant receiver

- Remove redundant dot-path stripping from sanitizeReturnType that corrupted
  qualified names inside generics (e.g. Promise<models.User> → User>)
- Split JS async fixture into separate files and add negative assertions
  to properly verify disambiguation (mirroring PHP test pattern)
- Accept 'constant' node type in Ruby scanConstructorBinding for factory
  call assignments (SERVICE = build_service())
- Add 'constant' to SIMPLE_RECEIVER_TYPES so extractReceiverName handles
  Ruby constant receivers (SERVICE.process)

* fix: nested generic arg splitting, JS/Ruby test false positives

- Replace naive comma split in extractReturnTypeName with bracket-balanced
  extractFirstGenericArg so nested types like Future<Result<User, Error>>
  unwrap correctly instead of producing malformed "Result<User"
- Add CompletableFuture to WRAPPER_GENERICS for Java async unwrapping
- Split js-jsdoc-return-type fixture models.js into user.js/repo.js and
  add negative assertions to prove disambiguation (not just file match)
- Split ruby-constant-factory-call fixture into separate service files
  and add negative assertions against AdminService resolution

* fix: review findings — receiverClassName parity, Rust wrappers, Go multi-return, Kotlin/Swift qualified calls

P1: Sequential path now includes receiverClassName narrowing for PHP
$this->method() disambiguation (was missing vs worker path).

P2: Added Rc/Arc/Weak/MutexGuard/Cow + 6 more Rust Deref types to
WRAPPER_GENERICS (Box excluded — Java Swing collision). Extended
Kotlin/Swift scanners to handle navigation_expression callees.
Added Go multi-return support (user, err := f()) with blank/_/err/ok
guard + AST-level first-return extraction in extractMethodSignature.

P3: Extracted shared verifyConstructorBindings() eliminating 60 lines
of duplication between sequential and worker paths. Added return-type
inference integration tests for C++, Rust, Swift with competing
methods and negative disambiguation assertions.

* fix: Swift navigation_suffix unwrapping, Rust lifetime skipping, Kotlin disambiguation tests

- Swift scanConstructorBinding: handle tree-sitter wrapping qualified
  identifiers in navigation_suffix nodes
- Add extractFirstTypeArg to skip Rust lifetime parameters ('a, '_)
  when unwrapping wrapper generics like Ref<'_, User>
- Kotlin tests: add Repo class fixture with competing save() methods
  to prove disambiguation; assert no spurious edges on known gap
- Remove tree-sitter-kotlin from optionalDependencies (now regular dep)

* fix: C# null-conditional calls, Ruby YARD bracket-balanced split, PHPDoc alternate order, escapeValue hardening

- Add C# null-conditional call support (user?.Save()): tree-sitter query for
  conditional_access_expression, member_binding_expression in MEMBER_ACCESS_NODE_TYPES,
  receiver extraction via conditional_access_expression parent walk
- Fix Ruby YARD type parsing for nested generics (Hash<Symbol, User>): replace
  naive split(',') with bracket-balanced splitter respecting <> depth
- Add alternate YARD format (@param [Type] name) alongside standard (@param name [Type])
- Add alternate PHPDoc format (@param $name Type) alongside standard (@param Type $name)
- Harden escapeValue in kuzu-adapter.ts: escape \n and \r to prevent Cypher injection
- Integration tests: C# null-conditional fixture (5 tests), Ruby YARD generics fixture (6 tests)
- Unit tests: PHPDoc alternate order (2 tests), C# null-conditional call-form (updated)

* test: add Python static/classmethod integration tests (issue #289)

Verifies that classes using only @staticmethod/@classmethod have HAS_METHOD
edges connecting them to their child methods. This was the root cause of
issue #289 where context() and impact() returned empty for such classes.

Tests cover: HAS_METHOD edge emission, unique static method resolution
(create_user, delete_user), and ambiguous same-named method handling
(find_user on both UserService and AdminService — safely refused).

* fix: lbug batch escapeValue newline hardening, Rust ::default() scanner exclusion

- Apply \n/\r escaping to batch upsert escapeValue in lbug-adapter.ts:429
  (missed instance of the CREATE-path fix from ec4dca4)
- Exclude Rust ::default() from scanConstructorBinding to match
  extractInitializer behavior — avoids wasted cross-file lookups on
  the broadly-implemented Default trait
- Unit tests: 2 new scanner exclusion tests (::default and ::new)
- Integration tests: 6 new Rust ::default() constructor resolution tests
  with disambiguation fixture (User::default vs Repo::default)

* fix: C#/Rust async await unwrap, PHP backslash namespace, fallback escaping

- C# scanConstructorBinding: unwrap await_expression to find invocation_expression
  (var user = await svc.GetUserAsync() now produces constructor binding)
- Rust scanConstructorBinding: unwrap .await postfix via shared unwrapAwait helper
  (let user = get_user().await now produces constructor binding)
- extractReturnTypeName: handle PHP backslash namespace separator (\App\Models\User → User)
- fallbackRelationshipInserts: match batch escapeValue hardening with \n/\r escaping

Tests: 2 unit (type-env), 3 unit (call-processor), 7 integration (csharp+rust), 7 fixtures

* fix: C#/Rust async-binding test false positives — add competing types and negative assertions

C# fixture: add Order.cs with Order.Save(), change OrderService to return
Task<Order> via GetOrderAsync, add negative assertion proving user.Save()
does not resolve to Order#Save.

Rust fixture: split models.rs into user.rs/repo.rs, make process_user and
process_repo async fn, add bidirectional negative assertions proving no
cross-contamination between User#save and Repo#save.

* fix: C# async-binding broken assertion, bare wrapper type leak, JSDoc optional params

- Split Program.cs Main into ProcessUser/ProcessOrder so negative
  assertions use strict toBeUndefined() (matching Rust pattern)
- Guard bare wrapper types (Task, Promise, Option…) in
  extractReturnTypeName — return undefined instead of the wrapper name
- Update JSDOC_PARAM_RE to capture @param {Type} [optionalName] syntax

* fix: update symbol and relationship counts in documentation
2026-03-15 18:49:40 +00:00

710 lines
27 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
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';
describe('processCallsFromExtracted', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('creates CALLS relationship for same-file resolution', async () => {
ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'helper',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].sourceId).toBe('Function:src/index.ts:main');
expect(rels[0].targetId).toBe('Function:src/index.ts:helper');
expect(rels[0].confidence).toBe(0.95);
expect(rels[0].reason).toBe('same-file');
});
it('creates CALLS relationship for import-resolved resolution', async () => {
ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'format',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.9);
expect(rels[0].reason).toBe('import-resolved');
});
it('resolves unique global symbol with moderate confidence', async () => {
ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'uniqueFunc',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.5);
expect(rels[0].reason).toBe('global');
});
it('refuses ambiguous global symbols — no CALLS edge created', async () => {
ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function');
ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('skips unresolvable calls', async () => {
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'nonExistent',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationshipCount).toBe(0);
});
it('refuses non-callable symbols even when the name resolves', async () => {
ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'Widget',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationshipCount).toBe(0);
});
it('refuses CALLS edges to Interface symbols', async () => {
ctx.symbols.add('src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', 'Interface');
ctx.importMap.set('src/index.ts', new Set(['src/types.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'Serializable',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0);
});
it('refuses CALLS edges to Enum symbols', async () => {
ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum');
ctx.importMap.set('src/index.ts', new Set(['src/status.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'Status',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0);
});
it('prefers same-file over import-resolved', async () => {
ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function');
ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/index.ts:render');
expect(rels[0].reason).toBe('same-file');
});
it('handles multiple calls from the same file', async () => {
ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
{ filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' },
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2);
});
it('uses arity to disambiguate import-scoped callable candidates', async () => {
ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0 });
ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 });
ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'log',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/formatter.ts:log');
expect(rels[0].reason).toBe('import-resolved');
});
it('refuses ambiguous call targets when arity does not produce a unique match', async () => {
ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1 });
ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 });
ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'log',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
}];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0);
});
it('calls progress callback', async () => {
ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
];
const onProgress = vi.fn();
await processCallsFromExtracted(graph, calls, ctx, onProgress);
expect(onProgress).toHaveBeenCalledWith(1, 1);
});
it('handles empty calls array', async () => {
await processCallsFromExtracted(graph, [], ctx);
expect(graph.relationshipCount).toBe(0);
});
// ---- Constructor-aware resolution (Phase 2) ----
it('resolves constructor call to Class when no Constructor node exists', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
callForm: 'constructor',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Class:src/models.ts:User');
expect(rels[0].reason).toBe('import-resolved');
});
it('resolves constructor call to Constructor node over Class node', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { parameterCount: 1 });
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
callForm: 'constructor',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Constructor:src/models.ts:User');
});
it('refuses Class target without callForm=constructor (existing behavior)', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('constructor call falls back to callable types when no Constructor/Class found', async () => {
ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'Widget',
sourceId: 'Function:src/index.ts:main',
callForm: 'constructor',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/utils.ts:Widget');
});
it('constructor arity filtering narrows overloaded constructors', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { parameterCount: 0 });
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { parameterCount: 2 });
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
argCount: 2,
callForm: 'constructor',
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Constructor:src/models.ts:User(2)');
});
it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => {
ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1 });
ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1 });
ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
}];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
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<User> to User', async () => {
ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise<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/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 () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const constructorBindings: FileConstructorBindings[] = [{
filePath: 'src/index.ts',
bindings: [
{ scope: 'processUser@12', varName: 'obj', calleeName: 'User' },
{ scope: 'processRepo@89', varName: 'obj', calleeName: 'Repo' },
],
}];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:processUser',
receiverName: 'obj',
callForm: 'member',
},
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:processRepo',
receiverName: 'obj',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(2);
// Both calls should resolve, each with the correct receiver type from their scope
// (the important thing is they don't collide — without scope awareness,
// last-write-wins would give both calls the same receiver type)
expect(rels[0].sourceId).toBe('Function:src/index.ts:processUser');
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<User>', () => {
expect(extractReturnTypeName('Promise<User>')).toBe('User');
});
it('unwraps Option<User>', () => {
expect(extractReturnTypeName('Option<User>')).toBe('User');
});
it('unwraps Result<User, Error> to first type arg', () => {
expect(extractReturnTypeName('Result<User, Error>')).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<K, V> → Map', () => {
expect(extractReturnTypeName('Map<string, User>')).toBe('Map');
});
it('handles nested wrapper: Promise<Option<User>>', () => {
// Promise<Option<User>> → unwrap Promise → Option<User> → unwrap Option → User
expect(extractReturnTypeName('Promise<Option<User>>')).toBe('User');
});
it('returns base type for collection generics (not unwrapped)', () => {
expect(extractReturnTypeName('Vec<User>')).toBe('Vec');
expect(extractReturnTypeName('List<User>')).toBe('List');
expect(extractReturnTypeName('Array<User>')).toBe('Array');
expect(extractReturnTypeName('Set<User>')).toBe('Set');
expect(extractReturnTypeName('ArrayList<User>')).toBe('ArrayList');
});
it('unwraps Optional<User>', () => {
expect(extractReturnTypeName('Optional<User>')).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<string, User>> → Map', () => {
// Promise is a wrapper — unwrap it to get Map<string, User>.
// Map is not a wrapper, so return its base type: Map.
expect(extractReturnTypeName('Promise<Map<string, User>>')).toBe('Map');
});
it('unwraps doubly-nested wrapper: Future<Result<User, Error>> → User', () => {
// Future → unwrap → Result<User, Error>; Result → unwrap first arg → User
expect(extractReturnTypeName('Future<Result<User, Error>>')).toBe('User');
});
it('unwraps CompletableFuture<Optional<User>> → User', () => {
// CompletableFuture → unwrap → Optional<User>; Optional → unwrap → User
expect(extractReturnTypeName('CompletableFuture<Optional<User>>')).toBe('User');
});
// Rust smart pointer unwrapping
it('unwraps Rc<User> → User', () => {
expect(extractReturnTypeName('Rc<User>')).toBe('User');
});
it('unwraps Arc<User> → User', () => {
expect(extractReturnTypeName('Arc<User>')).toBe('User');
});
it('unwraps Weak<User> → User', () => {
expect(extractReturnTypeName('Weak<User>')).toBe('User');
});
it('unwraps MutexGuard<User> → User', () => {
expect(extractReturnTypeName('MutexGuard<User>')).toBe('User');
});
it('unwraps RwLockReadGuard<User> → User', () => {
expect(extractReturnTypeName('RwLockReadGuard<User>')).toBe('User');
});
it('unwraps Cow<User> → User', () => {
expect(extractReturnTypeName('Cow<User>')).toBe('User');
});
// Nested: Arc<Option<User>> → User (double unwrap)
it('unwraps Arc<Option<User>> → User', () => {
expect(extractReturnTypeName('Arc<Option<User>>')).toBe('User');
});
// NOT unwrapped (containers/wrappers not in set)
it('does not unwrap Mutex<User> (not a Deref wrapper)', () => {
expect(extractReturnTypeName('Mutex<User>')).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();
});
});