mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
1941 lines
74 KiB
TypeScript
1941 lines
74 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { computeMRO } from '../../src/core/ingestion/mro-processor.js';
|
|
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
|
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
|
import { generateId } from '../../src/lib/utils.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function addClass(
|
|
graph: KnowledgeGraph,
|
|
name: string,
|
|
language: string,
|
|
label: 'Class' | 'Interface' | 'Struct' | 'Trait' = 'Class',
|
|
) {
|
|
const id = generateId(label, name);
|
|
graph.addNode({
|
|
id,
|
|
label,
|
|
properties: { name, filePath: `src/${name}.ts`, language },
|
|
});
|
|
return id;
|
|
}
|
|
|
|
function addMethod(
|
|
graph: KnowledgeGraph,
|
|
className: string,
|
|
methodName: string,
|
|
classLabel: 'Class' | 'Interface' | 'Struct' | 'Trait' = 'Class',
|
|
parameterTypes?: string[],
|
|
opts?: { isAbstract?: boolean; parameterCount?: number },
|
|
) {
|
|
// Derive arity for the ID suffix: explicit parameterCount > parameterTypes.length > 0
|
|
const arity = opts?.parameterCount ?? parameterTypes?.length ?? 0;
|
|
const classId = generateId(classLabel, className);
|
|
const methodId = generateId('Method', `${className}.${methodName}#${arity}`);
|
|
graph.addNode({
|
|
id: methodId,
|
|
label: 'Method',
|
|
properties: {
|
|
name: methodName,
|
|
filePath: `src/${className}.ts`,
|
|
parameterCount: arity,
|
|
...(parameterTypes ? { parameterTypes } : {}),
|
|
...(opts?.isAbstract !== undefined ? { isAbstract: opts.isAbstract } : {}),
|
|
},
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${classId}->${methodId}`),
|
|
sourceId: classId,
|
|
targetId: methodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
return methodId;
|
|
}
|
|
|
|
function addExtends(
|
|
graph: KnowledgeGraph,
|
|
childName: string,
|
|
parentName: string,
|
|
childLabel: 'Class' | 'Struct' = 'Class',
|
|
parentLabel: 'Class' | 'Interface' | 'Trait' = 'Class',
|
|
) {
|
|
const childId = generateId(childLabel, childName);
|
|
const parentId = generateId(parentLabel, parentName);
|
|
graph.addRelationship({
|
|
id: generateId('EXTENDS', `${childId}->${parentId}`),
|
|
sourceId: childId,
|
|
targetId: parentId,
|
|
type: 'EXTENDS',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
}
|
|
|
|
function addInterfaceExtends(
|
|
graph: KnowledgeGraph,
|
|
childName: string,
|
|
parentName: string,
|
|
childLabel: 'Interface' | 'Trait' = 'Interface',
|
|
parentLabel: 'Interface' | 'Trait' = 'Interface',
|
|
) {
|
|
const childId = generateId(childLabel, childName);
|
|
const parentId = generateId(parentLabel, parentName);
|
|
graph.addRelationship({
|
|
id: generateId('EXTENDS', `${childId}->${parentId}`),
|
|
sourceId: childId,
|
|
targetId: parentId,
|
|
type: 'EXTENDS',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
}
|
|
|
|
function addImplements(
|
|
graph: KnowledgeGraph,
|
|
childName: string,
|
|
parentName: string,
|
|
childLabel: 'Class' | 'Struct' = 'Class',
|
|
parentLabel: 'Interface' | 'Trait' = 'Interface',
|
|
) {
|
|
const childId = generateId(childLabel, childName);
|
|
const parentId = generateId(parentLabel, parentName);
|
|
graph.addRelationship({
|
|
id: generateId('IMPLEMENTS', `${childId}->${parentId}`),
|
|
sourceId: childId,
|
|
targetId: parentId,
|
|
type: 'IMPLEMENTS',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeMRO', () => {
|
|
// ---- C++ diamond --------------------------------------------------------
|
|
describe('C++ diamond inheritance', () => {
|
|
it('leftmost base wins when both B and C override foo', () => {
|
|
// Diamond: A <- B, A <- C, B <- D, C <- D
|
|
const graph = createKnowledgeGraph();
|
|
const aId = addClass(graph, 'A', 'cpp');
|
|
const bId = addClass(graph, 'B', 'cpp');
|
|
const cId = addClass(graph, 'C', 'cpp');
|
|
const dId = addClass(graph, 'D', 'cpp');
|
|
|
|
addExtends(graph, 'B', 'A');
|
|
addExtends(graph, 'C', 'A');
|
|
addExtends(graph, 'D', 'B'); // B is leftmost
|
|
addExtends(graph, 'D', 'C');
|
|
|
|
// A has foo, B overrides foo, C overrides foo
|
|
addMethod(graph, 'A', 'foo');
|
|
const bFoo = addMethod(graph, 'B', 'foo');
|
|
const cFoo = addMethod(graph, 'C', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
// D should have an entry with ambiguity on foo
|
|
const dEntry = result.entries.find((e) => e.className === 'D');
|
|
expect(dEntry).toBeDefined();
|
|
expect(dEntry!.language).toBe('cpp');
|
|
|
|
const fooAmbiguity = dEntry!.ambiguities.find((a) => a.methodName === 'foo');
|
|
expect(fooAmbiguity).toBeDefined();
|
|
expect(fooAmbiguity!.definedIn.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Leftmost base (B) wins
|
|
expect(fooAmbiguity!.resolvedTo).toBe(bFoo);
|
|
expect(fooAmbiguity!.reason).toContain('leftmost base');
|
|
expect(fooAmbiguity!.reason).toContain('B');
|
|
|
|
// OVERRIDES edge emitted
|
|
expect(result.overrideEdges).toBeGreaterThanOrEqual(1);
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides.some((r) => r.sourceId === dId && r.targetId === bFoo)).toBe(true);
|
|
});
|
|
|
|
it('no ambiguity when foo only in A (diamond no override)', () => {
|
|
// Diamond: A <- B, A <- C, B <- D, C <- D, but only A has foo
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'cpp');
|
|
addClass(graph, 'B', 'cpp');
|
|
addClass(graph, 'C', 'cpp');
|
|
addClass(graph, 'D', 'cpp');
|
|
|
|
addExtends(graph, 'B', 'A');
|
|
addExtends(graph, 'C', 'A');
|
|
addExtends(graph, 'D', 'B');
|
|
addExtends(graph, 'D', 'C');
|
|
|
|
// Only A has foo
|
|
addMethod(graph, 'A', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const dEntry = result.entries.find((e) => e.className === 'D');
|
|
expect(dEntry).toBeDefined();
|
|
// A::foo appears only once across ancestors — no collision
|
|
// (B and C don't have their own foo, the duplicate is A::foo seen through both paths)
|
|
const fooAmbiguity = dEntry!.ambiguities.find((a) => a.methodName === 'foo');
|
|
expect(fooAmbiguity).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---- C# class + interface -----------------------------------------------
|
|
describe('C# class + interface', () => {
|
|
it('class method beats interface default', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const classId = addClass(graph, 'MyClass', 'csharp');
|
|
const baseId = addClass(graph, 'BaseClass', 'csharp');
|
|
const ifaceId = addClass(graph, 'IDoSomething', 'csharp', 'Interface');
|
|
|
|
addExtends(graph, 'MyClass', 'BaseClass');
|
|
addImplements(graph, 'MyClass', 'IDoSomething');
|
|
|
|
const baseDoIt = addMethod(graph, 'BaseClass', 'doIt');
|
|
const ifaceDoIt = addMethod(graph, 'IDoSomething', 'doIt', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'MyClass');
|
|
expect(entry).toBeDefined();
|
|
|
|
const doItAmbiguity = entry!.ambiguities.find((a) => a.methodName === 'doIt');
|
|
expect(doItAmbiguity).toBeDefined();
|
|
// Class method wins
|
|
expect(doItAmbiguity!.resolvedTo).toBe(baseDoIt);
|
|
expect(doItAmbiguity!.reason).toContain('class method wins');
|
|
});
|
|
|
|
it('multiple interface methods with same name are ambiguous', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'MyClass', 'csharp');
|
|
addClass(graph, 'IFoo', 'csharp', 'Interface');
|
|
addClass(graph, 'IBar', 'csharp', 'Interface');
|
|
|
|
addImplements(graph, 'MyClass', 'IFoo');
|
|
addImplements(graph, 'MyClass', 'IBar');
|
|
|
|
addMethod(graph, 'IFoo', 'process', 'Interface');
|
|
addMethod(graph, 'IBar', 'process', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'MyClass');
|
|
expect(entry).toBeDefined();
|
|
|
|
const processAmbiguity = entry!.ambiguities.find((a) => a.methodName === 'process');
|
|
expect(processAmbiguity).toBeDefined();
|
|
expect(processAmbiguity!.resolvedTo).toBeNull();
|
|
expect(processAmbiguity!.reason).toContain('ambiguous');
|
|
expect(result.ambiguityCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ---- Python C3 ----------------------------------------------------------
|
|
describe('Python C3 linearization', () => {
|
|
it('C3 order determines winner in diamond with overrides', () => {
|
|
// Diamond: A <- B, A <- C, B <- D, C <- D
|
|
// class D(B, C) → C3 MRO: B, C, A
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'python');
|
|
addClass(graph, 'B', 'python');
|
|
addClass(graph, 'C', 'python');
|
|
const dId = addClass(graph, 'D', 'python');
|
|
|
|
addExtends(graph, 'B', 'A');
|
|
addExtends(graph, 'C', 'A');
|
|
addExtends(graph, 'D', 'B'); // B first → leftmost in C3
|
|
addExtends(graph, 'D', 'C');
|
|
|
|
addMethod(graph, 'A', 'foo');
|
|
const bFoo = addMethod(graph, 'B', 'foo');
|
|
addMethod(graph, 'C', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const dEntry = result.entries.find((e) => e.className === 'D');
|
|
expect(dEntry).toBeDefined();
|
|
|
|
const fooAmbiguity = dEntry!.ambiguities.find((a) => a.methodName === 'foo');
|
|
expect(fooAmbiguity).toBeDefined();
|
|
// C3 linearization for D(B, C): B comes first
|
|
expect(fooAmbiguity!.resolvedTo).toBe(bFoo);
|
|
expect(fooAmbiguity!.reason).toContain('C3 MRO');
|
|
});
|
|
});
|
|
|
|
// ---- Java class + interface ---------------------------------------------
|
|
describe('Java class + interface', () => {
|
|
it('class method beats interface default', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Service', 'java');
|
|
addClass(graph, 'BaseService', 'java');
|
|
addClass(graph, 'Runnable', 'java', 'Interface');
|
|
|
|
addExtends(graph, 'Service', 'BaseService');
|
|
addImplements(graph, 'Service', 'Runnable');
|
|
|
|
const baseRun = addMethod(graph, 'BaseService', 'run');
|
|
addMethod(graph, 'Runnable', 'run', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'Service');
|
|
expect(entry).toBeDefined();
|
|
|
|
const runAmbiguity = entry!.ambiguities.find((a) => a.methodName === 'run');
|
|
expect(runAmbiguity).toBeDefined();
|
|
expect(runAmbiguity!.resolvedTo).toBe(baseRun);
|
|
expect(runAmbiguity!.reason).toContain('class method wins');
|
|
});
|
|
});
|
|
|
|
// ---- Rust trait conflicts -----------------------------------------------
|
|
describe('Rust trait conflicts', () => {
|
|
it('trait conflicts result in null resolution with qualified syntax reason', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'MyStruct', 'rust', 'Struct');
|
|
addClass(graph, 'TraitA', 'rust', 'Trait');
|
|
addClass(graph, 'TraitB', 'rust', 'Trait');
|
|
|
|
addImplements(graph, 'MyStruct', 'TraitA', 'Struct', 'Trait');
|
|
addImplements(graph, 'MyStruct', 'TraitB', 'Struct', 'Trait');
|
|
|
|
addMethod(graph, 'TraitA', 'execute', 'Trait');
|
|
addMethod(graph, 'TraitB', 'execute', 'Trait');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'MyStruct');
|
|
expect(entry).toBeDefined();
|
|
|
|
const execAmbiguity = entry!.ambiguities.find((a) => a.methodName === 'execute');
|
|
expect(execAmbiguity).toBeDefined();
|
|
expect(execAmbiguity!.resolvedTo).toBeNull();
|
|
expect(execAmbiguity!.reason).toContain('qualified syntax');
|
|
expect(result.ambiguityCount).toBeGreaterThanOrEqual(1);
|
|
|
|
// No OVERRIDES edge emitted for Rust ambiguity
|
|
const overrides = graph.relationships.filter(
|
|
(r) => r.type === 'METHOD_OVERRIDES' && r.sourceId === generateId('Struct', 'MyStruct'),
|
|
);
|
|
expect(overrides).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---- Property collisions don't trigger OVERRIDES ------------------------
|
|
describe('Property nodes excluded from OVERRIDES', () => {
|
|
it('property name collision across parents does not emit OVERRIDES edge', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const parentA = addClass(graph, 'ParentA', 'typescript');
|
|
const parentB = addClass(graph, 'ParentB', 'typescript');
|
|
const child = addClass(graph, 'Child', 'typescript');
|
|
|
|
addExtends(graph, 'Child', 'ParentA');
|
|
addExtends(graph, 'Child', 'ParentB');
|
|
|
|
// Add Property nodes (same name 'name') to both parents via HAS_PROPERTY
|
|
const propA = generateId('Property', 'ParentA.name');
|
|
graph.addNode({
|
|
id: propA,
|
|
label: 'Property',
|
|
properties: { name: 'name', filePath: 'src/ParentA.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
|
|
sourceId: parentA,
|
|
targetId: propA,
|
|
type: 'HAS_PROPERTY',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const propB = generateId('Property', 'ParentB.name');
|
|
graph.addNode({
|
|
id: propB,
|
|
label: 'Property',
|
|
properties: { name: 'name', filePath: 'src/ParentB.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
|
|
sourceId: parentB,
|
|
targetId: propB,
|
|
type: 'HAS_PROPERTY',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
// No OVERRIDES edge should be emitted for properties
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(0);
|
|
expect(result.overrideEdges).toBe(0);
|
|
});
|
|
|
|
it('method collision still triggers OVERRIDES even when properties also collide', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const parentA = addClass(graph, 'PA', 'cpp');
|
|
const parentB = addClass(graph, 'PB', 'cpp');
|
|
addClass(graph, 'Ch', 'cpp');
|
|
|
|
addExtends(graph, 'Ch', 'PA');
|
|
addExtends(graph, 'Ch', 'PB');
|
|
|
|
// Method collision (should trigger OVERRIDES)
|
|
const methodA = addMethod(graph, 'PA', 'doWork');
|
|
addMethod(graph, 'PB', 'doWork');
|
|
|
|
// Property collision (should NOT trigger OVERRIDES — properties use HAS_PROPERTY, not HAS_METHOD)
|
|
const propA = generateId('Property', 'PA.id');
|
|
graph.addNode({
|
|
id: propA,
|
|
label: 'Property',
|
|
properties: { name: 'id', filePath: 'src/PA.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
|
|
sourceId: parentA,
|
|
targetId: propA,
|
|
type: 'HAS_PROPERTY',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const propB = generateId('Property', 'PB.id');
|
|
graph.addNode({
|
|
id: propB,
|
|
label: 'Property',
|
|
properties: { name: 'id', filePath: 'src/PB.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
|
|
sourceId: parentB,
|
|
targetId: propB,
|
|
type: 'HAS_PROPERTY',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
// Only 1 OVERRIDES edge (for the method, not the property)
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(1);
|
|
expect(overrides[0].targetId).toBe(methodA); // leftmost base wins for C++
|
|
expect(result.overrideEdges).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---- No ambiguity: single parent ----------------------------------------
|
|
describe('single parent, no ambiguity', () => {
|
|
it('single parent with unique methods produces no ambiguities', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Parent', 'typescript');
|
|
addClass(graph, 'Child', 'typescript');
|
|
|
|
addExtends(graph, 'Child', 'Parent');
|
|
|
|
addMethod(graph, 'Parent', 'foo');
|
|
addMethod(graph, 'Parent', 'bar');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'Child');
|
|
expect(entry).toBeDefined();
|
|
expect(entry!.ambiguities).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---- No parents: standalone class not in entries ------------------------
|
|
describe('standalone class', () => {
|
|
it('class with no parents is not included in entries', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Standalone', 'typescript');
|
|
addMethod(graph, 'Standalone', 'doStuff');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'Standalone');
|
|
expect(entry).toBeUndefined();
|
|
expect(result.overrideEdges).toBe(0);
|
|
expect(result.ambiguityCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---- Own method shadows ancestor ----------------------------------------
|
|
describe('own method shadows ancestor', () => {
|
|
it('class defining its own method suppresses ambiguity', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Base1', 'cpp');
|
|
addClass(graph, 'Base2', 'cpp');
|
|
addClass(graph, 'Child', 'cpp');
|
|
|
|
addExtends(graph, 'Child', 'Base1');
|
|
addExtends(graph, 'Child', 'Base2');
|
|
|
|
addMethod(graph, 'Base1', 'foo');
|
|
addMethod(graph, 'Base2', 'foo');
|
|
addMethod(graph, 'Child', 'foo'); // own method
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const entry = result.entries.find((e) => e.className === 'Child');
|
|
expect(entry).toBeDefined();
|
|
// No ambiguity because Child defines its own foo
|
|
const fooAmbiguity = entry!.ambiguities.find((a) => a.methodName === 'foo');
|
|
expect(fooAmbiguity).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---- Single-ancestor override detection ---------------------------------
|
|
describe('single-ancestor override detection', () => {
|
|
it('emits one Class→Method edge when a subclass overrides a parent method', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const childId = addClass(graph, 'B', 'java');
|
|
addClass(graph, 'A', 'java');
|
|
addExtends(graph, 'B', 'A');
|
|
const aFoo = addMethod(graph, 'A', 'foo');
|
|
addMethod(graph, 'B', 'foo'); // B overrides A.foo
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(1);
|
|
expect(overrides[0].sourceId).toBe(childId); // child CLASS is the source
|
|
expect(overrides[0].targetId).toBe(aFoo); // ancestor METHOD node, not the class
|
|
expect(overrides[0].confidence).toBe(0.9);
|
|
expect(result.overrideEdges).toBe(1);
|
|
});
|
|
|
|
it('emits a distinct edge per overridden method of the same parent', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'A', 'java');
|
|
addExtends(graph, 'B', 'A');
|
|
const aFoo = addMethod(graph, 'A', 'foo');
|
|
const aBar = addMethod(graph, 'A', 'bar');
|
|
addMethod(graph, 'B', 'foo');
|
|
addMethod(graph, 'B', 'bar');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(2);
|
|
expect(overrides.map((r) => r.targetId).sort()).toEqual([aBar, aFoo].sort());
|
|
expect(result.overrideEdges).toBe(2); // counter matches edges actually added
|
|
});
|
|
|
|
it('does not treat a same-named overload (different arity) as an override', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'A', 'java');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'A', 'foo', 'Class', undefined, { parameterCount: 0 });
|
|
addMethod(graph, 'B', 'foo', 'Class', undefined, { parameterCount: 1 }); // foo(x) — overload
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(0);
|
|
expect(result.overrideEdges).toBe(0);
|
|
});
|
|
|
|
it('targets the nearest ancestor when multiple levels define the method', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'C', 'java');
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'A', 'java');
|
|
addExtends(graph, 'C', 'B');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'A', 'foo');
|
|
const bFoo = addMethod(graph, 'B', 'foo'); // nearest ancestor of C with foo
|
|
addMethod(graph, 'C', 'foo');
|
|
|
|
computeMRO(graph);
|
|
|
|
const cOverride = graph.relationships.find(
|
|
(r) => r.type === 'METHOD_OVERRIDES' && r.sourceId === generateId('Class', 'C'),
|
|
);
|
|
expect(cOverride).toBeDefined();
|
|
expect(cOverride!.targetId).toBe(bFoo); // nearest (B), not A
|
|
});
|
|
|
|
it('does not emit overrides for qualified-syntax languages (Rust)', () => {
|
|
const graph = createKnowledgeGraph();
|
|
// Identical class-inheritance shape to the Java happy-path; the Rust
|
|
// (qualified-syntax) gate must suppress it.
|
|
addClass(graph, 'Derived', 'rust');
|
|
addClass(graph, 'Base', 'rust');
|
|
addExtends(graph, 'Derived', 'Base');
|
|
addMethod(graph, 'Base', 'run');
|
|
addMethod(graph, 'Derived', 'run');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(0);
|
|
expect(result.overrideEdges).toBe(0);
|
|
});
|
|
|
|
it('does not emit an override for a same-named interface method (implements, not extends)', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Impl', 'java');
|
|
addClass(graph, 'Iface', 'java', 'Interface');
|
|
addImplements(graph, 'Impl', 'Iface');
|
|
addMethod(graph, 'Iface', 'run', 'Interface');
|
|
addMethod(graph, 'Impl', 'run');
|
|
|
|
computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(overrides).toHaveLength(0); // interface satisfaction is METHOD_IMPLEMENTS, not an override
|
|
});
|
|
|
|
it('overrideEdges count equals the number of METHOD_OVERRIDES relationships', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'A', 'java');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'A', 'foo');
|
|
addMethod(graph, 'A', 'bar');
|
|
addMethod(graph, 'B', 'foo');
|
|
addMethod(graph, 'B', 'bar');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES');
|
|
expect(result.overrideEdges).toBe(overrides.length);
|
|
});
|
|
});
|
|
|
|
// ---- Empty graph --------------------------------------------------------
|
|
describe('empty graph', () => {
|
|
it('returns empty result for graph with no classes', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const result = computeMRO(graph);
|
|
expect(result.entries).toHaveLength(0);
|
|
expect(result.overrideEdges).toBe(0);
|
|
expect(result.ambiguityCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---- Cyclic inheritance (P1 fix) ----------------------------------------
|
|
describe('cyclic inheritance', () => {
|
|
it('does not stack overflow on cyclic Python hierarchy', () => {
|
|
// A extends B, B extends A — cyclic
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'python');
|
|
addClass(graph, 'B', 'python');
|
|
addExtends(graph, 'A', 'B');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'A', 'foo');
|
|
addMethod(graph, 'B', 'foo');
|
|
|
|
// Should NOT throw — c3Linearize returns null, falls back to BFS
|
|
const result = computeMRO(graph);
|
|
expect(result).toBeDefined();
|
|
// Both A and B have parents, so both get entries
|
|
expect(result.entries.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('handles 3-node cycle gracefully', () => {
|
|
// A → B → C → A
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'X', 'python');
|
|
addClass(graph, 'Y', 'python');
|
|
addClass(graph, 'Z', 'python');
|
|
addExtends(graph, 'X', 'Y');
|
|
addExtends(graph, 'Y', 'Z');
|
|
addExtends(graph, 'Z', 'X');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('returns null for C3 merge-conflict inconsistency (non-cyclic)', () => {
|
|
// Classic incompatible ordering: A(X,Y) and B(Y,X) → C(A,B) is unresolvable
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'X', 'python');
|
|
addClass(graph, 'Y', 'python');
|
|
addClass(graph, 'A', 'python');
|
|
addClass(graph, 'B', 'python');
|
|
addClass(graph, 'C', 'python');
|
|
addExtends(graph, 'A', 'X');
|
|
addExtends(graph, 'A', 'Y');
|
|
addExtends(graph, 'B', 'Y');
|
|
addExtends(graph, 'B', 'X');
|
|
addExtends(graph, 'C', 'A');
|
|
addExtends(graph, 'C', 'B');
|
|
addMethod(graph, 'X', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result).toBeDefined();
|
|
// C3 fails for C — falls back to BFS ancestors
|
|
const entryC = result.entries.find((e) => e.className === 'C');
|
|
expect(entryC).toBeDefined();
|
|
// BFS fallback still produces an MRO (just not C3-ordered)
|
|
expect(entryC!.mro.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ---- Performance (deep chains) -------------------------------------------
|
|
describe('performance', () => {
|
|
it('handles very deep single-inheritance chain without stack overflow', () => {
|
|
const graph = createKnowledgeGraph();
|
|
const DEPTH = 2000;
|
|
for (let i = 0; i < DEPTH; i++) {
|
|
addClass(graph, `C${i}`, 'python');
|
|
}
|
|
for (let i = 1; i < DEPTH; i++) {
|
|
addExtends(graph, `C${i}`, `C${i - 1}`);
|
|
}
|
|
addMethod(graph, 'C0', 'baseMethod');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result).toBeDefined();
|
|
const deepest = result.entries.find((e) => e.className === `C${DEPTH - 1}`);
|
|
if (deepest) {
|
|
expect(deepest.mro.length).toBe(DEPTH - 1);
|
|
}
|
|
}, 15_000);
|
|
});
|
|
|
|
// ---- METHOD_IMPLEMENTS edges -----------------------------------------------
|
|
describe('METHOD_IMPLEMENTS edges', () => {
|
|
it('emits METHOD_IMPLEMENTS for class implementing interface method', () => {
|
|
// IAnimal { speak() } <-- Dog { speak() }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IAnimal', 'java', 'Interface');
|
|
addClass(graph, 'Dog', 'java');
|
|
addImplements(graph, 'Dog', 'IAnimal');
|
|
const ifaceMethod = addMethod(graph, 'IAnimal', 'speak', 'Interface');
|
|
const classMethod = addMethod(graph, 'Dog', 'speak');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
|
|
// Verify the edge exists: ConcreteMethod → InterfaceMethod
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(classMethod);
|
|
expect(edges[0].targetId).toBe(ifaceMethod);
|
|
// Both sides have parameterCount=0 (arity match) → confidence 1.0
|
|
expect(edges[0].confidence).toBe(1.0);
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS for Rust struct implementing trait', () => {
|
|
// Drawable { draw() } <-- Circle { draw() }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Drawable', 'rust', 'Trait');
|
|
addClass(graph, 'Circle', 'rust', 'Struct');
|
|
addImplements(graph, 'Circle', 'Drawable', 'Struct', 'Trait');
|
|
const traitMethod = addMethod(graph, 'Drawable', 'draw', 'Trait');
|
|
const structMethod = addMethod(graph, 'Circle', 'draw', 'Struct');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
expect(edges[0].sourceId).toBe(structMethod);
|
|
expect(edges[0].targetId).toBe(traitMethod);
|
|
});
|
|
|
|
it('matches overloaded interface methods by parameterTypes', () => {
|
|
// IRepo { find(String), find(String, int) } <-- SqlRepo { find(String), find(String, int) }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IRepo', 'java', 'Interface');
|
|
addClass(graph, 'SqlRepo', 'java');
|
|
addImplements(graph, 'SqlRepo', 'IRepo');
|
|
|
|
// Use manual IDs to avoid overloaded-name collision (same name, different types)
|
|
const ifaceFind1 = generateId('Method', 'IRepo.find#1');
|
|
graph.addNode({
|
|
id: ifaceFind1,
|
|
label: 'Method',
|
|
properties: {
|
|
name: 'find',
|
|
filePath: 'src/IRepo.ts',
|
|
parameterTypes: ['String'],
|
|
parameterCount: 1,
|
|
},
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IRepo')}->${ifaceFind1}`),
|
|
sourceId: generateId('Interface', 'IRepo'),
|
|
targetId: ifaceFind1,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const ifaceFind2 = generateId('Method', 'IRepo.find#2');
|
|
graph.addNode({
|
|
id: ifaceFind2,
|
|
label: 'Method',
|
|
properties: {
|
|
name: 'find',
|
|
filePath: 'src/IRepo.ts',
|
|
parameterTypes: ['String', 'int'],
|
|
parameterCount: 2,
|
|
},
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IRepo')}->${ifaceFind2}`),
|
|
sourceId: generateId('Interface', 'IRepo'),
|
|
targetId: ifaceFind2,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const sqlFind1Id = generateId('Method', 'SqlRepo.find#1');
|
|
graph.addNode({
|
|
id: sqlFind1Id,
|
|
label: 'Method',
|
|
properties: {
|
|
name: 'find',
|
|
filePath: 'src/SqlRepo.ts',
|
|
parameterTypes: ['String'],
|
|
parameterCount: 1,
|
|
},
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'SqlRepo')}->${sqlFind1Id}`),
|
|
sourceId: generateId('Class', 'SqlRepo'),
|
|
targetId: sqlFind1Id,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const sqlFind2Id = generateId('Method', 'SqlRepo.find#2');
|
|
graph.addNode({
|
|
id: sqlFind2Id,
|
|
label: 'Method',
|
|
properties: {
|
|
name: 'find',
|
|
filePath: 'src/SqlRepo.ts',
|
|
parameterTypes: ['String', 'int'],
|
|
parameterCount: 2,
|
|
},
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'SqlRepo')}->${sqlFind2Id}`),
|
|
sourceId: generateId('Class', 'SqlRepo'),
|
|
targetId: sqlFind2Id,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(2);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
expect(edges).toHaveLength(2);
|
|
// find(String) → find(String) and find(String, int) → find(String, int)
|
|
const edge1 = edges.find((e) => e.targetId === ifaceFind1);
|
|
const edge2 = edges.find((e) => e.targetId === ifaceFind2);
|
|
expect(edge1).toBeDefined();
|
|
expect(edge1!.sourceId).toBe(sqlFind1Id);
|
|
expect(edge2).toBeDefined();
|
|
expect(edge2!.sourceId).toBe(sqlFind2Id);
|
|
});
|
|
|
|
it('includes default interface methods (not just abstract)', () => {
|
|
// Java 8 default method: IFoo { bar() } <-- Baz { bar() }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IFoo', 'java', 'Interface');
|
|
addClass(graph, 'Baz', 'java');
|
|
addImplements(graph, 'Baz', 'IFoo');
|
|
// Default method (has body, not abstract) — should still get METHOD_IMPLEMENTS
|
|
addMethod(graph, 'IFoo', 'bar', 'Interface');
|
|
addMethod(graph, 'Baz', 'bar');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
});
|
|
|
|
it('does not emit METHOD_IMPLEMENTS for class extending another class', () => {
|
|
// Animal { speak() } <-- Dog { speak() } — EXTENDS, not IMPLEMENTS
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Animal', 'java');
|
|
addClass(graph, 'Dog', 'java');
|
|
addExtends(graph, 'Dog', 'Animal');
|
|
addMethod(graph, 'Animal', 'speak');
|
|
addMethod(graph, 'Dog', 'speak');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
|
|
it('does not emit METHOD_IMPLEMENTS when class has no matching method', () => {
|
|
// IAnimal { speak() } <-- Dog { bark() } — no name match
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IAnimal', 'java', 'Interface');
|
|
addClass(graph, 'Dog', 'java');
|
|
addImplements(graph, 'Dog', 'IAnimal');
|
|
addMethod(graph, 'IAnimal', 'speak', 'Interface');
|
|
addMethod(graph, 'Dog', 'bark');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
|
|
it('skips Property nodes on interface', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IFoo', 'csharp', 'Interface');
|
|
addClass(graph, 'Bar', 'csharp');
|
|
addImplements(graph, 'Bar', 'IFoo');
|
|
|
|
// Add a Property to the interface (not a Method)
|
|
const propId = generateId('Property', 'IFoo.name');
|
|
graph.addNode({
|
|
id: propId,
|
|
label: 'Property',
|
|
properties: { name: 'name', filePath: 'src/IFoo.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IFoo')}->${propId}`),
|
|
sourceId: generateId('Interface', 'IFoo'),
|
|
targetId: propId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
addMethod(graph, 'Bar', 'name');
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
|
|
describe('METHOD_IMPLEMENTS transitive ancestors', () => {
|
|
it('transitive interface chain: C.foo links to both B.foo and A.foo', () => {
|
|
// A (Interface) has foo, B (Interface) has foo extends A, C (Class) implements B
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'java', 'Interface');
|
|
addClass(graph, 'B', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java');
|
|
|
|
addInterfaceExtends(graph, 'B', 'A');
|
|
addImplements(graph, 'C', 'B');
|
|
|
|
const aFoo = addMethod(graph, 'A', 'foo', 'Interface');
|
|
const bFoo = addMethod(graph, 'B', 'foo', 'Interface');
|
|
addMethod(graph, 'C', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
// C.foo should link to both B.foo and A.foo
|
|
expect(edges.some((e) => e.targetId === bFoo)).toBe(true);
|
|
expect(edges.some((e) => e.targetId === aFoo)).toBe(true);
|
|
expect(result.methodImplementsEdges).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('inherited contract method only on grandparent: C.bar links to A.bar', () => {
|
|
// A (Interface) has bar, B (Interface) extends A but has NO bar, C implements B
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'java', 'Interface');
|
|
addClass(graph, 'B', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java');
|
|
|
|
addInterfaceExtends(graph, 'B', 'A');
|
|
addImplements(graph, 'C', 'B');
|
|
|
|
const aBar = addMethod(graph, 'A', 'bar', 'Interface');
|
|
// B has no bar method
|
|
addMethod(graph, 'C', 'bar');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
// C.bar should link to A.bar even though A is not a direct parent
|
|
expect(edges.some((e) => e.targetId === aBar)).toBe(true);
|
|
expect(result.methodImplementsEdges).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('diamond deduplication: E.foo gets exactly one edge to A.foo', () => {
|
|
// A (Interface) has foo
|
|
// B (Interface) has foo, extends A
|
|
// D (Interface) has foo, extends A
|
|
// E (Class) implements B and D
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'java', 'Interface');
|
|
addClass(graph, 'B', 'java', 'Interface');
|
|
addClass(graph, 'D', 'java', 'Interface');
|
|
addClass(graph, 'E', 'java');
|
|
|
|
addInterfaceExtends(graph, 'B', 'A');
|
|
addInterfaceExtends(graph, 'D', 'A');
|
|
addImplements(graph, 'E', 'B');
|
|
addImplements(graph, 'E', 'D');
|
|
|
|
const aFoo = addMethod(graph, 'A', 'foo', 'Interface');
|
|
const bFoo = addMethod(graph, 'B', 'foo', 'Interface');
|
|
const dFoo = addMethod(graph, 'D', 'foo', 'Interface');
|
|
addMethod(graph, 'E', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const eFoo = generateId('Method', 'E.foo#0');
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
// Filter to only edges FROM E.foo
|
|
const eFooEdges = edges.filter((e) => e.sourceId === eFoo);
|
|
|
|
// E.foo should link to B.foo, D.foo, and exactly ONE A.foo (deduplicated)
|
|
expect(eFooEdges.filter((e) => e.targetId === bFoo)).toHaveLength(1);
|
|
expect(eFooEdges.filter((e) => e.targetId === dFoo)).toHaveLength(1);
|
|
expect(eFooEdges.filter((e) => e.targetId === aFoo)).toHaveLength(1);
|
|
// Total from E.foo: 3 edges (B.foo + D.foo + A.foo), not 4
|
|
expect(eFooEdges).toHaveLength(3);
|
|
});
|
|
|
|
it('no transitive through class-only chain', () => {
|
|
// A (Class) has foo, B (Class) extends A has foo, C (Class) extends B has foo
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'java');
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'C', 'java');
|
|
|
|
addExtends(graph, 'B', 'A');
|
|
addExtends(graph, 'C', 'B');
|
|
|
|
addMethod(graph, 'A', 'foo');
|
|
addMethod(graph, 'B', 'foo');
|
|
addMethod(graph, 'C', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
// All class-extends, no interface involved → 0 METHOD_IMPLEMENTS edges
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('is queryable via MATCH pattern', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IRepo', 'typescript', 'Interface');
|
|
addClass(graph, 'SqlRepo', 'typescript');
|
|
addImplements(graph, 'SqlRepo', 'IRepo');
|
|
addMethod(graph, 'IRepo', 'fetch', 'Interface');
|
|
const concreteId = addMethod(graph, 'SqlRepo', 'fetch');
|
|
|
|
computeMRO(graph);
|
|
|
|
// Simulate MATCH (m)-[:METHOD_IMPLEMENTS]->(i) RETURN m
|
|
const implementingMethods: string[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') {
|
|
implementingMethods.push(rel.sourceId);
|
|
}
|
|
});
|
|
expect(implementingMethods).toContain(concreteId);
|
|
});
|
|
|
|
describe('METHOD_IMPLEMENTS inherited + arity matching', () => {
|
|
it('inherited implementation: Base.foo satisfies I.foo when C has no own foo', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Base', 'java');
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java');
|
|
|
|
addExtends(graph, 'C', 'Base');
|
|
addImplements(graph, 'C', 'I');
|
|
|
|
const baseFoo = addMethod(graph, 'Base', 'foo');
|
|
const iFoo = addMethod(graph, 'I', 'foo', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(baseFoo);
|
|
expect(edges[0].targetId).toBe(iFoo);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
});
|
|
|
|
it('class has own method — no inherited lookup needed', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'Base2', 'java');
|
|
addClass(graph, 'I2', 'java', 'Interface');
|
|
addClass(graph, 'C2', 'java');
|
|
|
|
addExtends(graph, 'C2', 'Base2');
|
|
addImplements(graph, 'C2', 'I2');
|
|
|
|
const baseFoo = addMethod(graph, 'Base2', 'foo');
|
|
const iFoo = addMethod(graph, 'I2', 'foo', 'Interface');
|
|
const cFoo = addMethod(graph, 'C2', 'foo');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
// Should use C2.foo, not Base2.foo
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cFoo);
|
|
expect(edges[0].targetId).toBe(iFoo);
|
|
});
|
|
|
|
it('deep inheritance chain: GrandBase.foo satisfies I.foo', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'GrandBase', 'java');
|
|
addClass(graph, 'Base3', 'java');
|
|
addClass(graph, 'I3', 'java', 'Interface');
|
|
addClass(graph, 'C3', 'java');
|
|
|
|
addExtends(graph, 'Base3', 'GrandBase');
|
|
addExtends(graph, 'C3', 'Base3');
|
|
addImplements(graph, 'C3', 'I3');
|
|
|
|
const grandFoo = addMethod(graph, 'GrandBase', 'foo');
|
|
// Base3 has NO foo
|
|
const iFoo = addMethod(graph, 'I3', 'foo', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges: any[] = [];
|
|
graph.forEachRelationship((rel) => {
|
|
if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel);
|
|
});
|
|
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(grandFoo);
|
|
expect(edges[0].targetId).toBe(iFoo);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
});
|
|
|
|
it('arity mismatch prevents false match', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IArity', 'java', 'Interface');
|
|
addClass(graph, 'CArity', 'java');
|
|
addImplements(graph, 'CArity', 'IArity');
|
|
|
|
// Interface method: parameterCount=2, no parameterTypes
|
|
const iMethodId = generateId('Method', 'IArity.process#2');
|
|
graph.addNode({
|
|
id: iMethodId,
|
|
label: 'Method',
|
|
properties: { name: 'process', filePath: 'src/IArity.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IArity')}->${iMethodId}`),
|
|
sourceId: generateId('Interface', 'IArity'),
|
|
targetId: iMethodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
// Class method: parameterCount=3, no parameterTypes
|
|
const cMethodId = generateId('Method', 'CArity.process#3');
|
|
graph.addNode({
|
|
id: cMethodId,
|
|
label: 'Method',
|
|
properties: { name: 'process', filePath: 'src/CArity.ts', parameterCount: 3 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CArity')}->${cMethodId}`),
|
|
sourceId: generateId('Class', 'CArity'),
|
|
targetId: cMethodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
|
|
it('arity match when types missing', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IArityOk', 'java', 'Interface');
|
|
addClass(graph, 'CArityOk', 'java');
|
|
addImplements(graph, 'CArityOk', 'IArityOk');
|
|
|
|
// Interface method: parameterCount=2, no parameterTypes
|
|
const iMethodId = generateId('Method', 'IArityOk.process#2');
|
|
graph.addNode({
|
|
id: iMethodId,
|
|
label: 'Method',
|
|
properties: { name: 'process', filePath: 'src/IArityOk.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IArityOk')}->${iMethodId}`),
|
|
sourceId: generateId('Interface', 'IArityOk'),
|
|
targetId: iMethodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
// Class method: parameterCount=2, no parameterTypes
|
|
const cMethodId = generateId('Method', 'CArityOk.process#2');
|
|
graph.addNode({
|
|
id: cMethodId,
|
|
label: 'Method',
|
|
properties: { name: 'process', filePath: 'src/CArityOk.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CArityOk')}->${cMethodId}`),
|
|
sourceId: generateId('Class', 'CArityOk'),
|
|
targetId: cMethodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(1);
|
|
});
|
|
|
|
it('multiple same-arity candidates = ambiguous, no edge emitted', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IAmbig', 'java', 'Interface');
|
|
addClass(graph, 'CAmbig', 'java');
|
|
addImplements(graph, 'CAmbig', 'IAmbig');
|
|
|
|
// Interface method: parameterCount=1, no parameterTypes
|
|
const iMethodId = generateId('Method', 'IAmbig.handle#1');
|
|
graph.addNode({
|
|
id: iMethodId,
|
|
label: 'Method',
|
|
properties: { name: 'handle', filePath: 'src/IAmbig.ts', parameterCount: 1 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IAmbig')}->${iMethodId}`),
|
|
sourceId: generateId('Interface', 'IAmbig'),
|
|
targetId: iMethodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
// Two class methods named handle, both with parameterCount=1
|
|
const cMethod1 = generateId('Method', 'CAmbig.handle.1#1');
|
|
graph.addNode({
|
|
id: cMethod1,
|
|
label: 'Method',
|
|
properties: { name: 'handle', filePath: 'src/CAmbig.ts', parameterCount: 1 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CAmbig')}->${cMethod1}`),
|
|
sourceId: generateId('Class', 'CAmbig'),
|
|
targetId: cMethod1,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const cMethod2 = generateId('Method', 'CAmbig.handle.2#1');
|
|
graph.addNode({
|
|
id: cMethod2,
|
|
label: 'Method',
|
|
properties: { name: 'handle', filePath: 'src/CAmbig.ts', parameterCount: 1 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CAmbig')}->${cMethod2}`),
|
|
sourceId: generateId('Class', 'CAmbig'),
|
|
targetId: cMethod2,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
expect(result.methodImplementsEdges).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---- findInheritedMethod ambiguity detection ------------------------------
|
|
describe('findInheritedMethod ambiguity', () => {
|
|
it('returns null when two EXTENDS parents both provide matching method', () => {
|
|
// I { foo() }, B { foo() }, M { foo() }, C extends B + M, C implements I
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'cpp', 'Interface');
|
|
addClass(graph, 'B', 'cpp');
|
|
addClass(graph, 'M', 'cpp');
|
|
addClass(graph, 'C', 'cpp');
|
|
addImplements(graph, 'C', 'I');
|
|
addExtends(graph, 'C', 'B');
|
|
addExtends(graph, 'C', 'M');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
addMethod(graph, 'B', 'foo');
|
|
addMethod(graph, 'M', 'foo');
|
|
// C has NO own foo — must walk EXTENDS chain
|
|
|
|
const result = computeMRO(graph);
|
|
// Ambiguous: B.foo and M.foo both match — no METHOD_IMPLEMENTS edge
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const fooEdges = mi.filter((e) => graph.getNode(e.targetId)?.properties.name === 'foo');
|
|
expect(fooEdges).toHaveLength(0);
|
|
});
|
|
|
|
it('diamond dedup: same method via two paths is NOT ambiguous', () => {
|
|
// I { foo() }, GrandBase { foo() }, B extends GrandBase, M extends GrandBase
|
|
// C extends B + M, C implements I
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'cpp', 'Interface');
|
|
addClass(graph, 'GrandBase', 'cpp');
|
|
addClass(graph, 'B', 'cpp');
|
|
addClass(graph, 'M', 'cpp');
|
|
addClass(graph, 'C', 'cpp');
|
|
addImplements(graph, 'C', 'I');
|
|
addExtends(graph, 'C', 'B');
|
|
addExtends(graph, 'C', 'M');
|
|
addExtends(graph, 'B', 'GrandBase');
|
|
addExtends(graph, 'M', 'GrandBase');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
const gbFoo = addMethod(graph, 'GrandBase', 'foo');
|
|
// B and M have NO own foo — both inherit from GrandBase
|
|
|
|
const result = computeMRO(graph);
|
|
// Not ambiguous: same GrandBase.foo via both paths
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const fooEdge = mi.find((e) => e.sourceId === gbFoo);
|
|
expect(fooEdge).toBeDefined();
|
|
});
|
|
|
|
it('C extends B extends A, B and A both have foo → returns B.foo (nearest)', () => {
|
|
// I { foo() }, A { foo() }, B extends A { foo() }, C extends B implements I { no foo }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'A', 'java');
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'C', 'java');
|
|
addImplements(graph, 'C', 'I');
|
|
addExtends(graph, 'C', 'B');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
addMethod(graph, 'A', 'foo');
|
|
const bFoo = addMethod(graph, 'B', 'foo');
|
|
// C has NO own foo — nearest is B.foo at depth 1
|
|
|
|
computeMRO(graph);
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const fooEdge = mi.find((e) => e.sourceId === bFoo);
|
|
expect(fooEdge).toBeDefined();
|
|
// A.foo should NOT be reached
|
|
const aFooId = generateId('Method', 'A.foo#0');
|
|
const aFooEdge = mi.find((e) => e.sourceId === aFooId);
|
|
expect(aFooEdge).toBeUndefined();
|
|
});
|
|
|
|
it('C extends B extends A, only A has foo → returns A.foo (single match at depth 2)', () => {
|
|
// I { foo() }, A { foo() }, B extends A { no foo }, C extends B implements I { no foo }
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'A', 'java');
|
|
addClass(graph, 'B', 'java');
|
|
addClass(graph, 'C', 'java');
|
|
addImplements(graph, 'C', 'I');
|
|
addExtends(graph, 'C', 'B');
|
|
addExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
const aFoo = addMethod(graph, 'A', 'foo');
|
|
// B has NO foo, C has NO foo — only A.foo at depth 2
|
|
|
|
computeMRO(graph);
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const fooEdge = mi.find((e) => e.sourceId === aFoo);
|
|
expect(fooEdge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---- METHOD_IMPLEMENTS concrete-source guard ----------------------------
|
|
describe('METHOD_IMPLEMENTS concrete-source guard', () => {
|
|
it('interface B extends interface A, B redeclares foo → 0 METHOD_IMPLEMENTS', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'A', 'java', 'Interface');
|
|
addClass(graph, 'B', 'java', 'Interface');
|
|
addInterfaceExtends(graph, 'B', 'A');
|
|
addMethod(graph, 'A', 'foo', 'Interface');
|
|
addMethod(graph, 'B', 'foo', 'Interface');
|
|
|
|
computeMRO(graph);
|
|
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(mi).toHaveLength(0);
|
|
});
|
|
|
|
it('abstract class C implements I, C has abstract foo → 0 METHOD_IMPLEMENTS for foo', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java', 'Class');
|
|
addImplements(graph, 'C', 'I');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
|
|
// Add abstract method manually with isAbstract flag
|
|
const classId = generateId('Class', 'C');
|
|
const methodId = generateId('Method', 'C.foo#0');
|
|
graph.addNode({
|
|
id: methodId,
|
|
label: 'Method',
|
|
properties: { name: 'foo', filePath: 'src/C.ts', isAbstract: true, parameterCount: 0 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${classId}->${methodId}`),
|
|
sourceId: classId,
|
|
targetId: methodId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
computeMRO(graph);
|
|
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(mi).toHaveLength(0);
|
|
});
|
|
|
|
it('abstract class C implements I, C has concrete bar → 1 METHOD_IMPLEMENTS for bar', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java', 'Class');
|
|
addImplements(graph, 'C', 'I');
|
|
addMethod(graph, 'I', 'bar', 'Interface');
|
|
const cBar = addMethod(graph, 'C', 'bar');
|
|
|
|
computeMRO(graph);
|
|
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(mi).toHaveLength(1);
|
|
expect(mi[0].sourceId).toBe(cBar);
|
|
});
|
|
|
|
it('concrete class implements interface → 1 METHOD_IMPLEMENTS (regression)', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I', 'java', 'Interface');
|
|
addClass(graph, 'C', 'java', 'Class');
|
|
addImplements(graph, 'C', 'I');
|
|
addMethod(graph, 'I', 'foo', 'Interface');
|
|
const cFoo = addMethod(graph, 'C', 'foo');
|
|
|
|
computeMRO(graph);
|
|
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(mi).toHaveLength(1);
|
|
expect(mi[0].sourceId).toBe(cFoo);
|
|
});
|
|
});
|
|
|
|
describe('default interface method resolution', () => {
|
|
it('interface default method satisfies grandparent interface contract', () => {
|
|
// I1 has abstract bar, I2 extends I1 and provides concrete bar,
|
|
// C implements I2 with no own bar → edge from I2.bar → I1.bar
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I1Def', 'java', 'Interface');
|
|
addClass(graph, 'I2Def', 'java', 'Interface');
|
|
addClass(graph, 'CDef', 'java');
|
|
|
|
addInterfaceExtends(graph, 'I2Def', 'I1Def');
|
|
addImplements(graph, 'CDef', 'I2Def');
|
|
|
|
const i1Bar = addMethod(graph, 'I1Def', 'bar', 'Interface', undefined, { isAbstract: true });
|
|
const i2Bar = addMethod(graph, 'I2Def', 'bar', 'Interface', undefined, {
|
|
isAbstract: false,
|
|
});
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(i2Bar);
|
|
expect(edges[0].targetId).toBe(i1Bar);
|
|
});
|
|
|
|
it('own method takes priority over interface default', () => {
|
|
// I has concrete default bar, C implements I and has own bar
|
|
// → edge from C.bar → I.bar (own method wins, no IMPLEMENTS fallback needed)
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IOwn', 'java', 'Interface');
|
|
addClass(graph, 'COwn', 'java');
|
|
|
|
addImplements(graph, 'COwn', 'IOwn');
|
|
|
|
const iBar = addMethod(graph, 'IOwn', 'bar', 'Interface', undefined, { isAbstract: false });
|
|
const cBar = addMethod(graph, 'COwn', 'bar');
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cBar);
|
|
expect(edges[0].targetId).toBe(iBar);
|
|
});
|
|
|
|
it('transitive interface default: C implements I2, I2 extends I1, I1 has abstract bar, I2 has default bar → I2.bar satisfies I1.bar', () => {
|
|
// I1 (Interface) has abstract bar
|
|
// I2 (Interface) extends I1, has concrete default bar
|
|
// C (Class) implements I2, has NO bar
|
|
// The main emitter processes CImpl's ancestor I1 (transitive via I2).
|
|
// I1.bar is abstract → CImpl has no own bar → findInheritedMethod runs.
|
|
// EXTENDS BFS: nothing. IMPLEMENTS BFS: walks I2 → finds concrete I2.bar.
|
|
// Edge: I2.bar → I1.bar
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'I1', 'java', 'Interface');
|
|
addClass(graph, 'I2', 'java', 'Interface');
|
|
addClass(graph, 'CImpl', 'java');
|
|
|
|
// I1 has abstract bar
|
|
const i1Bar = addMethod(graph, 'I1', 'bar', 'Interface', undefined, { isAbstract: true });
|
|
// I2 has concrete default bar
|
|
const i2Bar = addMethod(graph, 'I2', 'bar', 'Interface');
|
|
|
|
// I2 extends I1
|
|
const i2Id = generateId('Interface', 'I2');
|
|
const i1Id = generateId('Interface', 'I1');
|
|
graph.addRelationship({
|
|
id: generateId('EXTENDS', `${i2Id}->${i1Id}`),
|
|
sourceId: i2Id,
|
|
targetId: i1Id,
|
|
type: 'EXTENDS',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
// CImpl implements I2
|
|
addImplements(graph, 'CImpl', 'I2');
|
|
|
|
const result = computeMRO(graph);
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
// I2.bar (concrete default) satisfies I1.bar (abstract contract)
|
|
const barEdge = mi.find((e) => e.targetId === i1Bar && e.sourceId === i2Bar);
|
|
expect(barEdge).toBeDefined();
|
|
});
|
|
|
|
it('EXTENDS method takes priority over interface default', () => {
|
|
// I has concrete default foo, Base has concrete foo,
|
|
// C extends Base and implements I with no own foo
|
|
// → edge from Base.foo → I.foo (EXTENDS wins over IMPLEMENTS default)
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IExtPri', 'java', 'Interface');
|
|
addClass(graph, 'BaseExtPri', 'java');
|
|
addClass(graph, 'CExtPri', 'java');
|
|
|
|
addExtends(graph, 'CExtPri', 'BaseExtPri');
|
|
addImplements(graph, 'CExtPri', 'IExtPri');
|
|
|
|
const iFoo = addMethod(graph, 'IExtPri', 'foo', 'Interface', undefined, {
|
|
isAbstract: false,
|
|
});
|
|
const baseFoo = addMethod(graph, 'BaseExtPri', 'foo');
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(baseFoo);
|
|
expect(edges[0].targetId).toBe(iFoo);
|
|
});
|
|
|
|
it('Dart implements Class — does NOT inherit concrete method bodies', () => {
|
|
// Dart: class C implements AbstractBase (labeled Class, not Interface)
|
|
// AbstractBase has concrete method foo
|
|
// C has NO foo — but Dart implements does NOT inherit bodies
|
|
// → 0 METHOD_IMPLEMENTS edges from the IMPLEMENTS fallback
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'AbstractBase', 'dart'); // Class label, not Interface
|
|
addClass(graph, 'DartImpl', 'dart');
|
|
|
|
// AbstractBase has concrete foo
|
|
addMethod(graph, 'AbstractBase', 'foo');
|
|
|
|
// DartImpl implements AbstractBase (IMPLEMENTS edge to a Class)
|
|
addImplements(graph, 'DartImpl', 'AbstractBase', 'Class', 'Interface');
|
|
// But we need AbstractBase to be a Class, not Interface — fix the label
|
|
// Actually addImplements creates the edge, but AbstractBase was added as Class.
|
|
// The IMPLEMENTS edge target needs to match the actual node ID.
|
|
// Let's do this manually:
|
|
const dartImplId = generateId('Class', 'DartImpl');
|
|
const absBaseId = generateId('Class', 'AbstractBase');
|
|
graph.addRelationship({
|
|
id: generateId('IMPLEMENTS', `${dartImplId}->${absBaseId}`),
|
|
sourceId: dartImplId,
|
|
targetId: absBaseId,
|
|
type: 'IMPLEMENTS',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
computeMRO(graph);
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
// No edges — IMPLEMENTS fallback skips Class-labeled parents
|
|
expect(mi).toHaveLength(0);
|
|
});
|
|
|
|
it('Interface default still works after Dart label gate', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IDefault', 'java', 'Interface');
|
|
addClass(graph, 'Impl', 'java');
|
|
addImplements(graph, 'Impl', 'IDefault');
|
|
// IDefault has abstract contract method
|
|
const iFoo = addMethod(graph, 'IDefault', 'foo', 'Interface', undefined, {
|
|
isAbstract: true,
|
|
});
|
|
// IDefault also has concrete default bar
|
|
const iBar = addMethod(graph, 'IDefault', 'bar', 'Interface');
|
|
// Impl has foo but not bar
|
|
addMethod(graph, 'Impl', 'foo');
|
|
|
|
computeMRO(graph);
|
|
const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
// foo: own method matches → edge from Impl.foo → IDefault.foo
|
|
const fooEdge = mi.find((e) => e.targetId === iFoo);
|
|
expect(fooEdge).toBeDefined();
|
|
// bar: no own method, IMPLEMENTS fallback finds IDefault.bar (Interface label OK)
|
|
const barEdge = mi.find((e) => e.sourceId === iBar && e.targetId === iBar);
|
|
// Actually bar is the same method — it's the default implementation satisfying itself.
|
|
// The emitter processes IDefault.bar as an ancestor method, Impl has no bar,
|
|
// findInheritedMethod runs, walks IMPLEMENTS → finds IDefault.bar (non-abstract).
|
|
// But excludeMethodId = ancestorMethodId = iBar → skipped to prevent self-edge!
|
|
// So no bar edge. This is correct — the default satisfies the contract inherently.
|
|
});
|
|
});
|
|
|
|
describe('METHOD_IMPLEMENTS confidence tiering', () => {
|
|
it('fully-typed match gets confidence 1.0', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'ITyped', 'java', 'Interface');
|
|
addClass(graph, 'CTyped', 'java');
|
|
addImplements(graph, 'CTyped', 'ITyped');
|
|
|
|
const iFoo = addMethod(graph, 'ITyped', 'foo', 'Interface', ['int', 'String']);
|
|
const cFoo = addMethod(graph, 'CTyped', 'foo', 'Class', ['int', 'String']);
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cFoo);
|
|
expect(edges[0].targetId).toBe(iFoo);
|
|
expect(edges[0].confidence).toBe(1.0);
|
|
});
|
|
|
|
it('arity-only match (both have parameterCount, no types) gets confidence 1.0', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IArity', 'java', 'Interface');
|
|
addClass(graph, 'CArity', 'java');
|
|
addImplements(graph, 'CArity', 'IArity');
|
|
|
|
// Manually add methods with parameterCount but no parameterTypes
|
|
const iBarId = generateId('Method', 'IArity.bar#2');
|
|
graph.addNode({
|
|
id: iBarId,
|
|
label: 'Method',
|
|
properties: { name: 'bar', filePath: 'src/IArity.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IArity')}->${iBarId}`),
|
|
sourceId: generateId('Interface', 'IArity'),
|
|
targetId: iBarId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const cBarId = generateId('Method', 'CArity.bar#2');
|
|
graph.addNode({
|
|
id: cBarId,
|
|
label: 'Method',
|
|
properties: { name: 'bar', filePath: 'src/CArity.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CArity')}->${cBarId}`),
|
|
sourceId: generateId('Class', 'CArity'),
|
|
targetId: cBarId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cBarId);
|
|
expect(edges[0].targetId).toBe(iBarId);
|
|
expect(edges[0].confidence).toBe(1.0);
|
|
});
|
|
|
|
it('lenient match (no types, no parameterCount on both sides) gets confidence 0.7', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'ILenient', 'java', 'Interface');
|
|
addClass(graph, 'CLenient', 'java');
|
|
addImplements(graph, 'CLenient', 'ILenient');
|
|
|
|
// Manually create methods WITHOUT parameterCount to simulate legacy/missing arity
|
|
const iBazId = generateId('Method', 'ILenient.baz');
|
|
graph.addNode({
|
|
id: iBazId,
|
|
label: 'Method',
|
|
properties: { name: 'baz', filePath: 'src/ILenient.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'ILenient')}->${iBazId}`),
|
|
sourceId: generateId('Interface', 'ILenient'),
|
|
targetId: iBazId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
const cBazId = generateId('Method', 'CLenient.baz');
|
|
graph.addNode({
|
|
id: cBazId,
|
|
label: 'Method',
|
|
properties: { name: 'baz', filePath: 'src/CLenient.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CLenient')}->${cBazId}`),
|
|
sourceId: generateId('Class', 'CLenient'),
|
|
targetId: cBazId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cBazId);
|
|
expect(edges[0].targetId).toBe(iBazId);
|
|
expect(edges[0].confidence).toBe(0.7);
|
|
});
|
|
|
|
it('one side has parameterCount, other does not → confidence 0.7', () => {
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IHalf', 'java', 'Interface');
|
|
addClass(graph, 'CHalf', 'java');
|
|
addImplements(graph, 'CHalf', 'IHalf');
|
|
|
|
// Interface method has parameterCount but no parameterTypes
|
|
const iQuxId = generateId('Method', 'IHalf.qux#2');
|
|
graph.addNode({
|
|
id: iQuxId,
|
|
label: 'Method',
|
|
properties: { name: 'qux', filePath: 'src/IHalf.ts', parameterCount: 2 },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Interface', 'IHalf')}->${iQuxId}`),
|
|
sourceId: generateId('Interface', 'IHalf'),
|
|
targetId: iQuxId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
// Class method has neither parameterTypes nor parameterCount (manually constructed)
|
|
const cQuxId = generateId('Method', 'CHalf.qux');
|
|
graph.addNode({
|
|
id: cQuxId,
|
|
label: 'Method',
|
|
properties: { name: 'qux', filePath: 'src/CHalf.ts' },
|
|
});
|
|
graph.addRelationship({
|
|
id: generateId('HAS_METHOD', `${generateId('Class', 'CHalf')}->${cQuxId}`),
|
|
sourceId: generateId('Class', 'CHalf'),
|
|
targetId: cQuxId,
|
|
type: 'HAS_METHOD',
|
|
confidence: 1.0,
|
|
reason: '',
|
|
});
|
|
|
|
computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].sourceId).toBe(cQuxId);
|
|
expect(edges[0].targetId).toBe(iQuxId);
|
|
expect(edges[0].confidence).toBe(0.7);
|
|
});
|
|
});
|
|
|
|
// ---- IMPLEMENTS BFS ambiguity for default methods -------------------------
|
|
describe('IMPLEMENTS BFS ambiguity for default methods', () => {
|
|
it('does not emit METHOD_IMPLEMENTS when two interfaces provide same default method (ambiguous)', () => {
|
|
// IAncestor (Interface) has abstract process()
|
|
// IAlpha (Interface) extends IAncestor, has concrete process()
|
|
// IBeta (Interface) extends IAncestor, has concrete process()
|
|
// CImpl (Class) implements IAlpha, implements IBeta
|
|
// CImpl has NO process() method
|
|
//
|
|
// findInheritedMethod walks IMPLEMENTS BFS and finds process() in BOTH
|
|
// IAlpha and IBeta => ambiguous => null => no METHOD_IMPLEMENTS edge.
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IAncestor', 'java', 'Interface');
|
|
addClass(graph, 'IAlpha', 'java', 'Interface');
|
|
addClass(graph, 'IBeta', 'java', 'Interface');
|
|
addClass(graph, 'CImpl', 'java');
|
|
|
|
addInterfaceExtends(graph, 'IAlpha', 'IAncestor');
|
|
addInterfaceExtends(graph, 'IBeta', 'IAncestor');
|
|
addImplements(graph, 'CImpl', 'IAlpha');
|
|
addImplements(graph, 'CImpl', 'IBeta');
|
|
|
|
addMethod(graph, 'IAncestor', 'process', 'Interface', undefined, { isAbstract: true });
|
|
addMethod(graph, 'IAlpha', 'process', 'Interface');
|
|
addMethod(graph, 'IBeta', 'process', 'Interface');
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
// IAlpha.process -> IAncestor.process and IBeta.process -> IAncestor.process
|
|
// are legitimate edges from sub-interface processing. The ambiguity check
|
|
// ensures that NO additional edge is emitted on behalf of CImpl (which has
|
|
// no own process()). Since CImpl has no methods, no edge should be sourced
|
|
// from a CImpl method. Verify by checking that the only METHOD_IMPLEMENTS
|
|
// edges for process() are the two interface-to-interface ones.
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const processEdges = edges.filter((e) => {
|
|
const target = graph.getNode(e.targetId);
|
|
return target?.properties.name === 'process';
|
|
});
|
|
// IAlpha.process->IAncestor.process and IBeta.process->IAncestor.process
|
|
// are emitted from the sub-interface processing (each concrete method
|
|
// implements the ancestor's abstract method). No additional edge should be
|
|
// emitted on behalf of CImpl because findInheritedMethod returns null
|
|
// (ambiguous: two candidates at the same BFS depth).
|
|
expect(processEdges).toHaveLength(2);
|
|
const alphaProcess = generateId('Method', 'IAlpha.process#0');
|
|
const betaProcess = generateId('Method', 'IBeta.process#0');
|
|
const sourceIds = processEdges.map((e) => e.sourceId).sort();
|
|
expect(sourceIds).toEqual([alphaProcess, betaProcess].sort());
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS when only one interface provides the default method (unambiguous)', () => {
|
|
// IAncestor (Interface) has abstract process()
|
|
// IAlpha (Interface) extends IAncestor, has concrete process()
|
|
// IBeta (Interface) extends IAncestor, does NOT have process()
|
|
// CImpl (Class) implements IAlpha, implements IBeta
|
|
// CImpl has NO process() method
|
|
//
|
|
// findInheritedMethod walks IMPLEMENTS BFS and finds process() only in
|
|
// IAlpha => unambiguous => emits 1 METHOD_IMPLEMENTS edge.
|
|
const graph = createKnowledgeGraph();
|
|
addClass(graph, 'IAncestor', 'java', 'Interface');
|
|
addClass(graph, 'IAlpha', 'java', 'Interface');
|
|
addClass(graph, 'IBeta', 'java', 'Interface');
|
|
addClass(graph, 'CImpl', 'java');
|
|
|
|
addInterfaceExtends(graph, 'IAlpha', 'IAncestor');
|
|
addInterfaceExtends(graph, 'IBeta', 'IAncestor');
|
|
addImplements(graph, 'CImpl', 'IAlpha');
|
|
addImplements(graph, 'CImpl', 'IBeta');
|
|
|
|
const ancestorProcess = addMethod(graph, 'IAncestor', 'process', 'Interface', undefined, {
|
|
isAbstract: true,
|
|
});
|
|
const alphaProcess = addMethod(graph, 'IAlpha', 'process', 'Interface');
|
|
// IBeta has no process() method
|
|
|
|
const result = computeMRO(graph);
|
|
|
|
const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS');
|
|
const processEdges = edges.filter((e) => {
|
|
const target = graph.getNode(e.targetId);
|
|
return target?.properties.name === 'process';
|
|
});
|
|
// At minimum, IAlpha.process -> IAncestor.process is emitted (from IAlpha's
|
|
// own processing). CImpl's findInheritedMethod also finds IAlpha.process as
|
|
// the sole unambiguous match, potentially emitting the same edge again.
|
|
expect(processEdges.length).toBeGreaterThanOrEqual(1);
|
|
// Every process edge should point from IAlpha.process to IAncestor.process
|
|
for (const edge of processEdges) {
|
|
expect(edge.sourceId).toBe(alphaProcess);
|
|
expect(edge.targetId).toBe(ancestorProcess);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---- PHM parent-order pinning ------------------------------------------
|
|
//
|
|
// PHM Unit 2 split buildAdjacency's single forEachRelationship into three
|
|
// typed iterations (EXTENDS, IMPLEMENTS, HAS_METHOD). Parent enumeration
|
|
// now runs ALL EXTENDS edges before ANY IMPLEMENTS edges. For classes
|
|
// with parents added in interleaved order, this re-orders `parentMap`
|
|
// and any C3 linearization that consumes it.
|
|
//
|
|
// Python (single EXTENDS model) and Java/C# (resolveCsharpJava partitions
|
|
// by edge type regardless of order) are unaffected in practice. This
|
|
// test pins the new behavior so a future "simplification" back to a
|
|
// single loop would surface as a deliberate change rather than a silent
|
|
// semantic drift.
|
|
describe('PHM: interleaved EXTENDS + IMPLEMENTS parent ordering', () => {
|
|
it('class methods win regardless of the order EXTENDS/IMPLEMENTS edges were added', () => {
|
|
const graph = createKnowledgeGraph();
|
|
// C extends Base (class) AND implements Iface (interface). Edges
|
|
// added in INTERLEAVED order: IMPLEMENTS first, then EXTENDS.
|
|
// Under the old single-loop adjacency, parentMap[C] would be
|
|
// [IfaceId, BaseId]. Under the new grouped adjacency,
|
|
// parentMap[C] is [BaseId, IfaceId] (EXTENDS bucket first).
|
|
//
|
|
// For resolveCsharpJava, class-method-wins is invariant to parent
|
|
// order — both produce the same winner. This test encodes that
|
|
// invariant, guarding the behavioral claim that 'Java/C# are
|
|
// unaffected' in the PHM commit message.
|
|
addClass(graph, 'Base', 'java');
|
|
addClass(graph, 'C', 'java');
|
|
addClass(graph, 'Iface', 'java', 'Interface');
|
|
addMethod(graph, 'Base', 'greet');
|
|
addMethod(graph, 'Iface', 'greet', 'Interface');
|
|
addMethod(graph, 'C', 'greet');
|
|
|
|
// Add IMPLEMENTS BEFORE EXTENDS to exercise interleaving.
|
|
addImplements(graph, 'C', 'Iface');
|
|
addExtends(graph, 'C', 'Base');
|
|
|
|
const result = computeMRO(graph);
|
|
const cId = generateId('Class', 'C');
|
|
const entry = result.entries.find((e) => e.classId === cId);
|
|
expect(entry).toBeDefined();
|
|
const mro = entry!.mro;
|
|
|
|
// Grouped iteration yields EXTENDS parents first. This pin fails
|
|
// loudly if a future refactor reverts the typed-bucket iteration
|
|
// to a single full-graph scan and restores insertion-order
|
|
// semantics.
|
|
// Grouped EXTENDS-before-IMPLEMENTS iteration produces this exact
|
|
// MRO for C: [Base, Iface]. A single-loop reversion would yield
|
|
// [Iface, Base] (IMPLEMENTS added first in this test).
|
|
expect(mro).toEqual(['Base', 'Iface']);
|
|
});
|
|
});
|
|
});
|