Skip to content

Commit d687d8f

Browse files
clay-goodclaude
andcommitted
feat(java): capture super() constructor-chain edges in the call graph (#138)
Round-4 Java deep-dive (spring-petclinic + commons-cli + gson). Three agents audited code skeletons/CFG/type-inference, duplicate detection, and the MCP context tools (orient/get_subgraph/get_signatures/...) — all returned clean bills with the full rounds-1-3 regression sweep PASSing. The one genuine gap left was constructor edges. Java `super(...)` explicit constructor invocations were invisible to the call graph (JAVA_CALL_QUERY only matched method_invocation / object_creation), so class-hierarchy constructor chains never showed in impact analysis or fan-in/ out. synthesizeJavaSuperCalls now reads each class's parent from its `extends` clause and emits a constructor-typed edge to the parent class's constructor (constructors are keyed by class simple-name). Deliberately scoped to avoid noise/regressions: - this(...) is omitted — overloaded constructors collapse to one node, so it would only ever be a self-loop. - super(...) to a superclass outside the codebase (e.g. extends RuntimeException) is dropped during resolution rather than turned into an external leaf node, via a callType:'constructor' guard in Strategy 4. Java `new` edges leave callType undefined, so existing behavior is untouched. Verified: commons-cli yields 7 internal super() edges (e.g. AmbiguousOption- Exception -> UnrecognizedOptionException); external superclasses add no node; petclinic (implicit super only) correctly yields 0. Also added an explicit package-info/module-info entity-filter regression test (agent suggestion). Full suite green (3720 passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 133346d commit d687d8f

3 files changed

Lines changed: 133 additions & 0 deletions

File tree

src/core/analyzer/artifact-generator.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,33 @@ describe('AnalysisArtifactGenerator', () => {
307307
}
308308
});
309309

310+
it('excludes package-info/module-info marker files from entities (#138)', async () => {
311+
const files: ScoredFile[] = [
312+
createScoredFile({ name: 'TypeToken.java', path: 'src/main/java/com/acme/reflect/TypeToken.java', directory: 'src/main/java/com/acme/reflect', score: 70 }),
313+
createScoredFile({ name: 'package-info.java', path: 'src/main/java/com/acme/reflect/package-info.java', directory: 'src/main/java/com/acme/reflect', score: 40 }),
314+
createScoredFile({ name: 'module-info.java', path: 'src/main/java/module-info.java', directory: 'src/main/java', score: 40 }),
315+
];
316+
const repoMap = createMockRepoMap({
317+
highValueFiles: files,
318+
allFiles: files,
319+
clusters: {
320+
byDirectory: { 'src/main/java/com/acme/reflect': files },
321+
byDomain: { reflect: files },
322+
byLayer: { presentation: files, business: [], data: [], infrastructure: [] },
323+
},
324+
});
325+
326+
const artifacts = await generateArtifacts(repoMap, createMockDepGraph(), {
327+
rootDir: tempDir,
328+
outputDir,
329+
});
330+
331+
const reflect = artifacts.repoStructure.domains.find(d => d.name === 'reflect');
332+
expect(reflect?.entities).toContain('TypeToken');
333+
expect(reflect?.entities).not.toContain('PackageInfo');
334+
expect(reflect?.entities).not.toContain('ModuleInfo');
335+
});
336+
310337
it('should generate entry points', async () => {
311338
const repoMap = createMockRepoMap();
312339
const depGraph = createMockDepGraph();

src/core/analyzer/call-graph.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,54 @@ public class UserRepository {
644644
expect(nodeNames(result)).toContain('UserRepository');
645645
expect(nodeNames(result)).toContain('init');
646646
});
647+
648+
it('captures super(...) as an edge to the parent class constructor (#138)', async () => {
649+
const builder = new CallGraphBuilder();
650+
const result = await builder.build([
651+
{ path: 'Person.java', language: 'Java', content: `
652+
public class Person {
653+
public Person(String name) {}
654+
}
655+
` },
656+
{ path: 'Owner.java', language: 'Java', content: `
657+
public class Owner extends Person {
658+
public Owner(String name, int age) { super(name); }
659+
}
660+
` },
661+
]);
662+
663+
// Constructor nodes are keyed by the class name; super(name) → Person's ctor.
664+
const ctorEdges = result.edges.filter(e => e.callType === 'constructor');
665+
expect(ctorEdges).toHaveLength(1);
666+
expect(edgePairs(result)).toContain('Owner→Person');
667+
});
668+
669+
it('omits this(...) self-delegation (overloads collapse to one node) (#138)', async () => {
670+
const builder = new CallGraphBuilder();
671+
const result = await builder.build([{
672+
path: 'Point.java', language: 'Java', content: `
673+
public class Point {
674+
public Point(int x, int y) {}
675+
public Point() { this(0, 0); }
676+
}
677+
` }]);
678+
// No constructor edge: this(...) would only be a self-loop on the collapsed node.
679+
expect(result.edges.filter(e => e.callType === 'constructor')).toHaveLength(0);
680+
});
681+
682+
it('drops super(...) to an external superclass without creating an external node (#138)', async () => {
683+
const builder = new CallGraphBuilder();
684+
const result = await builder.build([{
685+
path: 'FooException.java', language: 'Java', content: `
686+
public class FooException extends RuntimeException {
687+
public FooException(String m) { super(m); }
688+
}
689+
` }]);
690+
// The parent (RuntimeException) is not in the codebase → no edge, no external node.
691+
expect(result.edges.filter(e => e.callType === 'constructor')).toHaveLength(0);
692+
expect(nodeNames(result)).not.toContain('RuntimeException');
693+
expect(Array.from(result.nodes.keys()).some(id => id.includes('RuntimeException'))).toBe(false);
694+
});
647695
});
648696

