GitNexus/gitnexus/test/integration/resolvers/go.test.ts
Gergő Magyar 604b575e4b
feat: Phase 7 type resolution — return-aware loop inference & PHP class-property iterables (#341)
* feat(type-resolution): Phase 7.1+7.2 foundation — ReturnTypeLookup, context object, pendingCallResults

- Move extractReturnTypeName + helpers from call-processor.ts to type-extractors/shared.ts
  (breaks circular import risk: call-processor → type-env → type-extractors → call-processor)
- Add SymbolTable.lookupFuzzyCallable(name) — lazy callable-only index, O(1) per call,
  invalidated on add(); avoids per-call .filter() on lookupFuzzy results
- Add ReturnTypeLookup interface (conservative: undefined when 0 or 2+ callables match)
- Add ForLoopExtractorContext interface — replaces 4 positional params with context object;
  update all 10 language extractor implementations (go, ts, py, jvm×2, cs, rs, rb, php, c-cpp)
- Add PendingAssignment discriminated union (kind: 'copy' | 'callResult');
  update PendingAssignmentExtractor in all 9 language extractors that implement it
- Wire buildTypeEnv: build ReturnTypeLookup from optional symbolTable; split pendingAssignments
  into pendingCopies + pendingCallResults; add Tier 2b call-result propagation loop
- Update call-processor.test.ts to import extractReturnTypeName from shared.ts

* feat(type-resolution): Phase 7.3 — call_expression iterables in for-loop extractors (7 languages)

Extends for-loop type extraction in all 7 typed-iteration languages to
resolve element types when the iterable is a direct function call.

**New capability**: `for (var u : getUsers())` in Java, `for u in get_users()`
in Python, `for user in getUsers()` in TypeScript, etc. now resolve
`u`/`user` to the callee's return element type via lookupRawReturnType +
extractElementTypeFromString.

Changes per language:
- types.ts: extend ReturnTypeLookup with lookupRawReturnType (raw return
  string for container-type extraction); update ForLoopExtractorContext
  with returnTypeLookup field
- type-env.ts: implement lookupRawReturnType on the concrete ReturnTypeLookup
  built in buildTypeEnv (same guards as lookupReturnType, no extractReturnTypeName)
- go.ts: call_expression branch in range_clause — identifier func or
  selector_expression method; existing isChannelType guards updated
- typescript.ts: identifier fn branch inside call_expression handler
- python.ts: identifier fn branch inside call handler
- jvm.ts (Java): method_invocation without object field in enhanced_for_statement
- jvm.ts (Kotlin): simple_identifier callee branch in call_expression node
- csharp.ts: identifier fn branch in invocation_expression handler
- rust.ts: identifier func branch in call_expression handler (alongside
  existing field_expression/method-call path)

All branches follow the same conservative pattern:
  lookupRawReturnType(callee) → extractElementTypeFromString → bind loop var

* feat(type-resolution): Phase 7.4 — PHP \$this->property iterable via @var class property scan

Adds Strategy C to PHP's extractForLoopBinding for the pattern:

  foreach (\$this->property as \$item)

when Strategy A (resolveIterableElementType) and Strategy B (scopeEnv lookup)
both fail to find the element type.

Strategy C: when the iterable is a member_access_expression with object '$this',
walk up the AST to the enclosing class_declaration, scan its declaration_list
for a property_declaration whose variable_name matches the property, and extract
the element type from:
  1. PHPDoc @var annotation on a preceding comment sibling (/** @var User[] */)
  2. PHP 7.4+ native type field (e.g. UserRepo \$repo — skips generic 'array')

This eliminates the @param workaround that was previously required in the
php-foreach-member-access fixture (which used @param User[] \$users on the method
to populate the method's scopeEnv with a \$users binding).

New helpers in php.ts:
- PHPDOC_VAR_RE: regex for @var extraction
- extractClassPropertyElementType: reads @var or native type from a property_declaration
- findClassPropertyElementType: scans class body for a named property

Tests added (type-env.test.ts):
- PHP: resolves from @var User[] without @param workaround
- PHP: conservative — no binding for unknown property
- PHP: multi-class file — both classes resolve independently

Fixture updated (php-foreach-member-access/App.php):
- Removed the @param User[] \$users workaround from processMembers()
- Test now validates the natural class-property-based resolution path

* docs: mark Phase 7 complete in type-resolution-roadmap.md

Records that 7A (call_expression iterables, 7 languages), 7B (PHP
$this->property via @var scan), and 7C (ReturnTypeLookup + context object)
are all shipped. Adds implementation notes and strikethroughs on resolved
language-specific gaps.

* fix(docs): update project references to feat-phase7-type-resolution in AGENTS.md and CLAUDE.md

* feat(type-resolution): Phase 7.5 — PHP call_expression foreach + integration tests for 7 languages

Add integration test coverage for Phase 7.3's call_expression iterable
resolution across all 7 languages (Go, TypeScript, Python, Java, Kotlin,
PHP, Rust). Each test creates a fixture with competing User/Repo classes
that both define save(), then verifies for-loop iteration over a function
call's return value resolves to the correct class.

PHP was missing function_call_expression support in its for-loop extractor.
Three changes fix this:
- php.ts extractForLoopBinding: handle function_call_expression and
  member_call_expression iterables via returnTypeLookup
- php.ts normalizePhpReturnType: preserve array notation (User[]) in
  SymbolTable so lookupRawReturnType returns useful container types
- parse-worker.ts + parsing-processor.ts: upgrade uninformative AST
  return types (array, iterable) with PHPDoc @return annotations

35 new integration tests (5 per language), 2525 total tests passing.

* fix(type-resolution): address PR #341 review findings — PHP asymmetry + dormant infrastructure docs

- Replace normalizePhpType with extractElementTypeFromString in PHP call-expression
  foreach paths, aligning with all 6 other language extractors and preventing
  incorrect binding of bare non-container types like User
- Add NOTE comments clarifying pendingCallResults Tier 2b is infrastructure-ready
  but no extractor populates it yet
- Expand Go channel-type comments explaining why non-channel assumption is safe

* fix(type-resolution): address verification review — docs accuracy + PHP fallback guard

- Roadmap lines 86/100: correct pendingCallResults from "active" to "dormant infrastructure (Phase 9)"
- type-resolution-system.md line 363: update to reflect Phase 7.3 loop inference is delivered
- type-resolution-system.md line 409: clarify for-loop call-expression resolution (done) vs general assignment propagation (pending)
- php.ts:127: add declaration_list type guard on fallback to prevent silent wrong results
2026-03-18 08:39:38 +00:00

942 lines
35 KiB
TypeScript

/**
* Go: package imports + cross-package calls + ambiguous struct disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Heritage: package imports + cross-package calls (exercises PackageMap)
// ---------------------------------------------------------------------------
describe('Go package import & call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-pkg'),
() => {},
);
}, 60000);
it('detects exactly 2 structs and 1 interface', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Admin', 'User']);
expect(getNodesByLabel(result, 'Interface')).toEqual(['Repository']);
});
it('detects exactly 5 functions', () => {
expect(getNodesByLabel(result, 'Function')).toEqual([
'Authenticate', 'NewAdmin', 'NewUser', 'ValidateToken', 'main',
]);
});
it('emits exactly 7 CALLS edges (5 function + 2 struct literal)', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(7);
expect(edgeSet(calls)).toEqual([
'Authenticate → NewUser',
'NewAdmin → Admin',
'NewAdmin → NewUser',
'NewUser → User',
'main → Authenticate',
'main → NewAdmin',
'main → NewUser',
]);
});
it('resolves exactly 7 IMPORTS edges across Go packages', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(7);
expect(edgeSet(imports)).toEqual([
'main.go → admin.go',
'main.go → repository.go',
'main.go → service.go',
'main.go → user.go',
'service.go → admin.go',
'service.go → repository.go',
'service.go → user.go',
]);
});
it('emits exactly 1 EXTENDS edge for struct embedding: Admin → User', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('Admin');
expect(extends_[0].target).toBe('User');
});
it('does not emit IMPLEMENTS edges (Go uses structural typing)', () => {
expect(getRelationships(result, 'IMPLEMENTS').length).toBe(0);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Ambiguous: Handler struct in two packages, package import disambiguates
// ---------------------------------------------------------------------------
describe('Go ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-ambiguous'),
() => {},
);
}, 60000);
it('detects 2 Handler structs in separate packages', () => {
const structs: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`);
});
const handlers = structs.filter(s => s.startsWith('Handler@'));
expect(handlers.length).toBe(2);
expect(handlers.some(h => h.includes('internal/models/'))).toBe(true);
expect(handlers.some(h => h.includes('internal/other/'))).toBe(true);
});
it('import resolves to internal/models/handler.go (not internal/other/)', () => {
const imports = getRelationships(result, 'IMPORTS');
const modelsImport = imports.find(e => e.targetFilePath.includes('models'));
expect(modelsImport).toBeDefined();
expect(modelsImport!.targetFilePath).toBe('internal/models/handler.go');
});
it('no import edge to internal/other/', () => {
const imports = getRelationships(result, 'IMPORTS');
for (const imp of imports) {
expect(imp.targetFilePath).not.toMatch(/internal\/other\//);
}
});
});
describe('Go call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-calls'),
() => {},
);
}, 60000);
it('resolves main → WriteAudit to internal/onearg/log.go via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(1);
expect(calls[0].source).toBe('main');
expect(calls[0].target).toBe('WriteAudit');
expect(calls[0].targetFilePath).toBe('internal/onearg/log.go');
expect(calls[0].rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.Method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Go member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-member-calls'),
() => {},
);
}, 60000);
it('resolves processUser → Save as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('processUser');
expect(saveCall!.targetFilePath).toBe('models/user.go');
});
it('detects User struct and Save method', () => {
const structs: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
});
});
// ---------------------------------------------------------------------------
// Struct literal resolution: User{...} resolves to Struct node
// ---------------------------------------------------------------------------
describe('Go struct literal resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-struct-literals'),
() => {},
);
}, 60000);
it('resolves User{...} as a CALLS edge to the User struct', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(c => c.target === 'User');
expect(ctorCall).toBeDefined();
expect(ctorCall!.source).toBe('processUser');
expect(ctorCall!.targetLabel).toBe('Struct');
expect(ctorCall!.targetFilePath).toBe('user.go');
});
it('also resolves user.Save() as a member call', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('processUser');
});
it('detects User struct, Save method, and processUser function', () => {
const structs: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
expect(getNodesByLabel(result, 'Function')).toContain('processUser');
});
});
// ---------------------------------------------------------------------------
// Receiver-constrained resolution: typed variables disambiguate same-named methods
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Multi-assignment: user, repo := User{}, Repo{} — both sides captured in TypeEnv
// ---------------------------------------------------------------------------
describe('Go multi-assignment short var declaration', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-multi-assign'),
() => {},
);
}, 60000);
it('detects User and Repo structs with their methods', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Repo', 'User']);
expect(getNodesByLabel(result, 'Method')).toEqual(['Persist', 'Save']);
});
it('resolves both struct literals in multi-assignment: User{} and Repo{}', () => {
const calls = getRelationships(result, 'CALLS');
const structCalls = calls.filter(c => c.targetLabel === 'Struct');
expect(edgeSet(structCalls)).toEqual([
'process → Repo',
'process → User',
]);
});
it('resolves user.Save() to User.Save and repo.Persist() to Repo.Persist via receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save');
const cloneCall = calls.find(c => c.target === 'Persist');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process');
expect(saveCall!.targetFilePath).toBe('models.go');
expect(cloneCall).toBeDefined();
expect(cloneCall!.source).toBe('process');
expect(cloneCall!.targetFilePath).toBe('models.go');
});
});
describe('Go receiver-constrained resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-receiver-resolution'),
() => {},
);
}, 60000);
it('detects User and Repo structs, both with Save methods', () => {
const structs: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(structs).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.Save() to User.Save and repo.Save() to Repo.Save via receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'Save');
expect(saveCalls.length).toBe(2);
const userSave = saveCalls.find(c => c.targetFilePath === 'models/user.go');
const repoSave = saveCalls.find(c => c.targetFilePath === 'models/repo.go');
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
expect(userSave!.source).toBe('processEntities');
expect(repoSave!.source).toBe('processEntities');
});
});
// ---------------------------------------------------------------------------
// Variadic resolution: ...interface{} doesn't get filtered by arity
// ---------------------------------------------------------------------------
describe('Go variadic call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-variadic-resolution'),
() => {},
);
}, 60000);
it('resolves 3-arg call to variadic func Entry(...interface{}) in logger.go', () => {
const calls = getRelationships(result, 'CALLS');
const logCall = calls.find(c => c.target === 'Entry');
expect(logCall).toBeDefined();
expect(logCall!.source).toBe('main');
expect(logCall!.targetFilePath).toBe('internal/logger/logger.go');
});
});
// ---------------------------------------------------------------------------
// Local shadow: unqualified call resolves to local function, not imported package
// ---------------------------------------------------------------------------
describe('Go local definition shadows import', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-local-shadow'),
() => {},
);
}, 60000);
it('resolves Save("test") to local Save in main.go, not utils.go', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save' && c.source === 'main');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('cmd/main.go');
});
});
// ---------------------------------------------------------------------------
// Constructor-inferred type resolution: user := models.User{}; user.Save()
// Go composite literal constructor pattern (no explicit type annotations)
// ---------------------------------------------------------------------------
describe('Go constructor-inferred type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-constructor-type-inference'),
() => {},
);
}, 60000);
it('detects User and Repo structs, both with Save methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.Save() to models/user.go via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('processEntities');
});
it('resolves repo.Save() to models/repo.go via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('processEntities');
});
it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'Save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Pointer-constructor-inferred type resolution: user := &models.User{...}; user.Save()
// Go address-of composite literal constructor pattern (no explicit type annotations)
// ---------------------------------------------------------------------------
describe('Go pointer-constructor-inferred type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-pointer-constructor-inference'),
() => {},
);
}, 60000);
it('detects User and Repo structs, both with Save methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.Save() to models/user.go via &User{} pointer-constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('process');
});
it('resolves repo.Save() to models/repo.go via &Repo{} pointer-constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('process');
});
it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'Save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Parent resolution: struct embedding emits EXTENDS
// ---------------------------------------------------------------------------
describe('Go parent resolution (struct embedding)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-parent-resolution'),
() => {},
);
}, 60000);
it('detects BaseModel and User structs', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['BaseModel', 'User']);
});
it('emits EXTENDS edge: User → BaseModel (struct embedding)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('User');
expect(extends_[0].target).toBe('BaseModel');
});
});
// ---------------------------------------------------------------------------
// Go new() builtin type inference: user := new(User); user.Save()
// ---------------------------------------------------------------------------
describe('Go new() builtin type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-new-builtin'),
() => {},
);
}, 60000);
it('resolves user.Save() via new(User) inference', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
});
it('resolves user.Greet() via new(User) inference', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go');
expect(greetCall).toBeDefined();
expect(greetCall!.source).toBe('main');
});
});
// ---------------------------------------------------------------------------
// Go make() builtin type inference: sl := make([]User, 0); sl[0].Save()
// ---------------------------------------------------------------------------
describe('Go make() builtin type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-make-builtin'),
() => {},
);
}, 60000);
it('resolves sl[0].Save() via make([]User, 0) slice inference', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
});
it('resolves m["key"].Greet() via make(map[string]User) map inference', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go');
expect(greetCall).toBeDefined();
expect(greetCall!.source).toBe('main');
});
});
// ---------------------------------------------------------------------------
// Go type assertion inference: user := s.(User); user.Save()
// ---------------------------------------------------------------------------
describe('Go type assertion type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-type-assertion'),
() => {},
);
}, 60000);
it('resolves user.Save() via type assertion s.(User)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process');
});
it('resolves user.Greet() via type assertion s.(User)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go');
expect(greetCall).toBeDefined();
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();
});
});
// ---------------------------------------------------------------------------
// Nullable receiver: var user *models.User = findUser(); user.Save()
// Go pointer types (*User) — extractSimpleTypeName strips pointer prefix.
// ---------------------------------------------------------------------------
describe('Go nullable receiver resolution (pointer types)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-nullable-receiver'),
() => {},
);
}, 60000);
it('detects User and Repo structs, both with Save methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.Save() to User.Save via pointer receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('processEntities');
});
it('resolves repo.Save() to Repo.Save via pointer receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('processEntities');
});
it('user.Save() does NOT resolve to Repo.Save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'processEntities');
expect(saveCalls.filter(c => c.targetFilePath === 'models/user.go').length).toBe(1);
expect(saveCalls.filter(c => c.targetFilePath === 'models/repo.go').length).toBe(1);
});
it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'Save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Assignment chain propagation (Phase 4.3)
// ---------------------------------------------------------------------------
describe('Go assignment chain propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-assignment-chain'),
() => {},
);
}, 60000);
it('detects User and Repo structs each with a Save method', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
expect(saveMethods.length).toBe(2);
});
it('resolves alias.Save() to User#Save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
// Positive: alias.Save() must resolve to User#Save
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'),
);
expect(userSave).toBeDefined();
});
it('alias.Save() does NOT resolve to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
// Negative: alias comes from User, so only one edge to user.go
const wrongCall = calls.filter(c =>
c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'),
);
expect(wrongCall.length).toBe(1);
});
it('resolves rAlias.Save() to Repo#Save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
// Positive: rAlias.Save() must resolve to Repo#Save
const repoSave = calls.find(c =>
c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('repo.go'),
);
expect(repoSave).toBeDefined();
});
it('each alias resolves to its own struct, not the other', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'),
);
const repoSave = calls.find(c =>
c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('repo.go'),
);
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
});
// --- var form assignment chain ---
it('resolves var alias.Save() to User via var assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processWithVar' && c.targetFilePath.includes('user.go'),
);
expect(userSave).toBeDefined();
});
it('resolves var rAlias.Save() to Repo via var assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'Save' && c.source === 'processWithVar' && c.targetFilePath.includes('repo.go'),
);
expect(repoSave).toBeDefined();
});
it('var alias.Save() does NOT resolve to Repo (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const userSaves = calls.filter(c =>
c.target === 'Save' && c.source === 'processWithVar' && c.targetFilePath.includes('user.go'),
);
expect(userSaves.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Chained method calls: svc.GetUser().Save()
// Tests that Go chain call resolution correctly infers the intermediate
// receiver type from GetUser()'s return type and resolves Save() to User.
// ---------------------------------------------------------------------------
describe('Go chained method call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-chain-call'),
() => {},
);
}, 60000);
it('detects User, Repo structs and UserService', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
expect(getNodesByLabel(result, 'Struct')).toContain('UserService');
});
it('detects GetUser and Save symbols', () => {
const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')];
expect(allSymbols).toContain('GetUser');
expect(allSymbols).toContain('Save');
});
it('resolves svc.GetUser().Save() to User#Save via chain resolution', () => {
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('does NOT resolve svc.GetUser().Save() to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'Save' &&
c.source === 'processUser' &&
c.targetFilePath?.includes('repo.go'),
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Go map range: for _, user := range userMap where map[string]User
// ---------------------------------------------------------------------------
describe('Go map range type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-map-range'),
() => {},
);
}, 60000);
it('detects User and Repo structs with Save methods in separate files', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('Repo');
const methods = getNodesByLabel(result, 'Method');
expect(methods.filter(m => m === 'Save').length).toBe(2);
});
it('resolves user.Save() in map range to User#Save via map_type value', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processMap' && c.targetFilePath?.includes('user.go'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.Save() to Repo#Save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'Save' && c.source === 'processMap' && c.targetFilePath?.includes('repo.go'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Go for-loop with call_expression iterable: for _, user := range GetUsers()
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
// ---------------------------------------------------------------------------
describe('Go for-loop call_expression iterable resolution (Phase 7.3)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-for-call-expr'),
() => {},
);
}, 60000);
it('detects User and Repo structs with competing Save methods', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('Repo');
const methods = getNodesByLabel(result, 'Method');
expect(methods.filter(m => m === 'Save').length).toBe(2);
});
it('resolves user.Save() in range GetUsers() to User#Save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processUsers' && c.targetFilePath?.includes('user.go'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.Save() in range GetRepos() to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'Save' && c.source === 'processRepos' && c.targetFilePath?.includes('repo.go'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve user.Save() to Repo#Save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'Save' && c.source === 'processUsers' && c.targetFilePath?.includes('repo.go'),
);
expect(wrongSave).toBeUndefined();
});
it('does NOT resolve repo.Save() to User#Save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'Save' && c.source === 'processRepos' && c.targetFilePath?.includes('user.go'),
);
expect(wrongSave).toBeUndefined();
});
});