mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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
870 lines
34 KiB
TypeScript
870 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
|
import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.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('receiverKey collision: same method name in different classes does not collide', async () => {
|
|
// User.save@100 and Repo.save@200 are two methods named "save" in different classes.
|
|
// Each has a local variable "db" pointing to a different type.
|
|
// Without @startIndex in the key, the second binding would overwrite the first.
|
|
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
|
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
|
|
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
|
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' });
|
|
ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts']));
|
|
ctx.importMap.set('src/models/Repo.ts', new Set(['src/db/Cache.ts']));
|
|
|
|
// Two bindings: both enclosing scope is named "save" but at different startIndexes
|
|
const constructorBindings: FileConstructorBindings[] = [
|
|
{
|
|
filePath: 'src/models/User.ts',
|
|
bindings: [
|
|
// save@100: inside User.save(), db = new Database()
|
|
{ scope: 'save@100', varName: 'db', calleeName: 'Database' },
|
|
],
|
|
},
|
|
{
|
|
filePath: 'src/models/Repo.ts',
|
|
bindings: [
|
|
// save@200: inside Repo.save(), db = new Cache()
|
|
{ scope: 'save@200', varName: 'db', calleeName: 'Cache' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const calls: ExtractedCall[] = [
|
|
{
|
|
filePath: 'src/models/User.ts',
|
|
calledName: 'query',
|
|
sourceId: 'Method:src/models/User.ts:save',
|
|
receiverName: 'db',
|
|
callForm: 'member',
|
|
},
|
|
{
|
|
filePath: 'src/models/Repo.ts',
|
|
calledName: 'query',
|
|
sourceId: 'Method:src/models/Repo.ts:save',
|
|
receiverName: 'db',
|
|
callForm: 'member',
|
|
},
|
|
];
|
|
|
|
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
|
|
|
|
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
|
expect(rels).toHaveLength(2);
|
|
const userQueryRel = rels.find(r => r.sourceId === 'Method:src/models/User.ts:save');
|
|
const repoQueryRel = rels.find(r => r.sourceId === 'Method:src/models/Repo.ts:save');
|
|
expect(userQueryRel?.targetId).toBe('Method:src/db/Database.ts:query');
|
|
expect(repoQueryRel?.targetId).toBe('Method:src/db/Cache.ts:query');
|
|
});
|
|
|
|
it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => {
|
|
// Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve.
|
|
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
|
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
|
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts']));
|
|
|
|
const constructorBindings: FileConstructorBindings[] = [{
|
|
filePath: 'src/service.ts',
|
|
bindings: [
|
|
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
|
|
{ scope: 'save@50', varName: 'db', calleeName: 'Database' },
|
|
],
|
|
}];
|
|
|
|
const calls: ExtractedCall[] = [{
|
|
filePath: 'src/service.ts',
|
|
calledName: 'query',
|
|
sourceId: 'Method:src/service.ts:save',
|
|
receiverName: 'db',
|
|
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/db/Database.ts:query');
|
|
});
|
|
|
|
it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => {
|
|
// Two save@* scopes in the same file bind "db" to different types — truly ambiguous.
|
|
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
|
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
|
|
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
|
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' });
|
|
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts']));
|
|
|
|
const constructorBindings: FileConstructorBindings[] = [{
|
|
filePath: 'src/service.ts',
|
|
bindings: [
|
|
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
|
|
{ scope: 'save@50', varName: 'db', calleeName: 'Cache' },
|
|
],
|
|
}];
|
|
|
|
const calls: ExtractedCall[] = [{
|
|
filePath: 'src/service.ts',
|
|
calledName: 'query',
|
|
sourceId: 'Method:src/service.ts:save',
|
|
receiverName: 'db',
|
|
callForm: 'member',
|
|
}];
|
|
|
|
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
|
|
|
|
// Ambiguous — different types for same funcName+varName, should not emit a CALLS edge
|
|
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
|
expect(rels).toHaveLength(0);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// ---- Length caps (Phase 6) ----
|
|
|
|
it('pre-cap: returns undefined when raw input exceeds 2048 characters', () => {
|
|
const longInput = 'A'.repeat(2049);
|
|
expect(extractReturnTypeName(longInput)).toBeUndefined();
|
|
});
|
|
|
|
it('pre-cap: accepts raw input at exactly 2048 characters (boundary)', () => {
|
|
// A 2048-char string of uppercase letters passes the pre-cap gate.
|
|
// It won't match as a valid identifier (too long for post-cap), so the
|
|
// result is undefined — but the pre-cap itself does NOT reject it.
|
|
// We test this by verifying a 2048-char type that WOULD be valid in all
|
|
// other respects is still returned as undefined (post-cap rejects it).
|
|
const atLimit = 'U' + 'x'.repeat(2047); // 2048 chars, starts with uppercase
|
|
// Post-cap (512) will reject this, but the pre-cap should not fire.
|
|
// The important assertion: no throw and the result is undefined from post-cap.
|
|
expect(extractReturnTypeName(atLimit)).toBeUndefined();
|
|
});
|
|
|
|
it('pre-cap: accepts inputs shorter than 2048 characters without rejection', () => {
|
|
// 'User' is well under 2048 — should resolve normally.
|
|
expect(extractReturnTypeName('User')).toBe('User');
|
|
});
|
|
|
|
it('post-cap: returns undefined when extracted type name exceeds 512 characters', () => {
|
|
// Construct a raw string that is under the 2048-char pre-cap but produces
|
|
// a final identifier longer than 512 characters after extraction.
|
|
// A bare uppercase identifier of 513 chars satisfies all rules except post-cap.
|
|
const longTypeName = 'U' + 'x'.repeat(512); // 513 chars, starts with uppercase
|
|
expect(extractReturnTypeName(longTypeName)).toBeUndefined();
|
|
});
|
|
|
|
it('post-cap: accepts extracted type name at exactly 512 characters (boundary)', () => {
|
|
// 512-char identifier should pass the post-cap check (> 512 rejects, not >=).
|
|
const atLimit = 'U' + 'x'.repeat(511); // exactly 512 chars
|
|
expect(extractReturnTypeName(atLimit)).toBe(atLimit);
|
|
});
|
|
|
|
it('post-cap: accepts normal short type names well under 512 characters', () => {
|
|
expect(extractReturnTypeName('HttpClient')).toBe('HttpClient');
|
|
expect(extractReturnTypeName('UserService')).toBe('UserService');
|
|
});
|
|
});
|