649697
// ---------------------------------------------------------------------------

src/core/analyzer/call-graph.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,57 @@ function dedupeOverlappingCalls(
16371637
return rawEdges;
16381638
}
16391639

1640+
/**
1641+
* Synthesize call edges for Java `super(...)` explicit constructor invocations.
1642+
*
1643+
* A `super(...)` call targets the PARENT class's constructor, which is keyed in
1644+
* the graph by the parent's simple name (constructors are named after their
1645+
* class). We read each class's parent from its `extends` clause and emit a
1646+
* constructor-typed edge from the enclosing constructor to the parent name.
1647+
*
1648+
* `this(...)` is intentionally skipped: overloaded constructors collapse to a
1649+
* single node, so a `this(...)` edge would only ever be a self-loop.
1650+
*
1651+
* Edges whose parent does not resolve to an internal node (e.g. `extends
1652+
* RuntimeException`) are dropped during resolution — the `callType: 'constructor'`
1653+
* marker tells Strategy 4 not to manufacture an external leaf node for them, so
1654+
* the external-node set stays clean.
1655+
*/
1656+
function synthesizeJavaSuperCalls(
1657+
root: Parser.SyntaxNode,
1658+
nodes: FunctionNode[],
1659+
lang: unknown
1660+
): RawEdge[] {
1661+
// class simple-name → parent simple-name, from `extends` clauses.
1662+
const parentOf = new Map<string, string>();
1663+
const clsQuery = new _NativeQuery!(
1664+
lang as Parser.Language,
1665+
`(class_declaration name: (identifier) @cls (superclass (type_identifier) @parent))`
1666+
);
1667+
for (const m of clsQuery.matches(root)) {
1668+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1669+
const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1670+
if (cls && parent) parentOf.set(cls, parent);
1671+
}
1672+
if (parentOf.size === 0) return [];
1673+
1674+
const out: RawEdge[] = [];
1675+
const ctorQuery = new _NativeQuery!(
1676+
lang as Parser.Language,
1677+
`(explicit_constructor_invocation (super)) @node`
1678+
);
1679+
for (const m of ctorQuery.matches(root)) {
1680+
const node = m.captures.find(c => c.name === 'node')?.node;
1681+
if (!node) continue;
1682+
const caller = findEnclosingFunction(nodes, node.startIndex);
1683+
if (!caller?.className) continue;
1684+
const parent = parentOf.get(caller.className);
1685+
if (!parent) continue;
1686+
out.push({ callerId: caller.id, calleeName: parent, line: node.startPosition.row + 1, callType: 'constructor' });
1687+
}
1688+
return out;
1689+
}
1690+
16401691
async function extractJavaGraph(
16411692
filePath: string,
16421693
content: string
@@ -1704,6 +1755,9 @@ async function extractJavaGraph(
17041755
// preferring the qualified match (it carries the receiver).
17051756
const rawEdges = dedupeOverlappingCalls(callQuery, tree.rootNode, nodes, 'Java');
17061757

1758+
// super(...) constructor-chain edges (this(...) intentionally omitted).
1759+
rawEdges.push(...synthesizeJavaSuperCalls(tree.rootNode, nodes, lang));
1760+
17071761
return { nodes, rawEdges, cfg };
17081762
}
17091763

@@ -4226,6 +4280,10 @@ export class CallGraphBuilder {
42264280
if (!calleeNode && !raw.calleeObject) {
42274281
const candidates = trie.findBySimpleName(raw.calleeName);
42284282
if (candidates.length === 0) {
4283+
// A synthesized super(...) edge whose parent class is not in the
4284+
// analyzed code (e.g. `extends RuntimeException`) is dropped rather
4285+
// than turned into an external leaf — keeps the external-node set clean.
4286+
if (raw.callType === 'constructor') continue;
42294287
// Unresolved bare call — create a synthetic external leaf node
42304288
calleeNode = getOrCreateExternalNode(raw.calleeName, allNodes);
42314289
confidence = 'external';

0 commit comments

Comments
 (0)