mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-05 07:37:30 +00:00
* fix(scope): bind same-namespace inheritance bases before sibling visibility exists Cause: same-package / same-namespace visibility (`populateNamespaceSiblings`) is populated after the inheritance pre-pass and `buildMro`. A base written without an import (`class C extends B`, B in another file of the package) could bind only through the workspace-unique simple-name fallback, which any other package declaring a `B` defeats: no EXTENDS edge, an empty MRO, and lost calls through inherited members. In the combined lang-resolution corpus `java-grandparent-resolution` `c.greet().save()` dropped because `java-unrelated-method-collision` declares default-package A and B. Moving the sibling hook ahead of the pre-pass was measured and rejected. It retargeted correct edges and minted wrong ones, because the sibling channels carry candidates the pre-pass must not see: C# global-namespace types are consulted at every scope (same-file `Cat : Animal` bound another fixture's global Animal), namespace buckets merge across source roots with no proximity order (PHP, C#), and a qualified `extends app.base.Box` bound through a same-package `app.Box`. Fix: an optional `ScopeResolver.resolveImplicitInheritanceBase`, consulted by the pre-pass only when the shared chain bound nothing, the base was written bare (no qualifier, name capture at the start of the base expression), and no import claims the name. It adds edges and never retargets one. `resolveSameNamespaceInheritanceBase` picks the candidate in the deriving class's namespace, nearest by shared path prefix, refusing a tie. Java and Kotlin answer from their package facts, C# from the exact `namespacePrefix`. Corpus A/B (main vs this, same corpus): EXTENDS/IMPLEMENTS +42, all within their own fixture, 0 removed. CALLS: six `super.save()` edges keep their target but are now MRO-dispatched (`global`, as in isolation), one moves off another fixture's BaseModel.Save onto its own, and 7 are new (both grandparent `greet` calls, a default-method `validate`, the new fixtures). Receiver bench: call drops 128 -> 127, all kinds 192 -> 191, shape arm unchanged. Scope-capture fingerprints re-baselined for the added fixtures only (parked, --check passes on the old ones). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(java): resolve a duplicated package+type import to the importer's own root Cause: `buildJavaPackageIndex` kept the first file declaring each package+type. One repository can hold several build modules or sample projects that each declare `models.User`, so every `import models.User;` bound whichever copy was parsed first, a wrong IMPORTS edge at full confidence that carries the receiver types behind it. In the combined lang-resolution corpus 24 fixtures declare `models.User`, and e.g. `java-heritage` UserService.processUser called `java-assignment-chain/models/User.save`. Fix: the index keeps every declaring file in a sparse `duplicateTypes` map (only colliding pairs, so a normal workspace pays nothing), and a type import resolves to the declaring file sharing the longest leading path with the importer (its own module or source root). A tie, or an unknown importer, refuses rather than guessing. Wildcard imports are unchanged. Corpus A/B (previous commit vs this, same corpus): 40 CALLS edges that crossed into another fixture's User/Repo/Address/UserService are gone, 55 CALLS/ACCESSES edges now land in their own fixture (0 cross-fixture), and `java-parent-resolution` User IMPLEMENTS Serializable moves off `java-heritage` onto its own interface. Receiver-unresolved drops unchanged; import-target and receiver benches pass. Scope-capture java fingerprint re-baselined for the added fixture only (parked, --check passes on the old one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scope): respect base candidate visibility * fix(scope): match written JVM base arity --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3717 lines
148 KiB
TypeScript
3717 lines
148 KiB
TypeScript
/**
|
|
* Java: class extends + implements multiple interfaces + ambiguous package disambiguation
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import path from 'path';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import { _captureLogger, type PinoLogRecord } from '../../../src/core/logger.js';
|
|
import {
|
|
FIXTURES,
|
|
CROSS_FILE_FIXTURES,
|
|
getRelationships,
|
|
getNodesByLabel,
|
|
getNodesByLabelFull,
|
|
edgeSet,
|
|
getResolutionOutcomes,
|
|
runPipelineFromRepo,
|
|
writeFixtureRepo,
|
|
type PipelineResult,
|
|
} from './helpers.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Heritage: class extends + implements multiple interfaces
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java heritage resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-heritage'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects exactly 3 classes and 2 interfaces', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']);
|
|
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']);
|
|
});
|
|
|
|
it('emits exactly 1 EXTENDS edge: User → BaseModel', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('User');
|
|
expect(extends_[0].target).toBe('BaseModel');
|
|
});
|
|
|
|
it('emits exactly 2 IMPLEMENTS edges: User → Serializable, User → Validatable', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(implements_.length).toBe(2);
|
|
expect(edgeSet(implements_)).toEqual(['User → Serializable', 'User → Validatable']);
|
|
});
|
|
|
|
it('resolves exactly 4 IMPORTS edges', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
expect(imports.length).toBe(4);
|
|
expect(edgeSet(imports)).toEqual([
|
|
'User.java → Serializable.java',
|
|
'User.java → Validatable.java',
|
|
'UserService.java → Serializable.java',
|
|
'UserService.java → User.java',
|
|
]);
|
|
});
|
|
|
|
it('does not emit EXTENDS edges to interfaces', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.some((e) => e.target === 'Serializable')).toBe(false);
|
|
expect(extends_.some((e) => e.target === 'Validatable')).toBe(false);
|
|
});
|
|
|
|
it('emits exactly 3 CALLS edges', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(calls.length).toBe(3);
|
|
expect(edgeSet(calls)).toEqual([
|
|
'processUser → save',
|
|
'processUser → serialize',
|
|
'processUser → validate',
|
|
]);
|
|
});
|
|
|
|
it('no OVERRIDES edges target Property nodes', () => {
|
|
const overrides = getRelationships(result, 'METHOD_OVERRIDES');
|
|
for (const edge of overrides) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.label).not.toBe('Property');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generic-base heritage (#1951): extends Box<T> + implements IFoo<T>. An
|
|
// earlier query was type_identifier-only and matched 0 generic bases; the synth
|
|
// resolves the generic base to its bare name. Scope-resolution (the single path
|
|
// since #942) owns these edges.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java generic-base heritage resolution (#1951)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-generic-base'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits EXTENDS Service → Box for a generic superclass (extends Box<String>)', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toEqual(['Service → Box']);
|
|
});
|
|
|
|
it('emits IMPLEMENTS Service → IFoo for a generic interface (implements IFoo<String>)', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(edgeSet(implements_)).toEqual(['Service → IFoo']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Qualified (namespaced) bases (#1956 tri-review U2). Three shapes:
|
|
// - Service: 3-segment generic (extends app.base.Box<T>, implements app.base.IFoo<T>)
|
|
// - Plain: 2-segment plain (extends base.Base, implements base.IBar)
|
|
// - Two: 2-segment generic (extends base.Box<T>, implements base.IFoo<T>)
|
|
// Scope-resolution resolves each by its scoped-name tail (end-anchored
|
|
// scoped_type_identifier handling). The 2-segment cases are the regression
|
|
// guard: an un-anchored arm double-matches a 2-segment base (both segments are
|
|
// direct type_identifier children) and emits a spurious prefix edge — this
|
|
// asserts exactly one edge per base.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java qualified-base heritage resolution (#1956 U2)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-base'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits exactly one EXTENDS per class, tail-resolved (no spurious prefix edge)', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toEqual(['Plain → Base', 'Service → Box', 'Two → Box']);
|
|
});
|
|
|
|
it('emits exactly one IMPLEMENTS per class, tail-resolved (no spurious prefix edge)', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(edgeSet(implements_)).toEqual(['Plain → IBar', 'Service → IFoo', 'Two → IFoo']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interface-to-interface EXTENDS (#1951): `interface IA extends IB, IC<String>`.
|
|
// An earlier synth walked class_declaration ONLY, so it NEVER emitted
|
|
// interface-to-interface heritage — production silently dropped these edges.
|
|
// Widening the synth's traversal to also walk
|
|
// interface_declaration > extends_interfaces > type_list closes it.
|
|
// Both bases resolve to Interface symbols, so preEmitInheritanceEdges emits them
|
|
// as IMPLEMENTS. IC<String> exercises the generic-base reduction (IC<String> ->
|
|
// IC). Scope-resolution (the single resolution path since #942) owns these
|
|
// edges.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java interface-extends-interface heritage resolution (#1951)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-iface-extends'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects 3 interfaces and no classes', () => {
|
|
expect(getNodesByLabel(result, 'Interface')).toEqual(['IA', 'IB', 'IC']);
|
|
expect(getNodesByLabel(result, 'Class')).toEqual([]);
|
|
});
|
|
|
|
it('emits IMPLEMENTS IA → IB and IA → IC for interface-to-interface extends', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(edgeSet(implements_)).toEqual(['IA → IB', 'IA → IC']);
|
|
});
|
|
|
|
it('emits no EXTENDS edges (interface bases resolve to Interface → IMPLEMENTS)', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_).toEqual([]);
|
|
});
|
|
|
|
it('all interface-heritage edges point to real Interface graph nodes', () => {
|
|
for (const edge of getRelationships(result, 'IMPLEMENTS')) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.properties.name).toBe(edge.target);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ambiguous: Handler + Processor in two packages, imports disambiguate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java ambiguous symbol resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-ambiguous'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects 2 Handler classes and 2 Processor interfaces', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes.filter((n) => n === 'Handler').length).toBe(2);
|
|
expect(classes).toContain('UserHandler');
|
|
const ifaces = getNodesByLabel(result, 'Interface');
|
|
expect(ifaces.filter((n) => n === 'Processor').length).toBe(2);
|
|
});
|
|
|
|
it('resolves EXTENDS to models/Handler (not other/Handler)', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('UserHandler');
|
|
expect(extends_[0].target).toBe('Handler');
|
|
expect(extends_[0].targetFilePath).toBe('models/Handler.java');
|
|
});
|
|
|
|
it('resolves IMPLEMENTS to models/Processor (not other/Processor)', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(implements_.length).toBe(1);
|
|
expect(implements_[0].source).toBe('UserHandler');
|
|
expect(implements_[0].target).toBe('Processor');
|
|
expect(implements_[0].targetFilePath).toBe('models/Processor.java');
|
|
});
|
|
|
|
it('import edges point to models/ not other/', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const targets = imports.map((e) => e.target).sort();
|
|
expect(targets).toContain('Handler.java');
|
|
expect(targets).toContain('Processor.java');
|
|
for (const imp of imports) {
|
|
expect(imp.targetFilePath).toMatch(/^models\//);
|
|
}
|
|
});
|
|
|
|
it('all heritage edges point to real graph nodes', () => {
|
|
for (const edge of [
|
|
...getRelationships(result, 'EXTENDS'),
|
|
...getRelationships(result, 'IMPLEMENTS'),
|
|
]) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.properties.name).toBe(edge.target);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Java qualified class names', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('stores distinct qualified names for same-named classes across packages', () => {
|
|
const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User');
|
|
expect(users).toHaveLength(2);
|
|
expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([
|
|
'com.example.admin.User',
|
|
'com.example.models.User',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Java call resolution with arity filtering', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves processUser → writeAudit to util/OneArg.java via arity narrowing', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].source).toBe('processUser');
|
|
expect(calls[0].target).toBe('writeAudit');
|
|
expect(calls[0].targetFilePath).toBe('util/OneArg.java');
|
|
expect(calls[0].rel.reason).toBe('import-resolved');
|
|
});
|
|
});
|
|
|
|
describe('Java same-module priority for duplicate FQNs', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-duplicate-fqn-modules'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves Module1App.run calls to module1 UserService, not module2', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const module1ToModule1 = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' &&
|
|
c.targetFilePath === 'module1/src/main/java/com/example/UserService.java',
|
|
);
|
|
const module1ToModule2 = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' &&
|
|
c.targetFilePath === 'module2/src/main/java/com/example/UserService.java',
|
|
);
|
|
const module1ToAnyUserService = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' &&
|
|
/module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath),
|
|
);
|
|
|
|
expect(module1ToModule1.length).toBe(1);
|
|
expect(module1ToModule2.length).toBe(0);
|
|
expect(module1ToAnyUserService.length).toBe(1);
|
|
});
|
|
|
|
it('resolves Module2App.run calls to module2 UserService, not module1', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const module2ToModule2 = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' &&
|
|
c.targetFilePath === 'module2/src/main/java/com/example/UserService.java',
|
|
);
|
|
const module2ToModule1 = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' &&
|
|
c.targetFilePath === 'module1/src/main/java/com/example/UserService.java',
|
|
);
|
|
const module2ToAnyUserService = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'UserService' &&
|
|
c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' &&
|
|
/module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath),
|
|
);
|
|
|
|
expect(module2ToModule2.length).toBe(1);
|
|
expect(module2ToModule1.length).toBe(0);
|
|
expect(module2ToAnyUserService.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Member-call resolution: obj.method() resolves through pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java member-call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-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.java');
|
|
});
|
|
|
|
it('detects User class and save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
|
});
|
|
|
|
it('emits HAS_METHOD edge from User to save', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const edge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor resolution: new Foo() resolves to Constructor/Class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java constructor-call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-constructor-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves new User() as a CALLS edge to the User constructor', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const ctorCall = calls.find((c) => c.target === 'User');
|
|
expect(ctorCall).toBeDefined();
|
|
expect(ctorCall!.source).toBe('processUser');
|
|
// Java has explicit constructor_declaration → Constructor node
|
|
expect(ctorCall!.targetLabel).toBe('Constructor');
|
|
expect(ctorCall!.targetFilePath).toBe('models/User.java');
|
|
});
|
|
|
|
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 class, User constructor, save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Constructor')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Receiver-constrained resolution: typed variables disambiguate same-named methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java receiver-constrained resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-receiver-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, both with save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).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.java');
|
|
const repoSave = saveCalls.find((c) => c.targetFilePath === 'models/Repo.java');
|
|
|
|
expect(userSave).toBeDefined();
|
|
expect(repoSave).toBeDefined();
|
|
expect(userSave!.source).toBe('processEntities');
|
|
expect(repoSave!.source).toBe('processEntities');
|
|
});
|
|
|
|
it('resolves constructor calls for both User and Repo', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userCtor = calls.find((c) => c.target === 'User');
|
|
const repoCtor = calls.find((c) => c.target === 'Repo');
|
|
expect(userCtor).toBeDefined();
|
|
expect(repoCtor).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method references: expr::method, Type::method, Type::new, this::m, super::m
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java method-reference resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-method-reference'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves project method references to CALLS edges', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
|
|
expect(
|
|
calls.find(
|
|
(c) =>
|
|
c.source === 'mapViaInstanceBuilder' &&
|
|
c.target === 'buildResponse' &&
|
|
c.targetFilePath === 'models/ResponseBuilder.java',
|
|
),
|
|
).toBeDefined();
|
|
|
|
expect(
|
|
calls.find(
|
|
(c) =>
|
|
c.source === 'mapViaStaticUtil' &&
|
|
c.target === 'format' &&
|
|
c.targetFilePath === 'util/FormatUtil.java',
|
|
),
|
|
).toBeDefined();
|
|
|
|
expect(
|
|
calls.find(
|
|
(c) =>
|
|
c.source === 'mapUserNames' &&
|
|
c.target === 'getName' &&
|
|
c.targetFilePath === 'models/User.java',
|
|
),
|
|
).toBeDefined();
|
|
|
|
expect(
|
|
calls.find(
|
|
(c) =>
|
|
c.source === 'mapSaves' &&
|
|
c.target === 'saveOne' &&
|
|
c.targetFilePath === 'services/MethodRefService.java',
|
|
),
|
|
).toBeDefined();
|
|
|
|
expect(
|
|
calls.find(
|
|
(c) =>
|
|
c.source === 'wrapTransform' &&
|
|
c.target === 'transform' &&
|
|
c.targetFilePath === 'models/BaseHandler.java',
|
|
),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('resolves constructor references to Constructor nodes', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const ctorRef = calls.find(
|
|
(c) =>
|
|
c.source === 'mapNewUsers' &&
|
|
c.target === 'User' &&
|
|
c.targetFilePath === 'models/User.java',
|
|
);
|
|
|
|
expect(ctorRef).toBeDefined();
|
|
expect(ctorRef!.targetLabel).toBe('Constructor');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Named import disambiguation: two User classes, import resolves to correct one
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java named import disambiguation', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-named-imports'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects two User classes in different packages', () => {
|
|
const users = getNodesByLabel(result, 'Class').filter((n) => n === 'User');
|
|
expect(users.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() to com/example/models/User.java via named import', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.source).toBe('run');
|
|
expect(saveCall!.targetFilePath).toBe('com/example/models/User.java');
|
|
});
|
|
|
|
it('resolves new User() to com/example/models/User.java, not other/', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const ctorCall = calls.find((c) => c.target === 'User' && c.source === 'run');
|
|
expect(ctorCall).toBeDefined();
|
|
expect(ctorCall!.targetFilePath).toBe('com/example/models/User.java');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Declared-package resolution (#2910): external imports cannot suffix-match
|
|
// local lookalikes, while file layout does not override package declarations.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java declared-package import resolution (#2910)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-import-package-evidence'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('does not emit an IMPORTS edge from java.util.List to local util/List.java', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
expect(
|
|
imports.some(
|
|
(edge) =>
|
|
edge.sourceFilePath === 'app/Main.java' && edge.targetFilePath === 'util/List.java',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('resolves a declared package even when the file path is flattened', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
expect(
|
|
imports.some(
|
|
(edge) => edge.sourceFilePath === 'app/Main.java' && edge.targetFilePath === 'User.java',
|
|
),
|
|
).toBe(true);
|
|
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(
|
|
calls.some(
|
|
(edge) =>
|
|
edge.source === 'run' && edge.target === 'save' && edge.targetFilePath === 'User.java',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Variadic resolution: String... doesn't get filtered by arity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java variadic call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-variadic-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves 3-arg call to varargs method record(String...) in Logger.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const logCall = calls.find((c) => c.target === 'record');
|
|
expect(logCall).toBeDefined();
|
|
expect(logCall!.source).toBe('run');
|
|
expect(logCall!.targetFilePath).toBe('com/example/util/Logger.java');
|
|
});
|
|
|
|
it('CALLS edges from within variadic method have valid sourceId (no ID mismatch)', () => {
|
|
// Collect all CALLS edges whose source is in Logger.java
|
|
const danglingSourceIds: string[] = [];
|
|
for (const rel of result.graph.iterRelationships()) {
|
|
if (rel.type !== 'CALLS') continue;
|
|
const sourceNode = result.graph.getNode(rel.sourceId);
|
|
if (!sourceNode) {
|
|
danglingSourceIds.push(rel.sourceId);
|
|
continue;
|
|
}
|
|
// Specifically flag Logger.java sources that don't resolve
|
|
if (
|
|
sourceNode.properties.filePath === 'com/example/util/Logger.java' &&
|
|
!result.graph.getNode(rel.sourceId)
|
|
) {
|
|
danglingSourceIds.push(rel.sourceId);
|
|
}
|
|
}
|
|
|
|
// No CALLS edge should have a dangling (unresolvable) sourceId.
|
|
// This catches the bug where definition creates Method:...record#N but
|
|
// findEnclosingFunctionId generates Method:...record (no suffix),
|
|
// producing CALLS edges whose sourceId doesn't match any graph node.
|
|
expect(danglingSourceIds).toEqual([]);
|
|
|
|
// Additionally verify that ALL relationships (not just CALLS) have
|
|
// resolvable sourceIds — a stronger invariant.
|
|
const allDangling: string[] = [];
|
|
for (const rel of result.graph.iterRelationships()) {
|
|
if (!result.graph.getNode(rel.sourceId)) {
|
|
allDangling.push(`${rel.type}:${rel.sourceId}`);
|
|
}
|
|
}
|
|
expect(allDangling).toEqual([]);
|
|
});
|
|
|
|
it('resolves 2-arg call to fixed-prefix varargs method format(int, String...) in Formatter.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fmtCall = calls.find((c) => c.target === 'format' && c.source === 'run');
|
|
expect(fmtCall).toBeDefined();
|
|
expect(fmtCall!.targetFilePath).toBe('com/example/util/Formatter.java');
|
|
});
|
|
|
|
it('0-arg call to format(int, String...) still resolves in legacy mode (arity rejection is registry-only)', () => {
|
|
// When `requiredParameterCount = 1`, `javaArityCompatibility` returns
|
|
// 'incompatible' for 0-arg calls, which would prevent the CALLS edge.
|
|
// This test documents the current resolution behavior for this shape.
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const zeroArgFmtCall = calls.find((c) => c.target === 'format' && c.source === 'badCall');
|
|
expect(zeroArgFmtCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wildcard import: `import com.example.models.*` resolves every package file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java wildcard import resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-wildcard-import'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits IMPORTS edges to every file declaring the wildcard package', () => {
|
|
const imports = getRelationships(result, 'IMPORTS').filter(
|
|
(edge) => edge.sourceFilePath === 'com/example/app/Main.java',
|
|
);
|
|
expect(imports.map((edge) => edge.targetFilePath).sort()).toEqual([
|
|
'com/example/models/Order.java',
|
|
'com/example/models/User.java',
|
|
]);
|
|
});
|
|
|
|
it('resolves user.save() call via wildcard-imported User', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toBe('com/example/models/User.java');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local shadow: same-file definition takes priority over imported name
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java local definition shadows import', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-local-shadow'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves run → save to same-file definition, not the imported one', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toBe('src/main/java/com/example/app/Main.java');
|
|
});
|
|
|
|
it('does NOT resolve save to Logger.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveToUtils = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.targetFilePath === 'src/main/java/com/example/utils/Logger.java',
|
|
);
|
|
expect(saveToUtils).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor-inferred type resolution: var user = new User(); user.save()
|
|
// Java 10+ local variable type inference (no explicit type annotations)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java constructor-inferred type resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-constructor-type-inference'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, both with save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() to models/User.java via constructor-inferred type', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/User.java',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(userSave!.source).toBe('processEntities');
|
|
});
|
|
|
|
it('resolves repo.save() to models/Repo.java via constructor-inferred type', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/Repo.java',
|
|
);
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// For-each loop element typing: for (User user : users) user.save()
|
|
// Java: explicit type in enhanced_for_statement binds loop variable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java for-each loop element type resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-foreach'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, both with save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() in for-each to User#save (not Repo#save)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/User.java',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(userSave!.source).toBe('processEntities');
|
|
});
|
|
|
|
it('resolves repo.save() in for-each to Repo#save (not User#save)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/Repo.java',
|
|
);
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// this.save() resolves to enclosing class's own save method
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java this resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-self-this-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, each with a save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves this.save() inside User.process to User.save, not Repo.save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toBe('src/models/User.java');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parent class resolution: EXTENDS + IMPLEMENTS edges
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java parent resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-parent-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects BaseModel and User classes plus Serializable interface', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']);
|
|
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']);
|
|
});
|
|
|
|
it('emits EXTENDS edge: User → BaseModel', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('User');
|
|
expect(extends_[0].target).toBe('BaseModel');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge: User → Serializable', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
expect(implements_.length).toBe(1);
|
|
expect(implements_[0].source).toBe('User');
|
|
expect(implements_[0].target).toBe('Serializable');
|
|
});
|
|
|
|
it('all heritage edges point to real graph nodes', () => {
|
|
for (const edge of [
|
|
...getRelationships(result, 'EXTENDS'),
|
|
...getRelationships(result, 'IMPLEMENTS'),
|
|
]) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.properties.name).toBe(edge.target);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// super.save() resolves to parent class's save method
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java super resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-super-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects BaseModel, User, and Repo classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']);
|
|
});
|
|
|
|
it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const superSave = calls.find(
|
|
(c) =>
|
|
c.source === 'save' &&
|
|
c.target === 'save' &&
|
|
c.targetFilePath === 'src/models/BaseModel.java',
|
|
);
|
|
expect(superSave).toBeDefined();
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'src/models/Repo.java',
|
|
);
|
|
expect(repoSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// super.save() resolves to generic parent class's save method
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java generic parent super resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-generic-parent-resolution'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects BaseModel, User, and Repo classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']);
|
|
});
|
|
|
|
it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const superSave = calls.find(
|
|
(c) =>
|
|
c.source === 'save' &&
|
|
c.target === 'save' &&
|
|
c.targetFilePath === 'src/models/BaseModel.java',
|
|
);
|
|
expect(superSave).toBeDefined();
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'src/models/Repo.java',
|
|
);
|
|
expect(repoSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Return type inference: var user = svc.getUser("alice"); user.save()
|
|
// Java's CONSTRUCTOR_BINDING_SCANNER handles `var` declarations with
|
|
// method_invocation values, enabling end-to-end return type inference.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java return type inference via explicit method return type', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-return-type-inference'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and UserService classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
|
});
|
|
|
|
it('detects save and getUser methods', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('save');
|
|
expect(methods).toContain('getUser');
|
|
});
|
|
|
|
it('resolves user.save() to User#save via return type of getUser(): User', () => {
|
|
// Java's CONSTRUCTOR_BINDING_SCANNER binds `var user = svc.getUser()` to the
|
|
// return type of getUser (User), so the subsequent user.save() call resolves
|
|
// to User#save rather than an unresolved target.
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nullable receiver: Java uses explicit type annotations (User user = findUser())
|
|
// Tests that regular typed receiver resolution works with competing save() methods
|
|
// when the variable is assigned from a factory method returning the same type.
|
|
// Note: Java Optional<User> stores just "Optional" in TypeEnv (generics stripped),
|
|
// so this test uses plain typed variables to validate receiver disambiguation.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java nullable receiver resolution (typed factory return)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-nullable-receiver'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, both with save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() to User.save via receiver typing', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/User.java',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(userSave!.source).toBe('processEntities');
|
|
});
|
|
|
|
it('resolves repo.save() to Repo.save via receiver typing', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/Repo.java',
|
|
);
|
|
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');
|
|
// Each save() call should resolve to exactly one target file
|
|
expect(saveCalls.filter((c) => c.targetFilePath === 'models/User.java').length).toBe(1);
|
|
expect(saveCalls.filter((c) => c.targetFilePath === 'models/Repo.java').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('Java assignment chain propagation', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-assignment-chain'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes each with a save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).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.java'),
|
|
);
|
|
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.java
|
|
const wrongCall = calls.filter(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath.includes('User.java'),
|
|
);
|
|
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.java'),
|
|
);
|
|
expect(repoSave).toBeDefined();
|
|
});
|
|
|
|
it('each alias resolves to its own class, not the other', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath.includes('User.java'),
|
|
);
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath.includes('Repo.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(repoSave).toBeDefined();
|
|
expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java Optional<User> receiver resolution — extractSimpleTypeName unwraps
|
|
// Optional<User> to "User" via NULLABLE_WRAPPER_TYPES, enabling receiver
|
|
// disambiguation when the declaration type is Optional<T>.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java Optional<User> receiver resolution via wrapper unwrapping', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-optional-receiver'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes each with a save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() to User#save with Optional<User> in scope', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath?.includes('User.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves repo.save() to Repo#save alongside Optional usage', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath?.includes('Repo.java'),
|
|
);
|
|
expect(repoSave).toBeDefined();
|
|
});
|
|
|
|
it('disambiguates user.save() and repo.save() to different files', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath?.includes('User.java'),
|
|
);
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processEntities' &&
|
|
c.targetFilePath?.includes('Repo.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(repoSave).toBeDefined();
|
|
expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chained method call resolution: svc.getUser().save()
|
|
// The receiver of save() is a method_invocation (getUser()), not a simple identifier.
|
|
// Resolution must walk the chain: getUser() returns User, so save() → User#save.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java chained method call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-chain-call'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User, Repo and UserService classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('User');
|
|
expect(classes).toContain('Repo');
|
|
expect(classes).toContain('UserService');
|
|
});
|
|
|
|
it('detects save methods on both User and Repo', () => {
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('detects getUser method on UserService', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('getUser');
|
|
});
|
|
|
|
it('resolves svc.getUser().save() to User#save, NOT Repo#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User.java'),
|
|
);
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Repo.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(repoSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chained call on a new-expression receiver: new Local().inner()
|
|
// The receiver of inner() is an object_creation_expression, not a variable.
|
|
// Regression test for #2564: without treating `new Local()` as a typed
|
|
// receiver, the call falls back to name-only resolution and can pick an
|
|
// unrelated same-named method (Other.inner) instead of Local.inner.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java chained call on a new-expression receiver (#2564)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-new-expr-chain-call'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects LocalChain and Other classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('LocalChain');
|
|
expect(classes).toContain('Other');
|
|
});
|
|
|
|
it('resolves new Local().inner() to the local Local#inner, NOT Other#inner', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const localInner = calls.find(
|
|
(c) =>
|
|
c.target === 'inner' && c.source === 'm' && c.targetFilePath.includes('LocalChain.java'),
|
|
);
|
|
const otherInner = calls.find(
|
|
(c) => c.target === 'inner' && c.source === 'm' && c.targetFilePath.includes('Other.java'),
|
|
);
|
|
expect(localInner).toBeDefined();
|
|
expect(otherInner).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java record: container node + HAS_METHOD edges
|
|
// Regression test for #2564: JAVA_QUERIES previously had no @definition.record
|
|
// capture, so a record never got a Class/Record graph node — its methods
|
|
// existed as ownerless orphans with no HAS_METHOD edge.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java record method resolution (#2564)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-record-methods'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects a Record node for Point', () => {
|
|
const records = getNodesByLabel(result, 'Record');
|
|
expect(records).toContain('Point');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges linking sum and scaled to Point', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const sumEdge = hasMethod.find((e) => e.source === 'Point' && e.target === 'sum');
|
|
const scaledEdge = hasMethod.find((e) => e.source === 'Point' && e.target === 'scaled');
|
|
expect(sumEdge).toBeDefined();
|
|
expect(scaledEdge).toBeDefined();
|
|
});
|
|
|
|
it('resolves scaled() calling sum() via a CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const sumCall = calls.find((c) => c.target === 'sum' && c.source === 'scaled');
|
|
expect(sumCall).toBeDefined();
|
|
});
|
|
|
|
// #2936: the implicit accessor is minted at the COMPONENT's position, so on a
|
|
// single line it shares (name, line) with an explicit overload. The worker's
|
|
// per-class method map keyed on that pair, so the appended implicit entry
|
|
// evicted the source-written method and both definitions collapsed onto
|
|
// `Scaled.x#0` — the arity-1 call then bound to a zero-argument target.
|
|
it('keeps a same-line explicit overload distinct from the implicit accessor (#2936)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-sameline-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'Scaled.java':
|
|
'package probe;\npublic record Scaled(int x, int y) { int x(int factor) { return x * factor; } }\n',
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const arities = getNodesByLabelFull(linked, 'Method')
|
|
.filter((node) => node.name === 'x')
|
|
.map((node) => Number(node.properties.parameterCount))
|
|
.sort((left, right) => left - right);
|
|
|
|
expect(arities).toEqual([0, 1]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it('uses the Record node as a caller source and constructor-call target (#2801)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-link-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'Point.java': `package probe;
|
|
public record Point(int x) {
|
|
private static final int SEED = initialize();
|
|
private static int initialize() { return 1; }
|
|
}`,
|
|
'Use.java':
|
|
'package probe; public class Use { public Point make() { return new Point(1); } }',
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const initializerCall = getRelationships(linked, 'CALLS').find(
|
|
(edge) => edge.source === 'Point' && edge.target === 'initialize',
|
|
);
|
|
expect(initializerCall?.sourceLabel).toBe('Record');
|
|
|
|
const constructorCall = getRelationships(linked, 'CALLS').find(
|
|
(edge) => edge.source === 'make' && edge.target === 'Point',
|
|
);
|
|
expect(constructorCall?.targetLabel).toBe('Record');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it('links and dispatches an explicit Record interface method (#2900)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-heritage-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'RecordHeritage.java': `interface Named { String name(); }
|
|
record User(String value) implements Named {
|
|
public String name() { return value; }
|
|
}
|
|
class Reader {
|
|
String read(Named value) { return value.name(); }
|
|
}`,
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const implementsEdge = getRelationships(linked, 'IMPLEMENTS').find(
|
|
(edge) => edge.source === 'User' && edge.target === 'Named',
|
|
);
|
|
const fanout = getRelationships(linked, 'CALLS').filter(
|
|
(edge) =>
|
|
edge.source === 'read' &&
|
|
edge.target === 'name' &&
|
|
edge.rel.reason === 'interface-dispatch',
|
|
);
|
|
|
|
expect(implementsEdge?.sourceLabel).toBe('Record');
|
|
expect(implementsEdge?.targetLabel).toBe('Interface');
|
|
expect(fanout.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`).sort()).toEqual([
|
|
'Method:RecordHeritage.java',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it('materializes implicit accessors and dispatches them through a Record interface (#2917)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-accessor-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'Named.java': 'interface Named { String name(); }',
|
|
'User.java': 'record User(String name, java.util.List<String> tags) implements Named {}',
|
|
'Explicit.java': `record Explicit(String name) implements Named {
|
|
public String name() { return name.toUpperCase(); }
|
|
}`,
|
|
'Reader.java': `class Reader {
|
|
String read(Named value) { return value.name(); }
|
|
java.util.List<String> directTags(User value) { return value.tags(); }
|
|
}`,
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const implementsEdge = getRelationships(linked, 'IMPLEMENTS').find(
|
|
(edge) => edge.source === 'User' && edge.target === 'Named',
|
|
);
|
|
const fanout = getRelationships(linked, 'CALLS').filter(
|
|
(edge) =>
|
|
edge.source === 'read' &&
|
|
edge.target === 'name' &&
|
|
edge.rel.reason === 'interface-dispatch',
|
|
);
|
|
|
|
const methods = getNodesByLabelFull(linked, 'Method');
|
|
const userName = methods.find(
|
|
(method) => method.name === 'name' && method.properties.filePath.endsWith('User.java'),
|
|
);
|
|
const userTags = methods.find(
|
|
(method) => method.name === 'tags' && method.properties.filePath.endsWith('User.java'),
|
|
);
|
|
const explicitNames = methods.filter(
|
|
(method) => method.name === 'name' && method.properties.filePath.endsWith('Explicit.java'),
|
|
);
|
|
const userHasMethod = getRelationships(linked, 'HAS_METHOD').filter(
|
|
(edge) => edge.source === 'User' && (edge.target === 'name' || edge.target === 'tags'),
|
|
);
|
|
const methodImplements = getRelationships(linked, 'METHOD_IMPLEMENTS').filter(
|
|
(edge) => edge.source === 'name' && edge.target === 'name',
|
|
);
|
|
const directTags = getRelationships(linked, 'CALLS').find(
|
|
(edge) => edge.source === 'directTags' && edge.target === 'tags',
|
|
);
|
|
|
|
expect(implementsEdge?.sourceLabel).toBe('Record');
|
|
expect(implementsEdge?.targetLabel).toBe('Interface');
|
|
expect(userName?.properties).toMatchObject({
|
|
parameterCount: 0,
|
|
returnType: 'String',
|
|
visibility: 'public',
|
|
});
|
|
expect(userTags?.properties).toMatchObject({
|
|
parameterCount: 0,
|
|
returnType: 'java.util.List<String>',
|
|
visibility: 'public',
|
|
});
|
|
expect(explicitNames).toHaveLength(1);
|
|
expect(userHasMethod.map((edge) => edge.target).sort()).toEqual(['name', 'tags']);
|
|
expect(methodImplements.map((edge) => edge.sourceFilePath).sort()).toEqual([
|
|
expect.stringContaining('Explicit.java'),
|
|
expect.stringContaining('User.java'),
|
|
]);
|
|
expect(fanout.map((edge) => edge.targetFilePath).sort()).toEqual([
|
|
expect.stringContaining('Explicit.java'),
|
|
expect.stringContaining('User.java'),
|
|
]);
|
|
expect(directTags?.targetFilePath).toContain('User.java');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
});
|
|
|
|
describe('Java enum interface heritage (#2918)', () => {
|
|
it('links and dispatches an Enum interface method (#2918)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-heritage-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'EnumHeritage.java': `import java.lang.annotation.ElementType;
|
|
import java.lang.annotation.Target;
|
|
@Target(ElementType.TYPE_USE) @interface Marker {}
|
|
interface Named { String label(); }
|
|
enum Status implements @Marker Named {
|
|
ACTIVE;
|
|
public String label() { return "active"; }
|
|
}
|
|
class Reader {
|
|
String read(Named value) { return value.label(); }
|
|
}`,
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter(
|
|
(edge) => edge.source === 'Status' && edge.target === 'Named',
|
|
);
|
|
const fanout = getRelationships(linked, 'CALLS').filter(
|
|
(edge) =>
|
|
edge.source === 'read' &&
|
|
edge.target === 'label' &&
|
|
edge.rel.reason === 'interface-dispatch',
|
|
);
|
|
|
|
expect(implementsEdges).toHaveLength(1);
|
|
expect(implementsEdges[0]?.sourceLabel).toBe('Enum');
|
|
expect(implementsEdges[0]?.targetLabel).toBe('Interface');
|
|
expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([
|
|
'Method:EnumHeritage.java:Status.label#0',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it('keeps enum constant-body methods distinct while preserving enum heritage (#2918)', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-constant-body-'));
|
|
try {
|
|
writeFixtureRepo(root, {
|
|
'EnumConstantBody.java': `interface Named { String label(); }
|
|
enum Status implements Named {
|
|
ACTIVE { public String label() { return "active"; } },
|
|
INACTIVE;
|
|
public String label() { return "inactive"; }
|
|
}
|
|
class Reader {
|
|
String read(Named value) { return value.label(); }
|
|
}`,
|
|
});
|
|
|
|
const linked = await runPipelineFromRepo(root, () => {});
|
|
const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter(
|
|
(edge) => edge.source === 'Status' && edge.target === 'Named',
|
|
);
|
|
|
|
expect(implementsEdges).toHaveLength(1);
|
|
expect(implementsEdges[0]?.sourceLabel).toBe('Enum');
|
|
expect(getNodesByLabel(linked, 'Method').filter((name) => name === 'label')).toHaveLength(3);
|
|
const fanout = getRelationships(linked, 'CALLS').filter(
|
|
(edge) =>
|
|
edge.source === 'read' &&
|
|
edge.target === 'label' &&
|
|
edge.rel.reason === 'interface-dispatch',
|
|
);
|
|
expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([
|
|
'Method:EnumConstantBody.java:Status$1.label#0',
|
|
'Method:EnumConstantBody.java:Status.label#0',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it('keeps non-synthetic implementations ahead of abstract enum constant bodies at the cap', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-fanout-cap-'));
|
|
try {
|
|
const constants = Array.from(
|
|
{ length: 40 },
|
|
(_, index) => `A${index} { public String label() { return "enum-${index}"; } }`,
|
|
).join(',\n');
|
|
const classes = Array.from(
|
|
{ length: 30 },
|
|
(_, index) =>
|
|
`class ZImpl${index} extends ZBase { public String label() { return "class-${index}"; } }`,
|
|
).join('\n');
|
|
writeFixtureRepo(root, {
|
|
'Fanout.java': `interface Named { String label(); }
|
|
enum AaaBig implements Named {
|
|
${constants};
|
|
public abstract String label();
|
|
}
|
|
abstract class ZBase implements Named { public abstract String label(); }
|
|
${classes}
|
|
class Reader { String read(Named value) { return value.label(); } }`,
|
|
});
|
|
|
|
const loggerCapture = _captureLogger();
|
|
let linked: PipelineResult;
|
|
let logRecords: PinoLogRecord[];
|
|
try {
|
|
linked = await runPipelineFromRepo(root, () => {});
|
|
logRecords = loggerCapture.records();
|
|
} finally {
|
|
loggerCapture.restore();
|
|
}
|
|
const fanoutIds = getRelationships(linked, 'CALLS')
|
|
.filter(
|
|
(edge) =>
|
|
edge.source === 'read' &&
|
|
edge.target === 'label' &&
|
|
edge.rel.reason === 'interface-dispatch',
|
|
)
|
|
.map((edge) => edge.rel.targetId);
|
|
|
|
expect(fanoutIds).toHaveLength(32);
|
|
for (let index = 0; index < 30; index++) {
|
|
expect(fanoutIds).toContain(`Method:Fanout.java:ZImpl${index}.label#0`);
|
|
}
|
|
expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$1.label#0');
|
|
expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$2.label#0');
|
|
|
|
const warning = logRecords.find(
|
|
(record) =>
|
|
record.msg ===
|
|
'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)',
|
|
);
|
|
expect(warning).toMatchObject({
|
|
dispatchFanoutSkipped: 38,
|
|
fanoutCap: 32,
|
|
dispatchFanoutSkippedNames: [
|
|
'Named.label (70 targets; dropped: AaaBig$3.label, AaaBig$4.label, AaaBig$5.label, AaaBig$6.label, AaaBig$7.label, +33 more)',
|
|
],
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java 16+ instanceof pattern variable: `if (obj instanceof User user)`
|
|
// Phase 5.2: extractPatternBinding on instanceof_expression binds user → User.
|
|
// Disambiguation: User.save vs Repo.save — only User.save should be called.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java instanceof pattern variable resolution (Phase 5.2)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-instanceof-pattern'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes each with a save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() inside if (obj instanceof User user) to User#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('User.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve user.save() to Repo#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('Repo.java'),
|
|
);
|
|
expect(repoSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enum static method calls: Status.fromCode(200) should resolve via
|
|
// class-as-receiver with Enum type included in the filter.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java enum static method call resolution (Phase 5 review fix)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-enum-static-call'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Status as an Enum and App as a Class', () => {
|
|
expect(getNodesByLabel(result, 'Enum')).toContain('Status');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('App');
|
|
});
|
|
|
|
it('detects fromCode and label methods on Status', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('fromCode');
|
|
expect(methods).toContain('label');
|
|
});
|
|
|
|
it('resolves Status.fromCode(200) to Status#fromCode via class-as-receiver', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fromCodeCall = calls.find(
|
|
(c) =>
|
|
c.target === 'fromCode' &&
|
|
c.source === 'process' &&
|
|
c.targetFilePath?.includes('Status.java'),
|
|
);
|
|
expect(fromCodeCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves s.label() to Status#label', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const labelCall = calls.find(
|
|
(c) =>
|
|
c.target === 'label' && c.source === 'process' && c.targetFilePath?.includes('Status.java'),
|
|
);
|
|
expect(labelCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java 21+ switch pattern matching: switch (obj) { case User user -> user.save(); }
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java switch pattern binding', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-switch-pattern'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, both with save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save() in switch case User to models/User.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processAny' && c.targetFilePath === 'models/User.java',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves repo.save() in switch case Repo to models/Repo.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processAny' && c.targetFilePath === 'models/Repo.java',
|
|
);
|
|
expect(repoSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.save() in handleUser switch case User to models/User.java', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'handleUser' && c.targetFilePath === 'models/User.java',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT cross-resolve handleUser switch case User to Repo.save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const wrongSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'handleUser' && c.targetFilePath === 'models/Repo.java',
|
|
);
|
|
expect(wrongSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java Map .values() for-loop — method-aware type arg resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java Map .values() for-loop resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-map-keys-values'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User class with save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
});
|
|
|
|
it('resolves user.save() via Map.values() to User#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processValues' && c.targetFilePath?.includes('User'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const wrongSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processValues' && c.targetFilePath?.includes('Repo'),
|
|
);
|
|
expect(wrongSave).toBeUndefined();
|
|
});
|
|
|
|
it('resolves user.save() via List iteration to User#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processList' && c.targetFilePath?.includes('User'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java enhanced for-loop with call_expression iterable: for (User user : getUsers())
|
|
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java foreach call_expression iterable resolution (Phase 7.3)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-foreach-call-expr'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes with competing save methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
});
|
|
|
|
it('resolves user.save() in foreach over User.getUsers() to User#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processUsers' &&
|
|
c.targetFilePath?.includes('User.java'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves repo.save() in foreach over Repo.getRepos() to Repo#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processRepos' &&
|
|
c.targetFilePath?.includes('Repo.java'),
|
|
);
|
|
expect(repoSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const wrongSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processUsers' &&
|
|
c.targetFilePath?.includes('Repo.java'),
|
|
);
|
|
expect(wrongSave).toBeUndefined();
|
|
});
|
|
|
|
it('does NOT resolve repo.save() to User#save (negative)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const wrongSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'processRepos' &&
|
|
c.targetFilePath?.includes('User.java'),
|
|
);
|
|
expect(wrongSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 8: Field/property type resolution (1-level)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Field type resolution (Java)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-field-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes: Address, App, User', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'User']);
|
|
});
|
|
|
|
it('detects Property nodes for Java fields', () => {
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('address');
|
|
expect(properties).toContain('name');
|
|
expect(properties).toContain('city');
|
|
});
|
|
|
|
it('emits HAS_PROPERTY edges linking properties to classes', () => {
|
|
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
|
expect(propEdges.length).toBe(3);
|
|
expect(edgeSet(propEdges)).toContain('User → address');
|
|
expect(edgeSet(propEdges)).toContain('User → name');
|
|
expect(edgeSet(propEdges)).toContain('Address → city');
|
|
});
|
|
|
|
it('resolves user.address.save() → Address#save via field type', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save');
|
|
const addressSave = saveCalls.find(
|
|
(e) => e.source === 'processUser' && e.targetFilePath.includes('Address'),
|
|
);
|
|
expect(addressSave).toBeDefined();
|
|
});
|
|
|
|
it('emits ACCESSES read edge for user.address field access in chain', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const addressReads = accesses.filter((e) => e.target === 'address' && e.rel.reason === 'read');
|
|
expect(addressReads.length).toBe(1);
|
|
expect(addressReads[0].source).toBe('processUser');
|
|
expect(addressReads[0].targetLabel).toBe('Property');
|
|
});
|
|
|
|
it('populates field metadata (visibility, isStatic, declaredType) on Property nodes', () => {
|
|
const properties = getNodesByLabelFull(result, 'Property');
|
|
|
|
const city = properties.find((p) => p.name === 'city');
|
|
expect(city).toBeDefined();
|
|
expect(city!.properties.visibility).toBe('public');
|
|
expect(city!.properties.isStatic).toBe(false);
|
|
expect(city!.properties.isReadonly).toBe(false);
|
|
expect(city!.properties.declaredType).toBe('String');
|
|
|
|
const addr = properties.find((p) => p.name === 'address');
|
|
expect(addr).toBeDefined();
|
|
expect(addr!.properties.visibility).toBe('public');
|
|
expect(addr!.properties.declaredType).toBe('Address');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 8A: Deep field chain resolution (3-level)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Deep field chain resolution (Java)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-deep-field-chain'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes: Address, App, City, User', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User']);
|
|
});
|
|
|
|
it('detects Property nodes for Java fields', () => {
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('address');
|
|
expect(properties).toContain('city');
|
|
expect(properties).toContain('zipCode');
|
|
});
|
|
|
|
it('emits HAS_PROPERTY edges for nested type chain', () => {
|
|
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
|
expect(edgeSet(propEdges)).toContain('User → address');
|
|
expect(edgeSet(propEdges)).toContain('Address → city');
|
|
expect(edgeSet(propEdges)).toContain('City → zipCode');
|
|
});
|
|
|
|
it('resolves 2-level chain: user.address.save() → Address#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'processUser');
|
|
const addressSave = saveCalls.find((e) => e.targetFilePath.includes('Address'));
|
|
expect(addressSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves 3-level chain: user.address.city.getName() → City#getName', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getNameCalls = calls.filter((e) => e.target === 'getName' && e.source === 'processUser');
|
|
const cityGetName = getNameCalls.find((e) => e.targetFilePath.includes('City'));
|
|
expect(cityGetName).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mixed field+call chain resolution (Java)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Mixed field+call chain resolution (Java)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-mixed-chain'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes: Address, App, City, User, UserService', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual([
|
|
'Address',
|
|
'App',
|
|
'City',
|
|
'User',
|
|
'UserService',
|
|
]);
|
|
});
|
|
|
|
it('detects Property nodes for mixed-chain fields', () => {
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('city');
|
|
expect(properties).toContain('address');
|
|
});
|
|
|
|
it('resolves call→field chain: svc.getUser().address.save() → Address#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'processWithService');
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0].targetFilePath).toContain('Address');
|
|
});
|
|
|
|
it('resolves field→call chain: user.getAddress().city.getName() → City#getName', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getNameCalls = calls.filter(
|
|
(e) => e.target === 'getName' && e.source === 'processWithUser',
|
|
);
|
|
expect(getNameCalls.length).toBe(1);
|
|
expect(getNameCalls[0].targetFilePath).toContain('City');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ACCESSES write edges from assignment expressions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Write access tracking (Java)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-write-access'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits ACCESSES write edges for field assignments', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const writes = accesses.filter((e) => e.rel.reason === 'write');
|
|
expect(writes.length).toBe(2);
|
|
const nameWrite = writes.find((e) => e.target === 'name');
|
|
const addressWrite = writes.find((e) => e.target === 'address');
|
|
expect(nameWrite).toBeDefined();
|
|
expect(nameWrite!.source).toBe('updateUser');
|
|
expect(addressWrite).toBeDefined();
|
|
expect(addressWrite!.source).toBe('updateUser');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Call-result variable binding (Phase 9): var user = getUser(); user.save()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java call-result variable binding (Tier 2b)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-call-result-binding'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves user.save() to User#save via call-result binding', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java method chain binding via unified fixpoint (Phase 9C)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-method-chain-binding'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves city.save() to City#save via method chain', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('Models'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
|
|
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java grandparent method resolution via MRO (Phase B)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-grandparent-resolution'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects A, B, C, Greeting classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('A');
|
|
expect(classes).toContain('B');
|
|
expect(classes).toContain('C');
|
|
expect(classes).toContain('Greeting');
|
|
});
|
|
|
|
it('emits EXTENDS edges: B→A, C→B', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toContain('B → A');
|
|
expect(edgeSet(extends_)).toContain('C → B');
|
|
});
|
|
|
|
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath.includes('Greeting'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetCall = calls.find(
|
|
(c) => c.target === 'greet' && c.targetFilePath.includes('A.java'),
|
|
);
|
|
expect(greetCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Same-package `extends` whose base name another package also declares.
|
|
// com.acme.core's `B extends A` / `C extends B` carry no import (the Java
|
|
// same-package rule), while com.acme.legacy declares its own A and B. The base
|
|
// must bind through the package, not through a workspace-unique simple name
|
|
// that the legacy pair makes ambiguous. Two guards: `Shadowed` single-type
|
|
// imports legacy.A, which beats the same-package A; `Qualified` names
|
|
// `com.acme.legacy.B`, which the same-package B must never answer.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java same-package extends with a colliding simple name', () => {
|
|
let result: PipelineResult;
|
|
const core = 'src/main/java/com/acme/core';
|
|
const legacy = 'src/main/java/com/acme/legacy';
|
|
const extendsEdges = (): string[] =>
|
|
getRelationships(result, 'EXTENDS').map(
|
|
(e) => `${e.sourceFilePath}:${e.source} → ${e.targetFilePath}:${e.target}`,
|
|
);
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-same-package-extends-collision'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('binds unqualified bases by the same-package rule, a single-type import first', () => {
|
|
const unqualified = extendsEdges().filter((e) => !e.startsWith(`${core}/Qualified.java:`));
|
|
expect(unqualified.sort()).toEqual([
|
|
`${core}/B.java:B → ${core}/A.java:A`,
|
|
`${core}/C.java:C → ${core}/B.java:B`,
|
|
`${core}/Shadowed.java:Shadowed → ${legacy}/A.java:A`,
|
|
`${legacy}/B.java:B → ${legacy}/A.java:A`,
|
|
]);
|
|
});
|
|
|
|
it('never binds a qualified base through the same-package simple name', () => {
|
|
expect(extendsEdges()).not.toContain(`${core}/Qualified.java:Qualified → ${core}/B.java:B`);
|
|
});
|
|
|
|
it.each([
|
|
['greet', `${core}/A.java`],
|
|
['save', `${core}/Stamp.java`],
|
|
])('resolves App.process → %s through the C → B → A chain (%s)', (target, targetFile) => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(c) => c.source === 'process' && c.target === target,
|
|
);
|
|
expect(calls.map((c) => c.targetFilePath)).toEqual([targetFile]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One package+type declared under several source roots. alpha and beta each
|
|
// declare `models.User`; beta's UserService imports it from its own module,
|
|
// gamma's Report sits equally far from both. The import must bind the
|
|
// importer's own copy, and refuse when no copy is nearer, instead of whichever
|
|
// file was parsed first. The import-free twin (same-package siblings) is
|
|
// 'Java same-module priority for duplicate FQNs' above.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java import of a type declared under several source roots', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-duplicate-fqn-source-roots'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it.each<[string, string, readonly string[]]>([
|
|
[
|
|
'beta/src/main/java/services/UserService.java',
|
|
'process',
|
|
['beta/src/main/java/models/User.java'],
|
|
],
|
|
['gamma/src/main/java/reports/Report.java', 'run', []],
|
|
])('%s (%s) imports and calls only %j', (importer, method, userFiles) => {
|
|
const imports = getRelationships(result, 'IMPORTS').filter(
|
|
(e) => e.sourceFilePath === importer && e.targetFilePath.endsWith('/models/User.java'),
|
|
);
|
|
const saves = getRelationships(result, 'CALLS').filter(
|
|
(c) => c.source === method && c.target === 'save',
|
|
);
|
|
expect(imports.map((e) => e.targetFilePath)).toEqual(userFiles);
|
|
expect(saves.map((c) => c.targetFilePath)).toEqual(userFiles);
|
|
});
|
|
});
|
|
|
|
// ── Phase P: Overload Disambiguation via Parameter Types ─────────────────
|
|
|
|
describe('Java overload disambiguation by parameter types', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-overload-param-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('produces distinct graph nodes for same-arity overloads via type-hash suffix', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const lookupNodes = methods.filter((m) => m.name === 'lookup');
|
|
// Type-hash disambiguation → 2 distinct graph nodes (lookup#1~int, lookup#1~String)
|
|
expect(lookupNodes.length).toBe(2);
|
|
const types = lookupNodes.map((n) => n.properties.parameterTypes).sort();
|
|
expect(types).toEqual([['String'], ['int']]);
|
|
});
|
|
|
|
it('callById() emits exactly one CALLS edge to lookup(int)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fromCallById = calls.filter((c) => c.source === 'callById' && c.target === 'lookup');
|
|
expect(fromCallById.length).toBe(1);
|
|
const targetNode = result.graph.getNode(fromCallById[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['int']);
|
|
});
|
|
|
|
it('callByName() emits exactly one CALLS edge to lookup(String)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fromCallByName = calls.filter((c) => c.source === 'callByName' && c.target === 'lookup');
|
|
expect(fromCallByName.length).toBe(1);
|
|
const targetNode = result.graph.getNode(fromCallByName[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['String']);
|
|
});
|
|
});
|
|
|
|
// ── Phase P: Same-arity overloads — cross-file + chain resolution ─────────
|
|
|
|
describe('Java same-arity overload cross-file and chain resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-same-arity-cross-file'), () => {});
|
|
}, 60000);
|
|
|
|
// -- Cross-file: caller in App.java → overloaded method in DbLookup.java --
|
|
|
|
it('crossFileById() emits exactly one CALLS edge to find(int) in DbLookup', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edges = calls.filter(
|
|
(c) =>
|
|
c.source === 'crossFileById' &&
|
|
c.target === 'find' &&
|
|
c.targetFilePath.includes('DbLookup'),
|
|
);
|
|
expect(edges.length).toBe(1);
|
|
const targetNode = result.graph.getNode(edges[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['int']);
|
|
});
|
|
|
|
it('crossFileByName() emits exactly one CALLS edge to find(String) in DbLookup', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edges = calls.filter(
|
|
(c) =>
|
|
c.source === 'crossFileByName' &&
|
|
c.target === 'find' &&
|
|
c.targetFilePath.includes('DbLookup'),
|
|
);
|
|
expect(edges.length).toBe(1);
|
|
const targetNode = result.graph.getNode(edges[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['String']);
|
|
});
|
|
|
|
// -- METHOD_IMPLEMENTS: DbLookup.find(int) → ILookup.find(int) etc. --
|
|
|
|
it('emits METHOD_IMPLEMENTS from DbLookup.find(int) → ILookup.find(int)', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edges = mi.filter(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('DbLookup') &&
|
|
e.targetFilePath.includes('ILookup'),
|
|
);
|
|
// Two distinct edges: find(int)→find(int) and find(String)→find(String)
|
|
expect(edges.length).toBe(2);
|
|
for (const edge of edges) {
|
|
const sourceNode = result.graph.getNode(edge.rel.sourceId);
|
|
const targetNode = result.graph.getNode(edge.rel.targetId);
|
|
expect(sourceNode?.properties.parameterTypes).toEqual(targetNode?.properties.parameterTypes);
|
|
}
|
|
});
|
|
|
|
// -- Chain: db.find(42) → result → fmt.format(result) --
|
|
|
|
it('chainIntToFormat() calls find and format — each resolves to exactly one overload', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const findEdges = calls.filter((c) => c.source === 'chainIntToFormat' && c.target === 'find');
|
|
const formatEdges = calls.filter(
|
|
(c) => c.source === 'chainIntToFormat' && c.target === 'format',
|
|
);
|
|
// find(42) → find(int)
|
|
expect(findEdges.length).toBe(1);
|
|
const findTarget = result.graph.getNode(findEdges[0].rel.targetId);
|
|
expect(findTarget?.properties.parameterTypes).toEqual(['int']);
|
|
// format(result) where result is String → format(String)
|
|
expect(formatEdges.length).toBe(1);
|
|
const formatTarget = result.graph.getNode(formatEdges[0].rel.targetId);
|
|
expect(formatTarget?.properties.parameterTypes).toEqual(['String']);
|
|
});
|
|
|
|
it('chainNameToFormat() calls find and format — each resolves to exactly one overload', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const findEdges = calls.filter((c) => c.source === 'chainNameToFormat' && c.target === 'find');
|
|
const formatEdges = calls.filter(
|
|
(c) => c.source === 'chainNameToFormat' && c.target === 'format',
|
|
);
|
|
// find("alice") → find(String)
|
|
expect(findEdges.length).toBe(1);
|
|
const findTarget = result.graph.getNode(findEdges[0].rel.targetId);
|
|
expect(findTarget?.properties.parameterTypes).toEqual(['String']);
|
|
// format(result) where result is String → format(String)
|
|
expect(formatEdges.length).toBe(1);
|
|
const formatTarget = result.graph.getNode(formatEdges[0].rel.targetId);
|
|
expect(formatTarget?.properties.parameterTypes).toEqual(['String']);
|
|
});
|
|
});
|
|
|
|
// ── Phase P: Virtual Dispatch via Constructor Type ───────────────────────
|
|
|
|
describe('Java virtual dispatch via constructor type (same-file)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-virtual-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Animal, Dog, and App classes in same file', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Animal');
|
|
expect(classes).toContain('Dog');
|
|
expect(classes).toContain('App');
|
|
});
|
|
|
|
it('detects Dog extends Animal heritage', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const dogExtends = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
|
|
expect(dogExtends).toBeDefined();
|
|
});
|
|
|
|
it('detects fetchBall() as Dog-only method', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('fetchBall');
|
|
});
|
|
|
|
it('resolves fetchBall() calls from run() — proves virtual dispatch override', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fetchCalls = calls.filter((c) => c.source === 'run' && c.target === 'fetchBall');
|
|
// animal.fetchBall() only resolves if constructorTypeMap overrides
|
|
// receiver from Animal → Dog (since only Dog has fetchBall).
|
|
// dog.fetchBall() resolves directly via Dog type.
|
|
// Both target same nodeId → 1 CALLS edge after dedup.
|
|
expect(fetchCalls.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 14: Cross-file binding propagation
|
|
// models/UserFactory.java exports static getUser() returning User
|
|
// app/App.java static-imports getUser, calls var user = getUser(); user.save()
|
|
// → user is typed User via cross-file return type propagation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java cross-file binding propagation', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'java-cross-file'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User class with save and getName methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('getName');
|
|
});
|
|
|
|
it('detects UserFactory class with getUser method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('getUser');
|
|
});
|
|
|
|
it('detects App class with run method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('App');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('run');
|
|
});
|
|
|
|
it('emits IMPORTS edge from App.java to UserFactory.java', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const edge = imports.find(
|
|
(e) => e.sourceFilePath.includes('App') && e.targetFilePath.includes('UserFactory'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.save() in run() to User#save via cross-file return type propagation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User.java'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.getName() in run() to User#getName via cross-file return type propagation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getNameCall = calls.find(
|
|
(c) => c.target === 'getName' && c.source === 'run' && c.targetFilePath.includes('User.java'),
|
|
);
|
|
expect(getNameCall).toBeDefined();
|
|
});
|
|
|
|
it('emits HAS_METHOD edges linking save and getName to User', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
|
|
const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'getName');
|
|
expect(saveEdge).toBeDefined();
|
|
expect(getNameEdge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method enrichment: abstract, static, annotations, parameterTypes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java method enrichment', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-method-enrichment'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Animal and Dog classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Animal');
|
|
expect(classes).toContain('Dog');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges for Animal methods', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const animalMethods = hasMethod.filter((e) => e.source === 'Animal').map((e) => e.target);
|
|
expect(animalMethods).toContain('speak');
|
|
expect(animalMethods).toContain('classify');
|
|
expect(animalMethods).toContain('breathe');
|
|
});
|
|
|
|
it('emits HAS_METHOD edge for Dog.speak', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const dogSpeak = hasMethod.find((e) => e.source === 'Dog' && e.target === 'speak');
|
|
expect(dogSpeak).toBeDefined();
|
|
});
|
|
|
|
it('emits EXTENDS edge Dog -> Animal', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const dogExtends = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
|
|
expect(dogExtends).toBeDefined();
|
|
});
|
|
|
|
it('marks abstract speak as isAbstract (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const speak = methods.find(
|
|
(n) => n.name === 'speak' && n.properties.filePath?.includes('Animal.java'),
|
|
);
|
|
if (speak?.properties.isAbstract !== undefined) {
|
|
expect(speak.properties.isAbstract).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('marks breathe as NOT isAbstract (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const breathe = methods.find((n) => n.name === 'breathe');
|
|
if (breathe?.properties.isAbstract !== undefined) {
|
|
expect(breathe.properties.isAbstract).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('marks classify as isStatic (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const classify = methods.find((n) => n.name === 'classify');
|
|
if (classify?.properties.isStatic !== undefined) {
|
|
expect(classify.properties.isStatic).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('marks breathe as NOT isStatic (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const breathe = methods.find((n) => n.name === 'breathe');
|
|
if (breathe?.properties.isStatic !== undefined) {
|
|
expect(breathe.properties.isStatic).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('captures @Override annotation on Dog.speak (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const dogSpeak = methods.find(
|
|
(n) => n.name === 'speak' && n.properties.annotations?.includes('@Override'),
|
|
);
|
|
if (dogSpeak) {
|
|
expect(dogSpeak.properties.annotations).toContain('@Override');
|
|
}
|
|
});
|
|
|
|
it('populates parameterTypes for classify (conditional)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Function');
|
|
const classify = methods.find((n) => n.name === 'classify');
|
|
if (classify?.properties.parameterTypes !== undefined) {
|
|
expect(classify.properties.parameterTypes).toContain('String');
|
|
}
|
|
});
|
|
|
|
it('resolves dog.speak() CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const speakCall = calls.find(
|
|
(c) => c.target === 'speak' && c.sourceFilePath.includes('App.java'),
|
|
);
|
|
expect(speakCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves Animal.classify() static CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const classifyCall = calls.find(
|
|
(c) => c.target === 'classify' && c.sourceFilePath.includes('App.java'),
|
|
);
|
|
expect(classifyCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java interface dispatch (METHOD_IMPLEMENTS)
|
|
// Action interface: execute(), priority()
|
|
// LogEvent implements Action, SendEmail implements Action
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java interface dispatch (METHOD_IMPLEMENTS)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-interface-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits METHOD_IMPLEMENTS edges from LogEvent.execute → Action.execute', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edge = mi.find(
|
|
(e) =>
|
|
e.source === 'execute' &&
|
|
e.target === 'execute' &&
|
|
e.sourceFilePath.includes('LogEvent') &&
|
|
e.targetFilePath.includes('Action'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS edges from SendEmail.execute → Action.execute', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edge = mi.find(
|
|
(e) =>
|
|
e.source === 'execute' &&
|
|
e.target === 'execute' &&
|
|
e.sourceFilePath.includes('SendEmail') &&
|
|
e.targetFilePath.includes('Action'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS for priority() in both implementors', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const priorityEdges = mi.filter(
|
|
(e) =>
|
|
e.source === 'priority' && e.target === 'priority' && e.targetFilePath.includes('Action'),
|
|
);
|
|
expect(priorityEdges.length).toBe(2);
|
|
const sourceFiles = priorityEdges.map((e) => e.sourceFilePath).sort();
|
|
expect(sourceFiles.some((f) => f.includes('LogEvent'))).toBe(true);
|
|
expect(sourceFiles.some((f) => f.includes('SendEmail'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java overloaded method disambiguation (METHOD_IMPLEMENTS with arity)
|
|
// Repository interface: find(int), find(String, boolean), save(String)
|
|
// SqlRepository implements Repository with matching overloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java overloaded method disambiguation (METHOD_IMPLEMENTS)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-overload-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects distinct Method nodes for overloaded find methods on SqlRepository', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const findMethods = methods.filter(
|
|
(m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'),
|
|
);
|
|
expect(findMethods.length).toBe(2);
|
|
const paramCounts = findMethods.map((m) => m.properties.parameterCount).sort();
|
|
expect(paramCounts).toEqual([1, 2]);
|
|
});
|
|
|
|
it('detects distinct Method nodes for overloaded find methods on Repository interface', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const findMethods = methods.filter(
|
|
(m) =>
|
|
m.name === 'find' &&
|
|
m.properties.filePath?.includes('Repository') &&
|
|
!m.properties.filePath?.includes('SqlRepository'),
|
|
);
|
|
expect(findMethods.length).toBe(2);
|
|
const paramCounts = findMethods.map((m) => m.properties.parameterCount).sort();
|
|
expect(paramCounts).toEqual([1, 2]);
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS for find(int) → Repository.find(int)', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edge = mi.find(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('SqlRepository') &&
|
|
e.targetFilePath.includes('Repository'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
// Verify at least one find→find edge has arity 1 on source side
|
|
const findEdges = mi.filter(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('SqlRepository') &&
|
|
e.targetFilePath.includes('Repository'),
|
|
);
|
|
const sourceNodes = findEdges.map((e) => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
return methods.find(
|
|
(m) =>
|
|
m.name === 'find' &&
|
|
m.properties.filePath?.includes('SqlRepository') &&
|
|
m.properties.parameterCount === 1,
|
|
);
|
|
});
|
|
expect(sourceNodes.some((n) => n !== undefined)).toBe(true);
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS for find(String, boolean) → Repository.find(String, boolean)', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const findEdges = mi.filter(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('SqlRepository') &&
|
|
e.targetFilePath.includes('Repository'),
|
|
);
|
|
// There should be two find→find edges (one per overload)
|
|
expect(findEdges.length).toBe(2);
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS for save(String) → Repository.save(String)', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edge = mi.find(
|
|
(e) =>
|
|
e.source === 'save' &&
|
|
e.target === 'save' &&
|
|
e.sourceFilePath.includes('SqlRepository') &&
|
|
e.targetFilePath.includes('Repository'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('emits exactly 3 METHOD_IMPLEMENTS edges', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edges = mi.filter(
|
|
(e) => e.sourceFilePath.includes('SqlRepository') && e.targetFilePath.includes('Repository'),
|
|
);
|
|
expect(edges.length).toBe(3);
|
|
});
|
|
|
|
it('emits CALLS edges from run() to both find overloads', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const findCalls = calls.filter(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'find' &&
|
|
c.sourceFilePath.includes('App') &&
|
|
c.targetFilePath.includes('SqlRepository'),
|
|
);
|
|
expect(findCalls.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ── Phase P: Sequential path parity — same-arity overloads ────────────────
|
|
|
|
describe('Java same-arity overloads (worker path)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-same-arity-cross-file'), () => {});
|
|
}, 60000);
|
|
|
|
it('produces distinct graph nodes for find(int) and find(String)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const findNodes = methods.filter(
|
|
(m) => m.name === 'find' && m.properties.filePath?.includes('DbLookup'),
|
|
);
|
|
expect(findNodes.length).toBe(2);
|
|
const types = findNodes.map((n) => n.properties.parameterTypes).sort();
|
|
expect(types).toEqual([['String'], ['int']]);
|
|
});
|
|
|
|
it('crossFileById() → find(int) — sequential path', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edges = calls.filter(
|
|
(c) =>
|
|
c.source === 'crossFileById' &&
|
|
c.target === 'find' &&
|
|
c.targetFilePath.includes('DbLookup'),
|
|
);
|
|
expect(edges.length).toBe(1);
|
|
const targetNode = result.graph.getNode(edges[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['int']);
|
|
});
|
|
|
|
it('crossFileByName() → find(String) — sequential path', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edges = calls.filter(
|
|
(c) =>
|
|
c.source === 'crossFileByName' &&
|
|
c.target === 'find' &&
|
|
c.targetFilePath.includes('DbLookup'),
|
|
);
|
|
expect(edges.length).toBe(1);
|
|
const targetNode = result.graph.getNode(edges[0].rel.targetId);
|
|
expect(targetNode?.properties.parameterTypes).toEqual(['String']);
|
|
});
|
|
|
|
it('METHOD_IMPLEMENTS edges match interface methods — sequential path', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const edges = mi.filter(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('DbLookup') &&
|
|
e.targetFilePath.includes('ILookup'),
|
|
);
|
|
expect(edges.length).toBe(2);
|
|
for (const edge of edges) {
|
|
const sourceNode = result.graph.getNode(edge.rel.sourceId);
|
|
const targetNode = result.graph.getNode(edge.rel.targetId);
|
|
expect(sourceNode?.properties.parameterTypes).toEqual(targetNode?.properties.parameterTypes);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-class pure method chain resolution via lookupMethodByOwner (#575)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Cross-class method chain resolution (Java) — #575', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-method-chain-cross-class'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects classes: Address, App, City, User', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User']);
|
|
});
|
|
|
|
it('two-step chain: user.getAddress().save() → Address#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'twoStepChain');
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0].targetFilePath).toContain('Address');
|
|
});
|
|
|
|
it('two-step chain: user.getAddress().save() also emits CALLS to User#getAddress', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getAddressCalls = calls.filter(
|
|
(e) => e.target === 'getAddress' && e.source === 'twoStepChain',
|
|
);
|
|
expect(getAddressCalls.length).toBe(1);
|
|
expect(getAddressCalls[0].targetFilePath).toContain('User');
|
|
});
|
|
|
|
it('three-step chain: user.getAddress().getCity().getZipCode() → City#getZipCode', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const zipCalls = calls.filter(
|
|
(e) => e.target === 'getZipCode' && e.source === 'threeStepChain',
|
|
);
|
|
expect(zipCalls.length).toBe(1);
|
|
expect(zipCalls[0].targetFilePath).toContain('City');
|
|
});
|
|
|
|
it('three-step chain emits CALLS for all intermediate steps', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const threeStepCalls = calls.filter((e) => e.source === 'threeStepChain');
|
|
const targets = threeStepCalls.map((e) => e.target).sort();
|
|
expect(targets).toContain('getAddress');
|
|
expect(targets).toContain('getCity');
|
|
expect(targets).toContain('getZipCode');
|
|
});
|
|
|
|
it('mixed chain: user.getAddress().city.getZipCode() → City#getZipCode', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const zipCalls = calls.filter((e) => e.target === 'getZipCode' && e.source === 'mixedChain');
|
|
expect(zipCalls.length).toBe(1);
|
|
expect(zipCalls[0].targetFilePath).toContain('City');
|
|
});
|
|
|
|
it('mixed chain emits ACCESSES edge for field step: .city on Address', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const cityAccess = accesses.filter((e) => e.target === 'city' && e.source === 'mixedChain');
|
|
expect(cityAccess.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SM-9: inherited method resolution — class Child extends Parent
|
|
// child.parentMethod() resolves to Parent#parentMethod via the parent walk.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java Child extends Parent — inherited method resolution (SM-9)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-child-extends-parent'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Parent and Child classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Parent');
|
|
expect(classes).toContain('Child');
|
|
});
|
|
|
|
it('emits EXTENDS edge: Child → Parent', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toContain('Child → Parent');
|
|
});
|
|
|
|
it('resolves c.parentMethod() to Parent#parentMethod via MRO walk', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const parentMethodCall = calls.find(
|
|
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent'),
|
|
);
|
|
expect(parentMethodCall).toBeDefined();
|
|
// Pin the caller too — not just the target — so a regression that
|
|
// misattributes the edge to a different source would fail loudly.
|
|
expect(parentMethodCall!.source).toBe('run');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SM-11: Java User implements Validator — interface default method (Java 8+)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java User implements Validator — interface default method (SM-11)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-interface-default-method'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects Validator interface and User class', () => {
|
|
expect(getNodesByLabel(result, 'Interface')).toContain('Validator');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge: User → Validator', () => {
|
|
const impls = getRelationships(result, 'IMPLEMENTS');
|
|
expect(edgeSet(impls)).toContain('User → Validator');
|
|
});
|
|
|
|
it('resolves user.validate() to Validator.validate via implements-split MRO', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const validateCall = calls.find(
|
|
(c) => c.target === 'validate' && c.targetFilePath.includes('Validator.java'),
|
|
);
|
|
expect(validateCall).toBeDefined();
|
|
expect(validateCall!.source).toBe('run');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cast-wrapped receivers: ((Type) expr).method() resolves via the CAST type
|
|
// (#2353). Every scenario pairs the cast target with a decoy class owning a
|
|
// same-named method on the receiver's declared type, so a regression that
|
|
// ignores the cast produces a detectably wrong edge instead of a silent pass.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java cast receiver resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-cast-receiver'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects the caller plus target and decoy classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual([
|
|
'App',
|
|
'Box',
|
|
'Fallback',
|
|
'Shape',
|
|
'Target',
|
|
'Wrapper',
|
|
]);
|
|
});
|
|
|
|
it('resolves simple cast ((Box) obj).open() to Box.open, not declared-type Wrapper.open', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const openCall = calls.find((c) => c.target === 'open' && c.source === 'castSimple');
|
|
expect(openCall).toBeDefined();
|
|
expect(openCall!.targetFilePath).toBe('models/Box.java');
|
|
});
|
|
|
|
it('does not emit an open() edge to the decoy Wrapper', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(
|
|
calls.some((c) => c.target === 'open' && c.targetFilePath === 'models/Wrapper.java'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('resolves nested CFR cast ((Target)((Object)expr)).render() to Target.render', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const renderCall = calls.find((c) => c.target === 'render' && c.source === 'castNested');
|
|
expect(renderCall).toBeDefined();
|
|
expect(renderCall!.targetFilePath).toBe('models/Target.java');
|
|
});
|
|
|
|
it('does not emit a render() edge to the inner cast or to the decoy Shape (expr declared type)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(
|
|
calls.some((c) => c.target === 'render' && c.targetFilePath === 'models/Shape.java'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('resolves cast + this.field ((Target)((Object)this.held)).draw() to Target.draw', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const drawCall = calls.find((c) => c.target === 'draw' && c.source === 'castThisField');
|
|
expect(drawCall).toBeDefined();
|
|
expect(drawCall!.targetFilePath).toBe('models/Target.java');
|
|
});
|
|
|
|
it('does not emit a draw() edge to the decoy Shape (field declared type)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(calls.some((c) => c.target === 'draw' && c.targetFilePath === 'models/Shape.java')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
// ((String) obj).act(): `String` is a resolvable-SHAPE cast type (simple
|
|
// identifier) that is not locally indexed, so resolution deliberately falls
|
|
// back to obj's OWN declared type (Fallback). This is intentionally kept,
|
|
// unlike the unparseable-cast case (#2353 review F1), whose criterion is:
|
|
// a paren group that is type-shaped but UNPARSEABLE (generic / array / FQN)
|
|
// must resolve to nothing, because falling through to the pre-cast
|
|
// expression's declared type emits a confident wrong edge. Here the cast IS
|
|
// parseable — it just names a type we didn't index — so no better
|
|
// information exists, and upcast casts make the declared type a plausible
|
|
// dispatch target. Residual risk kept visible: a cross-cast to an unindexed
|
|
// sibling type would still emit this declared-type fallback edge.
|
|
it('falls back to the declared type for a cast to an unindexed simple type (String)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const actCall = calls.find((c) => c.target === 'act' && c.source === 'castUnindexedType');
|
|
expect(actCall).toBeDefined();
|
|
expect(actCall!.targetFilePath).toBe('models/Fallback.java');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unparseable casts (#2353 review F1): a receiver whose paren group is
|
|
// TYPE-SHAPED but unparseable — generic (Box<String>), array (Box[]),
|
|
// fully-qualified (models.Box) — is a cast the resolver cannot look up.
|
|
// It must resolve to NOTHING (pre-#2353 behavior): stripping the parens and
|
|
// falling through resolves the pre-cast expression's own declared type and
|
|
// emits a confident wrong CALLS edge (reason "import-resolved") to the decoy.
|
|
// Every assertion is source-scoped (c.source === caller method) so it cannot
|
|
// collide with the positive-shape scenarios pinned above.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java unparseable cast receiver resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-cast-receiver'), () => {});
|
|
}, 60000);
|
|
|
|
const callTargets = (source: string, target: string): string[] =>
|
|
getRelationships(result, 'CALLS')
|
|
.filter((c) => c.source === source && c.target === target)
|
|
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
|
|
|
|
it('emits no open() edge for a generic cast ((Box<String>) obj) — declared-type decoy Wrapper', () => {
|
|
expect(callTargets('castGeneric', 'open')).toEqual([]);
|
|
});
|
|
|
|
it('emits no act2() edge for an array cast ((Box[]) obj) — declared-type decoy Wrapper', () => {
|
|
expect(callTargets('castArray', 'act2')).toEqual([]);
|
|
});
|
|
|
|
it('emits no act3() edge for a fully-qualified cast ((models.Box) obj) — declared-type decoy Wrapper', () => {
|
|
expect(callTargets('castQualified', 'act3')).toEqual([]);
|
|
});
|
|
|
|
it('emits no act4() edge for a generic-FQN cast over this.field — field declared-type decoy Shape', () => {
|
|
expect(callTargets('castGenericFqnThisField', 'act4')).toEqual([]);
|
|
});
|
|
|
|
it('leaves a non-cast parenthesized receiver untouched — no crash, no fabricated edge', () => {
|
|
const fromNonCast = getRelationships(result, 'CALLS')
|
|
.filter((c) => c.source === 'nonCastParen')
|
|
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
|
|
expect(fromNonCast).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// this.field chains (#2353 review F4/F5/F7): resolved by the generic
|
|
// per-segment chain walker — the head `this` segment resolves via the
|
|
// synthesized Function-scope typeBinding, each following segment via
|
|
// class-scope typeBindings. Initializer-context sites (instance
|
|
// initializer block / field initializer) have no function scope and
|
|
// therefore no synthesized `this` binding; they resolve via the
|
|
// literal-`this` head seed (enclosing class def) and attribute their
|
|
// CALLS edge to the enclosing Class node. Every scenario has a decoy
|
|
// class (Decoy) owning a same-named method, so a wrong resolution
|
|
// emits a detectable edge.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java this.field chain resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-this-field-chain'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects the caller plus target and decoy classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual([
|
|
'App',
|
|
'Core',
|
|
'Decoy',
|
|
'Engine',
|
|
'Mapper',
|
|
'Monitor',
|
|
'Report',
|
|
'ReportFactory',
|
|
'Result',
|
|
]);
|
|
});
|
|
|
|
it('resolves one-hop this.engine.start() to Engine.start', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'chainOneHop' && c.target === 'start');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.targetFilePath).toBe('models/Engine.java');
|
|
});
|
|
|
|
it('resolves two-hop this.engine.core.ignite() through two typed fields to Core.ignite', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'chainTwoHop' && c.target === 'ignite');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.targetFilePath).toBe('models/Core.java');
|
|
});
|
|
|
|
it('resolves this.monitor.watch() inside an instance initializer block to Monitor.watch', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'App' && c.target === 'watch');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.targetFilePath).toBe('models/Monitor.java');
|
|
});
|
|
|
|
it('resolves the field initializer this.factory.make() to ReportFactory.make', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'App' && c.target === 'make');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.targetFilePath).toBe('models/ReportFactory.java');
|
|
});
|
|
|
|
// #2353 review F5: the dot inside the string argument must not break
|
|
// chain segmentation — both the middle-of-chain lookup() call and the
|
|
// chained run() call resolve to their declaring classes.
|
|
it('resolves a chain whose call argument contains a dot — this.mapper.lookup("a.b").run()', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const lookupEdge = calls.find((c) => c.source === 'chainDottedArg' && c.target === 'lookup');
|
|
expect(lookupEdge).toBeDefined();
|
|
expect(lookupEdge!.targetFilePath).toBe('models/Mapper.java');
|
|
const runEdge = calls.find((c) => c.source === 'chainDottedArg' && c.target === 'run');
|
|
expect(runEdge).toBeDefined();
|
|
expect(runEdge!.targetFilePath).toBe('models/Result.java');
|
|
});
|
|
|
|
// Consistency guard: no this-only special-casing — an identically-shaped
|
|
// parameter-receiver chain (same classes) resolves to the same target.
|
|
it('resolves an identically-shaped obj.field.method() chain the same way as the this. variant', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const paramEdge = calls.find((c) => c.source === 'chainOneHopParam' && c.target === 'start');
|
|
expect(paramEdge).toBeDefined();
|
|
expect(paramEdge!.targetFilePath).toBe('models/Engine.java');
|
|
const thisEdge = calls.find((c) => c.source === 'chainOneHop' && c.target === 'start');
|
|
expect(thisEdge).toBeDefined();
|
|
expect(thisEdge!.targetFilePath).toBe(paramEdge!.targetFilePath);
|
|
});
|
|
|
|
it('emits no CALLS edge to any decoy method', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const decoyEdges = calls
|
|
.filter((c) => c.targetFilePath === 'models/Decoy.java')
|
|
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
|
|
expect(decoyEdges).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bare-`this` dispatch pinning (#2353 review F6): Java `this.member` sites
|
|
// resolve through Case 4 — the synthesized Function-scope `this` typeBinding
|
|
// (languages/java/receiver-binding.ts) feeding the MRO walk — NOT through the
|
|
// C++-authored Case 0.5 chain walk gated by `resolveThisViaEnclosingClass`
|
|
// (receiver-bound-calls.ts). PR #2353 briefly enabled that flag for Java; U7
|
|
// reverted it per the toggle's own contract doc. These scenarios characterize
|
|
// the Case 4 baseline (characterization, not idealization — two deliberate
|
|
// baseline quirks are pinned with deferred-item comments below), and the
|
|
// interface-default fan-out scenario is the A/B discriminator: Case 0.5
|
|
// provably drops the interface-dispatch edges that only Case 4 emits.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java bare-this dispatch (Case 4 pinning)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-this-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects the hierarchy, collision, and interface fixture types', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual([
|
|
'Base',
|
|
'Derived',
|
|
'FastTask',
|
|
'Runner',
|
|
'SizeDecoy',
|
|
'SlowTask',
|
|
'Widget',
|
|
]);
|
|
expect(getNodesByLabel(result, 'Interface')).toEqual(['Task']);
|
|
});
|
|
|
|
// Characterization, not idealization: `this.greet("world")` (arity 1)
|
|
// inside Derived binds to Derived.greet(String, int) — Case 4's
|
|
// `pickFirstNonStaticOnly` short-circuits on a single-overload owner
|
|
// without arity narrowing, so the inherited Base.greet(String) never gets
|
|
// a look. The ideal Base.greet target is the deferred Case 4 arity item
|
|
// (docs/plans/2026-07-02-001 § Deferred). Under PR #2353's Case 0.5, the
|
|
// `hiddenByName` C++ name-hiding rule dropped this member site entirely
|
|
// and a free-call fallback edge (reason 'local-call') to the same target
|
|
// masked the drop.
|
|
it('pins this.greet("world") in Derived to Derived.greet(String,int) — arity-blind shortcut', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetEdges = [
|
|
...new Set(
|
|
calls
|
|
.filter((c) => c.source === 'announce' && c.target === 'greet')
|
|
.map((c) => `${c.targetFilePath} reason=${c.rel.reason}`),
|
|
),
|
|
].sort();
|
|
expect(greetEdges).toEqual(['models/Derived.java reason=global']);
|
|
});
|
|
|
|
// Characterization, not idealization: with field `size` AND method
|
|
// `size()` on Widget, the bare-this READ `this.size` emits ACCESSES
|
|
// reason 'read' targeting the METHOD node — `pickFirstNonStaticOnly`
|
|
// consults methods before fields for every site kind, so methods shadow
|
|
// fields on read sites too. The read-should-target-the-Property fix is
|
|
// the deferred Case 4 methods-shadow-fields item
|
|
// (docs/plans/2026-07-02-001 § Deferred).
|
|
it('pins the this.size field read to the size() Method node (methods-shadow-fields)', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const sizeReads = accesses.filter((e) => e.source === 'describe' && e.target === 'size');
|
|
expect(sizeReads.length).toBe(1);
|
|
expect(sizeReads[0].rel.reason).toBe('read');
|
|
expect(sizeReads[0].targetLabel).toBe('Method');
|
|
expect(sizeReads[0].targetFilePath).toBe('models/Widget.java');
|
|
});
|
|
|
|
it('resolves the this.size() call beside the size field to Widget.size()', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const sizeCalls = [
|
|
...new Set(
|
|
calls
|
|
.filter((c) => c.source === 'measure' && c.target === 'size')
|
|
.map((c) => `${c.targetLabel} @ ${c.targetFilePath}`),
|
|
),
|
|
].sort();
|
|
expect(sizeCalls).toEqual(['Method @ models/Widget.java']);
|
|
});
|
|
|
|
// The A/B discriminator: only Case 4 emits interface-dispatch fan-out
|
|
// (`emitInterfaceDispatchFor`); Case 0.5 resolved this same site to
|
|
// Task.run WITHOUT the implementor edges. Target-SET assertions rather
|
|
// than edge counts, per the deferred duplicate-reference-site quirk.
|
|
it('emits the primary this.run() edge from the default method to Task.run', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const primaries = [
|
|
...new Set(
|
|
calls
|
|
.filter(
|
|
(c) =>
|
|
c.source === 'runAll' && c.target === 'run' && c.rel.reason !== 'interface-dispatch',
|
|
)
|
|
.map((c) => c.targetFilePath),
|
|
),
|
|
].sort();
|
|
expect(primaries).toEqual(['models/Task.java']);
|
|
});
|
|
|
|
it('fans this.run() out to exactly the implementors via interface-dispatch edges', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fanout = [
|
|
...new Set(
|
|
calls
|
|
.filter((c) => c.source === 'runAll' && c.rel.reason === 'interface-dispatch')
|
|
.map((c) => c.targetFilePath),
|
|
),
|
|
].sort();
|
|
expect(fanout).toEqual(['models/FastTask.java', 'models/SlowTask.java']);
|
|
});
|
|
|
|
it('interface-dispatch fan-out excludes the interface itself and the non-implementor Runner', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fanout = calls.filter((c) => c.rel.reason === 'interface-dispatch');
|
|
for (const edge of fanout) {
|
|
expect(edge.targetFilePath).not.toBe('models/Task.java');
|
|
expect(edge.targetFilePath).not.toBe('models/Runner.java');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issues #2545/#2550: an anonymous class body (`new Runnable() { public
|
|
// void run() {} }`) is a first-class instance. It gets a synthesized
|
|
// javac-style Class node (`Worker$1`), owns its methods (re-keyed
|
|
// `Worker$1.run`), and the enclosing-owner walk attributes to it instead
|
|
// of the lexically enclosing named class. `(object_creation_expression
|
|
// (class_body) @scope.class)` (from #2545) provides the scope boundary;
|
|
// the #2550 instance model provides identity + ownership.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java anonymous-class instance identity (#2550)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anonymous-class-scope'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits a Class node Worker$1 for the anonymous Runnable body', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Worker$1');
|
|
});
|
|
|
|
it('re-keys the anonymous run method to Worker$1.run and owns it via HAS_METHOD', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const owned = hasMethod.find(
|
|
(e) =>
|
|
e.rel.sourceId === 'Class:src/Worker.java:Worker$1' &&
|
|
e.rel.targetId === 'Method:src/Worker.java:Worker$1.run#0',
|
|
);
|
|
expect(owned).toBeDefined();
|
|
});
|
|
|
|
it('still extracts the anonymous Runnable method as a Method', () => {
|
|
expect(getNodesByLabel(result, 'Method')).toContain('run');
|
|
});
|
|
|
|
it('resolves handler.run() to Worker$1.run via the receiver path, not the free-call leak', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const explicit = calls.find((c) => c.source === 'makeHandler' && c.target === 'run');
|
|
expect(explicit).toBeDefined();
|
|
expect(explicit!.rel.targetId).toBe('Method:src/Worker.java:Worker$1.run#0');
|
|
expect(explicit!.rel.reason).not.toBe('local-call');
|
|
});
|
|
|
|
it("does not resolve process()'s bare run() to the anonymous class's method (any reason)", () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const leaked = calls.find((c) => c.source === 'process' && c.target === 'run');
|
|
expect(leaked).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Java instance-ownership free-call gate (#2550)', () => {
|
|
it("does not resolve a bare call to an unrelated same-file class's method", async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-unrelated-method-collision'),
|
|
() => {},
|
|
);
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const leaked = calls.find((c) => c.source === 'work' && c.target === 'helper');
|
|
expect(leaked).toBeUndefined();
|
|
}, 60000);
|
|
|
|
it("still resolves a class's own bare call to its own method (implicit this)", async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-builtin-name-legit-dispatch'),
|
|
() => {},
|
|
);
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const legitimate = calls.find((c) => c.source === 'trigger' && c.target === 'run');
|
|
expect(legitimate).toBeDefined();
|
|
expect(legitimate!.targetFilePath).toBe('src/RealTask.java');
|
|
}, 60000);
|
|
|
|
it('still resolves a bare inherited call through the MRO arm', async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-inherited-bare-call'),
|
|
() => {},
|
|
);
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const inherited = calls.find((c) => c.source === 'go' && c.target === 'log');
|
|
expect(inherited).toBeDefined();
|
|
}, 60000);
|
|
|
|
it('numbers multiple anonymous bodies in source order (Multi$1, Multi$2)', async () => {
|
|
const result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anon-numbering'), () => {});
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Multi$1');
|
|
expect(classes).toContain('Multi$2');
|
|
}, 60000);
|
|
});
|
|
|
|
describe('Java local-type identity and lexical scope (#2562)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-local-class-naming'), () => {});
|
|
}, 60000);
|
|
|
|
it('matches javac local-name and anonymous-name sequences per immediate host', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Outer$1Local');
|
|
expect(classes).toContain('Outer$1CtorHost');
|
|
expect(classes).toContain('Outer$1NestedHost');
|
|
expect(classes).toContain('Outer$1');
|
|
expect(classes).toContain('Outer$2');
|
|
expect(classes).toContain('Outer$2Local');
|
|
expect(classes).toContain('Outer$1Local$1');
|
|
expect(classes).toContain('Outer$1CtorHost$1Local');
|
|
expect(classes).toContain('Outer$1NestedHost$Member$1Local');
|
|
expect(classes).toContain('Outer$MemberHost$1Local');
|
|
expect(classes).toContain('Outer$1StaticLocal');
|
|
expect(classes).toContain('Outer$1InstanceLocal');
|
|
expect(classes).toContain('Outer$1LambdaLocal');
|
|
expect(classes).toContain('Outer$3$1Local');
|
|
expect(classes).toContain('Compact$1Local');
|
|
expect(classes).toContain('Compact$1');
|
|
expect(classes).not.toContain('Local');
|
|
});
|
|
|
|
it('emits the correct graph label and owner for every local type kind', () => {
|
|
expect(getNodesByLabel(result, 'Enum')).toContain('Types$1E');
|
|
expect(getNodesByLabel(result, 'Record')).toContain('Types$1R');
|
|
expect(getNodesByLabel(result, 'Interface')).toContain('Types$1I');
|
|
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1E');
|
|
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1R');
|
|
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1I');
|
|
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
for (const [label, owner, method] of [
|
|
['Class', 'Outer$1Local', 'inner'],
|
|
['Class', 'Outer$2Local', 'inner'],
|
|
['Class', 'Outer$1CtorHost$1Local', 'inner'],
|
|
['Class', 'Outer$1NestedHost$Member', 'make'],
|
|
['Enum', 'Types$1E', 'enumHit'],
|
|
['Record', 'Types$1R', 'recordHit'],
|
|
['Interface', 'Types$1I', 'run'],
|
|
]) {
|
|
expect(
|
|
hasMethod.some(
|
|
(edge) =>
|
|
edge.rel.sourceId ===
|
|
`${label}:src/${owner.startsWith('Types') ? 'Types' : 'Outer'}.java:${owner}` &&
|
|
edge.rel.targetId ===
|
|
`Method:src/${owner.startsWith('Types') ? 'Types' : 'Outer'}.java:${owner}.${method}#0`,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('keeps source-level construction dispatch bound to each local identity', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(calls.find((c) => c.source === 'first' && c.target === 'inner')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$1Local.inner#0',
|
|
);
|
|
expect(calls.find((c) => c.source === 'second' && c.target === 'inner')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$2Local.inner#0',
|
|
);
|
|
expect(calls.find((c) => c.source === 'CtorHost' && c.target === 'inner')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$1CtorHost$1Local.inner#0',
|
|
);
|
|
for (const targetId of [
|
|
'Method:src/Outer.java:Outer$1StaticLocal.staticHit#0',
|
|
'Method:src/Outer.java:Outer$1InstanceLocal.instanceHit#0',
|
|
'Method:src/Outer.java:Outer$1LambdaLocal.lambdaHit#0',
|
|
'Method:src/Outer.java:Outer$3$1Local.anonymousHit#0',
|
|
'Method:src/Outer.java:Outer$MemberHost$1Local.ordinaryMemberHit#0',
|
|
'Method:src/Compact.java:Compact$1Local.inner#0',
|
|
'Method:src/Types.java:Types$1E.enumHit#0',
|
|
'Method:src/Types.java:Types$1R.recordHit#0',
|
|
'Method:src/Types.java:Types$1.run#0',
|
|
]) {
|
|
expect(
|
|
calls.some((call) => call.rel.targetId === targetId),
|
|
targetId,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('respects declaration-order visibility against a same-named member type', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(call) => call.source === 'declarationOrder',
|
|
);
|
|
|
|
expect(calls.find((call) => call.target === 'member')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Cyclic.member#0',
|
|
);
|
|
expect(calls.find((call) => call.target === 'local')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$1Cyclic.local#0',
|
|
);
|
|
});
|
|
|
|
it('keeps same-named local types isolated to their disjoint blocks', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter((call) => call.source === 'blocks');
|
|
|
|
expect(calls.find((call) => call.target === 'firstBlock')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$3Local.firstBlock#0',
|
|
);
|
|
expect(calls.find((call) => call.target === 'secondBlock')?.rel.targetId).toBe(
|
|
'Method:src/Outer.java:Outer$4Local.secondBlock#0',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2550 review hardening: (a) an anonymous class inherits from its
|
|
// constructed type, so bare calls to inherited methods INSIDE the
|
|
// anonymous body pass the ownership gate's MRO arm (review caught the
|
|
// gate suppressing that true edge — the anon had no EXTENDS edge and an
|
|
// empty MRO); (b) enum/interface/record hosts synthesize names too, and
|
|
// a hostless anonymous body must NOT materialize a phantom Class node
|
|
// named after the constructed type.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java anonymous-class inheritance and host coverage (#2550 review)', () => {
|
|
it('anon extending a same-file class keeps bare inherited calls and gains an EXTENDS edge', async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-anon-extends-base'),
|
|
() => {},
|
|
);
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const anonExtends = extends_.find(
|
|
(e) => e.rel.sourceId === 'Class:src/App.java:AnonExtHost$1' && e.target === 'Base',
|
|
);
|
|
expect(anonExtends).toBeDefined();
|
|
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const inherited = calls.find((c) => c.source === 'extra' && c.target === 'work');
|
|
expect(inherited).toBeDefined();
|
|
expect(inherited!.rel.targetId).toBe('Method:src/App.java:Base.work#0');
|
|
}, 60000);
|
|
|
|
it('enum-hosted anonymous body is modeled (EnumHost$1) with no phantom constructed-type Class', async () => {
|
|
const result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anon-enum-host'), () => {});
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('EnumHost$1');
|
|
expect(classes).not.toContain('Runnable');
|
|
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const own = calls.find((c) => c.source === 'caller' && c.target === 'run');
|
|
expect(own).toBeDefined();
|
|
expect(own!.rel.targetId).toBe('Method:src/EnumHost.java:EnumHost.run#0');
|
|
}, 60000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2555: enum constant bodies (`enum E { A { ... } }`) are javac's other
|
|
// anonymous-class shape (`E$N`). They join the instance model: synthesized
|
|
// Class node, re-keyed owned methods, EXTENDS to the host enum (so bare
|
|
// calls from the body to enum helpers pass the ownership gate's MRO arm),
|
|
// and the same-file bare-call leak closed. Naming follows JLS 13.1
|
|
// immediate-host binary names (`EnumWrap$Mode$1` for nested hosts).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java enum constant bodies (#2555)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-enum-constant-body'), () => {});
|
|
}, 60000);
|
|
|
|
it('models constant bodies as EnumConst$1 / EnumConst$2 with owned re-keyed methods', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('EnumConst$1');
|
|
expect(classes).toContain('EnumConst$2');
|
|
expect(classes).not.toContain('A');
|
|
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const owned = hasMethod.find(
|
|
(e) =>
|
|
e.rel.sourceId === 'Class:src/EnumConst.java:EnumConst$1' &&
|
|
e.rel.targetId === 'Method:src/EnumConst.java:EnumConst$1.hook#0',
|
|
);
|
|
expect(owned).toBeDefined();
|
|
});
|
|
|
|
it('emits EXTENDS from each constant body to the host enum', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const first = extends_.find(
|
|
(e) => e.rel.sourceId === 'Class:src/EnumConst.java:EnumConst$1' && e.target === 'EnumConst',
|
|
);
|
|
expect(first).toBeDefined();
|
|
});
|
|
|
|
it("resolves a constant body's bare call to an enum helper via the MRO arm", () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const inherited = calls.find((c) => c.source === 'hook' && c.target === 'log');
|
|
expect(inherited).toBeDefined();
|
|
expect(inherited!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst.log#0');
|
|
});
|
|
|
|
it("does not resolve an unrelated same-file class's bare hook() to any constant body", () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const leaked = calls.find((c) => c.source === 'caller' && c.target === 'hook');
|
|
expect(leaked).toBeUndefined();
|
|
});
|
|
|
|
it('names a nested-host anonymous body with the JLS 13.1 immediate-host chain', async () => {
|
|
const nested = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-nested-host-naming'),
|
|
() => {},
|
|
);
|
|
const classes = getNodesByLabel(nested, 'Class');
|
|
expect(classes).toContain('EnumWrap$Mode$1');
|
|
expect(classes).not.toContain('EnumWrap$1');
|
|
}, 60000);
|
|
|
|
it('chains anonymous enclosing types per JLS 13.1: anon inside anon is NestHost$1$1', async () => {
|
|
const result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anon-in-anon'), () => {});
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('NestHost$1');
|
|
expect(classes).toContain('NestHost$1$1');
|
|
expect(classes).not.toContain('NestHost$2');
|
|
}, 60000);
|
|
|
|
it('chains through enum constant bodies: anon inside a constant body is N$1$1', async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-anon-in-constant-body'),
|
|
() => {},
|
|
);
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('N$1');
|
|
expect(classes).toContain('N$1$1');
|
|
expect(classes).not.toContain('N$2');
|
|
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const owned = hasMethod.find(
|
|
(e) =>
|
|
e.rel.sourceId === 'Class:src/N.java:N$1$1' &&
|
|
e.rel.targetId === 'Method:src/N.java:N$1$1.run#0',
|
|
);
|
|
expect(owned).toBeDefined();
|
|
}, 60000);
|
|
|
|
it('names a bodied constant in a NESTED enum with the full host chain (EnumWrap2$Mode$1)', async () => {
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-nested-enum-constant'),
|
|
() => {},
|
|
);
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('EnumWrap2$Mode$1');
|
|
expect(classes).not.toContain('EnumWrap2$1');
|
|
}, 60000);
|
|
|
|
it('attributes same-named methods across sibling constant bodies to their own classes', async () => {
|
|
// Review-caught collapse: the scope-side qualifier chained the
|
|
// synthesized class def to `M3.M3$2`, desyncing from the structure
|
|
// node id, so both bodies' `hook` calls attributed to M3$1's node
|
|
// via the simple-name fallback. Each body's caller must be its own.
|
|
const result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'java-enum-constant-same-name'),
|
|
() => {},
|
|
);
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fromA = calls.find(
|
|
(c) =>
|
|
c.rel.sourceId === 'Method:src/M3.java:M3$1.hook#0' &&
|
|
c.rel.targetId === 'Method:src/M3.java:M3.base#0',
|
|
);
|
|
const fromC = calls.find(
|
|
(c) =>
|
|
c.rel.sourceId === 'Method:src/M3.java:M3$2.hook#0' &&
|
|
c.rel.targetId === 'Method:src/M3.java:M3.log#0',
|
|
);
|
|
expect(fromA).toBeDefined();
|
|
expect(fromC).toBeDefined();
|
|
const misattributed = calls.find(
|
|
(c) =>
|
|
c.rel.sourceId === 'Method:src/M3.java:M3$1.hook#0' &&
|
|
c.rel.targetId === 'Method:src/M3.java:M3.log#0',
|
|
);
|
|
expect(misattributed).toBeUndefined();
|
|
}, 60000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2561: E.CONST.method() emits no CALLS edge — the receiver-side follow-up
|
|
// to #2555. A bodied constant's receiver must resolve to its synthesized
|
|
// E$N class; a body-less constant's receiver must resolve to the host enum
|
|
// itself.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java enum-constant receiver dispatch (#2561)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-enum-constant-body'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves EnumConst.A.hook() to the bodied constant override (EnumConst$1.hook#0)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const dispatch = calls.find((c) => c.source === 'dispatchToConstant' && c.target === 'hook');
|
|
expect(dispatch).toBeDefined();
|
|
expect(dispatch!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst$1.hook#0');
|
|
});
|
|
|
|
it("resolves EnumConst.A.log() to the host enum's inherited method via E$N's MRO (EnumConst.log#0)", () => {
|
|
// A's body overrides hook() but NOT log(); log() lives only on the enum.
|
|
// The bodied constant's receiver binds to EnumConst$1, whose MRO includes
|
|
// EnumConst (via @reference.inherits), so the qualified call reaches the
|
|
// host enum's own method — the inherited-dispatch capability the fix enables.
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const dispatch = calls.find((c) => c.source === 'dispatchInherited' && c.target === 'log');
|
|
expect(dispatch).toBeDefined();
|
|
expect(dispatch!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst.log#0');
|
|
});
|
|
|
|
it("resolves Plain.A.m() to the body-less constant's inherited enum method (Plain.m#0)", () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const dispatch = calls.find((c) => c.source === 'callPlain' && c.target === 'm');
|
|
expect(dispatch).toBeDefined();
|
|
expect(dispatch!.rel.targetId).toBe('Method:src/Plain.java:Plain.m#0');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Program boundary vs. analysis uncertainty (#2744).
|
|
//
|
|
// The origin classifier's only source of positive EXTERNAL evidence is
|
|
// `LanguageProvider.isBuiltInName`. Java declared no built-in set, so no Java
|
|
// drop could ever be judged external and every one of them hedged `impact()`
|
|
// down to `epistemic: 'lower-bound'` — safe, but it left the language unable to
|
|
// name its own boundary. This exercises the whole wiring end to end (provider →
|
|
// `runScopeResolution` → pass options → classifier → drop record), which the
|
|
// classifier unit tests deliberately do not.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Java receiver-unresolved drops name the program boundary (#2744)', () => {
|
|
let repoDir: string;
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-java-receiver-origin-'));
|
|
writeFixtureRepo(repoDir, {
|
|
'src/Boundary.java': `public class Boundary {
|
|
public void platform(String raw) {
|
|
// Rooted at \`System\`, which the language itself names. Nothing was
|
|
// lost: no node in this index could have been the target.
|
|
System.out.println(raw);
|
|
}
|
|
|
|
public void unknownReceiver(OrderRepository repo) {
|
|
// \`OrderRepository\` is declared nowhere here and is not a platform
|
|
// name. Absence of evidence is not evidence of externality — this must
|
|
// stay hedged, or a genuinely missing in-program caller gets published
|
|
// as \`exact\`. Spelled as a CHAIN on purpose: a bare \`repo.findAll()\`
|
|
// never reaches the drop recorder at all (the pass takes a different
|
|
// arm and records nothing), so it would assert on an empty set.
|
|
repo.find().save();
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
result = await runPipelineFromRepo(repoDir, () => {}, {});
|
|
}, 60000);
|
|
|
|
afterAll(() => {
|
|
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('marks a System.out.println(...) drop external', () => {
|
|
const drops = getResolutionOutcomes(result).filter(
|
|
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
|
|
);
|
|
expect(drops).toContainEqual(
|
|
expect.objectContaining({ name: 'println', siteKind: 'call', receiverOrigin: 'external' }),
|
|
);
|
|
});
|
|
|
|
it('does not mark a drop on an undeclared user receiver external', () => {
|
|
const drops = getResolutionOutcomes(result).filter(
|
|
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
|
|
);
|
|
expect(drops).toContainEqual(
|
|
expect.objectContaining({ name: 'save', siteKind: 'call', receiverOrigin: 'unknown' }),
|
|
);
|
|
expect(drops).not.toContainEqual(
|
|
expect.objectContaining({ name: 'save', receiverOrigin: 'external' }),
|
|
);
|
|
});
|
|
});
|