Skip to content

Commit 03eb4f4

Browse files
clay-goodclaude
andcommitted
feat(cha): extend hierarchy extraction to Kotlin/PHP/Swift/Scala + harden cross-file resolution
CHA was INERT for Kotlin, PHP, Swift, and Scala — they extract classes but extractClassRelationships had no branch for them (the C# defect, x4: zero inheritance edges -> zero override/dispatch edges). Added a branch for each (Kotlin/Swift via delegation/ inheritance specifiers; PHP splitting base_clause/extends vs class_interface_clause/implements; Scala via extends_clause + a new getScalaParser). Ruby/Go already worked; C++ has a branch. Real-repo dogfood (DesignPatternsPHP, kotlinx.coroutines) confirmed correct edges at scale (PHP 71, Kotlin 157 override edges) AND surfaced two false-positive bugs, both fixed: - Cross-file/cross-namespace same-name collision -> false override edge (two unrelated `Logger`/ `Formatter` interfaces in different PHP namespaces). Fixed in buildClassNodes: when a base name is not same-file AND ambiguous, SKIP rather than global-first-match — false-negatives over false-positives. (FQCN/namespace-aware resolution is the future enhancement.) - Kotlin qualified supertype mis-captured: `Job : CoroutineContext.Element` matched the outer `CoroutineContext` (a phantom from extension-function receivers), fabricating overrides. Fixed by taking the supertype leaf name and skipping qualified (dotted) types; applied to Swift too. Generic supertypes (`Segment<T>`) still resolve to `Segment`. Test-covered the last unproven scenario: override propagation through the DB-backed lazy path (bfsFromDB) — traversed by default, excluded under directResolvedOnly, matching buildAdjacency. CHA hierarchy support now: TS/JS, Python, Java, C++, C#, Ruby, Go, Kotlin, PHP, Swift, Scala. Tests +8; full suite green (3762). Decisions: bf52c392, d5967a48, 9d87726f. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b63359 commit 03eb4f4

5 files changed

Lines changed: 291 additions & 2 deletions

File tree

openspec/changes/archive/add-type-hierarchy-resolved-dispatch/tasks.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,36 @@
6565
> dispatch path matches by name only (arity is enforced on the override path). Tests: +7
6666
> (`type-inference-engine.test.ts` Java/C# `var new T`; `cha.test.ts` cross-file resolution + C#
6767
> override edges). Full suite green (3754).
68+
>
69+
> **Multi-language coverage + dogfood pass (follow-up, same PR #155).** Auditing
70+
> `extractClassRelationships` against the languages OpenLore parses revealed CHA was **inert for
71+
> Kotlin, PHP, Swift, and Scala** — they extract classes but had no hierarchy-extraction branch (the
72+
> exact C# defect, ×4: zero inheritance edges → zero override/dispatch edges). Added a branch for each
73+
> (Kotlin/Swift via delegation/inheritance specifiers, PHP splitting `base_clause` vs
74+
> `class_interface_clause`, Scala via `extends_clause` + a new `getScalaParser`). Ruby and Go already
75+
> worked; C++ has a branch (its abstract-pure-virtual-base case is the documented node boundary).
76+
> Real-repo dogfood (DesignPatternsPHP, kotlinx.coroutines) confirmed correct edges at scale (PHP 71,
77+
> Kotlin 157 override edges) **and** surfaced two false-positive bugs, both fixed:
78+
> - **Cross-file / cross-namespace same-name collision → false override edge.** Two unrelated `Logger`
79+
> (and `Formatter`) interfaces in different PHP namespaces: the child's implementers live in other
80+
> files, so same-file-first missed and the global first-match wired the wrong twin (and *stole* the
81+
> real edge). Fixed language-agnostically in `buildClassNodes`: when a base name is not same-file
82+
> AND is ambiguous (several classes share it across files), **skip** rather than guess —
83+
> false-negatives over false-positives. (Cost: some legitimate reused-name cross-file edges are also
84+
> skipped; namespace/FQCN-aware resolution is the future enhancement, like RTA/VTA pruning.)
85+
> - **Kotlin qualified supertype mis-captured.** `interface Job : CoroutineContext.Element` matched the
86+
> outer segment `CoroutineContext` (a phantom class from extension-function receivers `fun
87+
> CoroutineContext.x()`), synthesizing false `CoroutineContext.x → Job.x` overrides. Fixed by taking
88+
> the supertype's leaf name and **skipping qualified types** (`Outer.Inner`); applied to Swift too
89+
> (identical `user_type` structure, same latent bug). Generic supertypes (`Segment<T>`) still
90+
> resolve correctly to `Segment`.
91+
>
92+
> Also verified the last unproven scenario end-to-end: **override propagation through the DB-backed
93+
> lazy path** — a `bfsFromDB` test (real `EdgeStore`) confirms override edges are traversed by default
94+
> and excluded under `directResolvedOnly`, matching the in-memory `buildAdjacency` path. Tests: +8
95+
> (`cha.test.ts` Kotlin/PHP/Swift/Scala override + ambiguous-cross-file + Kotlin-qualified-supertype;
96+
> `graph.test.ts` bfsFromDB DB-path propagation). Full suite green (3762). Languages with CHA hierarchy
97+
> support: TS/JS, Python, Java, C++, C#, Ruby, Go, Kotlin, PHP, Swift, Scala.
6898
6999
## 1. Confirm the surface is purely additive (no type changes)
70100
- [x] Verify `EdgeConfidence` already includes `'synthesized'` (`call-graph.ts:34`), `CallEdge` already

openspec/specs/analyzer/spec.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5553,6 +5553,24 @@ The system SHALL resolve base-class references to same-file declarations before
55535553

55545554
> Decision recorded: 66e47bb4
55555555
> Date: 2026-06-17
5556+
### Requirement: ExtendChaHierarchyExtractionToKotlinphpswiftscalaWithAmbiguityskipAndQualifiedsupertypeskipGuards
5557+
5558+
The system SHALL extract class-hierarchy relationships for Kotlin, PHP, Swift, and Scala, skipping ambiguous cross-file base names and qualified supertypes to avoid false-positive dispatch edges.
5559+
5560+
> Decision recorded: 9d87726f
5561+
> Date: 2026-06-17
5562+
### Requirement: ExtendChaTypehierarchyExtractionToKotlinPhpSwiftAndScala
5563+
5564+
The system SHALL extract class-hierarchy relationships (extends/implements/mixin edges) from Kotlin, PHP, Swift, and Scala source files using tree-sitter grammars.
5565+
5566+
> Decision recorded: bf52c392
5567+
> Date: 2026-06-17
5568+
### Requirement: AmbiguousCrossfileParentResolutionPrefersFalsenegativeOverFalsepositive
5569+
5570+
The system SHALL skip cross-file parent resolution when the parent name is ambiguous (declared in more than one file), preferring false-negatives over false-positives in inheritance edges.
5571+
5572+
> Decision recorded: d5967a48
5573+
> Date: 2026-06-17
55565574
55575575
## Technical Notes
55585576

@@ -6020,3 +6038,33 @@ Route-matching regexes use character offsets (m.index) that are resolved to line
60206038
Adversarial dogfooding CHA on real OO corpora (java-design-patterns, python-patterns, dotnet/samples) surfaced three defects: (1) global first-match base-class resolution linked unrelated same-named classes across files — fixed by preferring same-file ClassNode resolution before global fallback; (2) type-inference required uppercase declared types, so Java 10+/C# `var x = new T()` locals recovered no type and virtual calls fell to broad cha-name-arity — fixed by adding var-new-T matchers for Java and C#; (3) no C# branch in extractClassRelationships left CHA inert for C# — fixed by adding a C# branch over base_list, splitting base-class vs interface by I<Upper> naming convention.
60216039

60226040
**Consequences:** Same-file base resolution eliminates cross-file name collisions (empty same-file base still falls back to global — rare, documented). var-new-T recovery converts many heuristic cha-name-arity edges into precise type_inference edges (Java cha-name-arity 113→65 on design-patterns slice). C# CHA now functional (0→81 inheritance / 59 override edges on slice). Residual cha-name-arity over-approximation remains for non-locally-recoverable receivers; field-type tracking / RTA-VTA pruning stay out-of-scope per HighPrecisionCHABounds.
6041+
6042+
### Extend CHA hierarchy extraction to Kotlin/PHP/Swift/Scala with ambiguity-skip and qualified-supertype-skip guards
6043+
6044+
**Status:** Approved
6045+
**Date:** 2026-06-17
6046+
**ID:** 9d87726f
6047+
6048+
CHA was inert for Kotlin, PHP, Swift, and Scala because extractClassRelationships had no branch for them, producing zero inheritance and dispatch edges. Added hierarchy-extraction branches for each language (Kotlin/Swift via delegation/inheritance specifiers; PHP distinguishing extends from implements; Scala via extends_clause with a new getScalaParser). Real-repo dogfooding then surfaced two false-positive bugs: (1) cross-file/cross-namespace same-name class collisions wired by the global first-match fallback — fixed by skipping resolution when a base name is non-same-file AND ambiguous; (2) Kotlin/Swift qualified supertypes (Outer.Inner) mis-captured the outer segment as the base — fixed by taking the leaf name and skipping dotted types.
6049+
6050+
**Consequences:** CHA now has hierarchy support for 11 languages (TS/JS, Python, Java, C++, C#, Ruby, Go, Kotlin, PHP, Swift, Scala). The ambiguity-skip is language-agnostic and eliminates cross-file collision false positives everywhere, at the cost of skipping legitimate cross-file override edges whose base name is reused (FQCN/namespace-aware resolution is the future enhancement). Swift/Scala protocol/trait abstract methods without bodies are not extracted as nodes, so only concrete-base hierarchies get edges.
6051+
6052+
### Extend CHA type-hierarchy extraction to Kotlin, PHP, Swift, and Scala
6053+
6054+
**Status:** Approved
6055+
**Date:** 2026-06-17
6056+
**ID:** bf52c392
6057+
6058+
The polymorphic-dispatch (CHA) feature requires class-hierarchy edges for each supported language; adding tree-sitter-based extraction for these four languages broadens the cross-language coverage of resolved virtual-call edges.
6059+
6060+
**Consequences:** New runtime dependency on tree-sitter-scala; each language follows the same pattern (lazy parser init + query-based extraction) so maintenance cost is linear; qualified/nested type names are deliberately skipped to avoid phantom edges.
6061+
6062+
### Ambiguous cross-file parent resolution prefers false-negative over false-positive
6063+
6064+
**Status:** Approved
6065+
**Date:** 2026-06-17
6066+
**ID:** d5967a48
6067+
6068+
When multiple classes share a bare name across different files (e.g. two unrelated `Logger` interfaces in different PHP namespaces), a global first-match would fabricate a false override edge and steal the real one from the correct twin; skipping the resolution entirely is safer for downstream dispatch accuracy.
6069+
6070+
**Consequences:** Some legitimate cross-file inheritance edges will be missed when name collisions exist; this is an acceptable precision-over-recall tradeoff that avoids polluting the call graph with phantom dispatch targets.

src/core/analyzer/call-graph.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ let _swiftParser: Parser | undefined;
459459
let _phpParser: Parser | undefined;
460460
let _csParser: Parser | undefined;
461461
let _ktParser: Parser | undefined;
462+
let _scalaParser: Parser | undefined;
462463
let _exParser: Parser | undefined;
463464

464465
// null = tried and unavailable; undefined = not yet tried
@@ -489,6 +490,7 @@ let _SwiftLanguage: object | undefined;
489490
let _PhpLanguage: object | undefined;
490491
let _CsLanguage: object | undefined;
491492
let _KtLanguage: object | undefined;
493+
let _ScalaLanguage: object | undefined;
492494
let _ExLanguage: object | undefined;
493495

494496
async function getTSParser(): Promise<{ parser: Parser; lang: object } | null> {
@@ -599,6 +601,18 @@ async function getKotlinParser(): Promise<{ parser: Parser; lang: object } | nul
599601
return { parser: _ktParser!, lang: _KtLanguage! };
600602
}
601603

604+
async function getScalaParser(): Promise<{ parser: Parser; lang: object } | null> {
605+
const NP = await loadNativeParser();
606+
if (!NP) return null;
607+
if (!_scalaParser) {
608+
const scalaModule = await import('tree-sitter-scala');
609+
_ScalaLanguage = (scalaModule as { default: object }).default;
610+
_scalaParser = new NP();
611+
_scalaParser.setLanguage(_ScalaLanguage as unknown as Parser.Language);
612+
}
613+
return { parser: _scalaParser!, lang: _ScalaLanguage! };
614+
}
615+
602616
async function getElixirParser(): Promise<{ parser: Parser; lang: object } | null> {
603617
const NP = await loadNativeParser();
604618
if (!NP) return null;
@@ -2739,6 +2753,92 @@ async function extractClassRelationships(
27392753
}
27402754
}
27412755

2756+
} else if (file.language === 'Kotlin') {
2757+
const r = await getKotlinParser();
2758+
if (!r) continue;
2759+
const { parser, lang } = r;
2760+
const tree = (parser as Parser).parse(file.content);
2761+
2762+
// Kotlin `class C : Base(), IFace` — every supertype is a `delegation_specifier`
2763+
// with no syntactic class/interface distinction (a superclass may carry a
2764+
// constructor_invocation). Capture the whole user_type and take its leaf name,
2765+
// SKIPPING qualified types (`Outer.Inner`, e.g. `Job : CoroutineContext.Element`):
2766+
// those resolve to a nested/stdlib type, and matching the outer segment wires the
2767+
// class to a phantom (an extension-function receiver such as `CoroutineContext`).
2768+
for (const declType of ['class_declaration', 'object_declaration', 'interface_declaration']) {
2769+
for (const wrap of ['(user_type) @put', '(constructor_invocation (user_type) @put)']) {
2770+
const Q = `(${declType} (type_identifier) @cls (delegation_specifier ${wrap}))`;
2771+
for (const m of safeQuery(lang, Q, tree.rootNode)) {
2772+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
2773+
const put = m.captures.find(c => c.name === 'put')?.node.text;
2774+
if (!cls || !put) continue;
2775+
const parent = put.replace(/<[\s\S]*$/, '').trim(); // strip generic args
2776+
if (parent.includes('.')) continue; // skip qualified/nested types
2777+
merge(file.path, cls, [parent], []);
2778+
}
2779+
}
2780+
}
2781+
2782+
} else if (file.language === 'PHP') {
2783+
const r = await getPhpParser();
2784+
if (!r) continue;
2785+
const { parser, lang } = r;
2786+
const tree = (parser as Parser).parse(file.content);
2787+
2788+
// PHP distinguishes `extends` (base_clause, one parent) from `implements`
2789+
// (class_interface_clause, many interfaces).
2790+
const EXTENDS_Q = `(class_declaration name: (name) @cls (base_clause (name) @parent))`;
2791+
const IMPLEMENTS_Q = `(class_declaration name: (name) @cls (class_interface_clause (name) @iface))`;
2792+
for (const m of safeQuery(lang, EXTENDS_Q, tree.rootNode)) {
2793+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
2794+
const parent = m.captures.find(c => c.name === 'parent')?.node.text;
2795+
if (cls && parent) merge(file.path, cls, [parent], []);
2796+
}
2797+
for (const m of safeQuery(lang, IMPLEMENTS_Q, tree.rootNode)) {
2798+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
2799+
const iface = m.captures.find(c => c.name === 'iface')?.node.text;
2800+
if (cls && iface) merge(file.path, cls, [], [iface]);
2801+
}
2802+
2803+
} else if (file.language === 'Swift') {
2804+
const r = await getSwiftParser();
2805+
if (!r) continue;
2806+
const { parser, lang } = r;
2807+
const tree = (parser as Parser).parse(file.content);
2808+
2809+
// Swift `class C: Base, Proto` — every supertype/protocol is an
2810+
// `inheritance_specifier` with no syntactic distinction. (class_declaration in
2811+
// this grammar also covers struct/enum/extension.) Take the user_type leaf name
2812+
// and skip qualified types (`Module.Type`) for the same reason as Kotlin.
2813+
for (const declType of ['class_declaration', 'protocol_declaration']) {
2814+
const Q = `(${declType} (type_identifier) @cls (inheritance_specifier (user_type) @put))`;
2815+
for (const m of safeQuery(lang, Q, tree.rootNode)) {
2816+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
2817+
const put = m.captures.find(c => c.name === 'put')?.node.text;
2818+
if (!cls || !put) continue;
2819+
const parent = put.replace(/<[\s\S]*$/, '').trim();
2820+
if (parent.includes('.')) continue;
2821+
merge(file.path, cls, [parent], []);
2822+
}
2823+
}
2824+
2825+
} else if (file.language === 'Scala') {
2826+
const r = await getScalaParser();
2827+
if (!r) continue;
2828+
const { parser, lang } = r;
2829+
const tree = (parser as Parser).parse(file.content);
2830+
2831+
// Scala `class C extends Base with Trait` — the superclass and every mixed-in
2832+
// trait sit in one `extends_clause`. Treat each as a subtype edge.
2833+
for (const declType of ['class_definition', 'trait_definition', 'object_definition']) {
2834+
const Q = `(${declType} (identifier) @cls (extends_clause (type_identifier) @parent))`;
2835+
for (const m of safeQuery(lang, Q, tree.rootNode)) {
2836+
const cls = m.captures.find(c => c.name === 'cls')?.node.text;
2837+
const parent = m.captures.find(c => c.name === 'parent')?.node.text;
2838+
if (cls && parent) merge(file.path, cls, [parent], []);
2839+
}
2840+
}
2841+
27422842
} else if (file.language === 'Ruby') {
27432843
const r = await getRubyParser();
27442844
if (!r) continue;
@@ -2863,11 +2963,22 @@ function buildClassNodes(
28632963
// (a class extending a base declared in its own file / a same-named local shadow);
28642964
// genuine cross-file inheritance falls back to the global first match.
28652965
const byName = new Map<string, ClassNode>();
2966+
const nameCount = new Map<string, number>();
28662967
for (const cls of classMap.values()) {
2968+
nameCount.set(cls.name, (nameCount.get(cls.name) ?? 0) + 1);
28672969
if (!byName.has(cls.name)) byName.set(cls.name, cls);
28682970
}
2869-
const resolveParent = (parentName: string, childFile: string): ClassNode | undefined =>
2870-
classMap.get(`${childFile}::${parentName}`) ?? byName.get(parentName);
2971+
const resolveParent = (parentName: string, childFile: string): ClassNode | undefined => {
2972+
const sameFile = classMap.get(`${childFile}::${parentName}`);
2973+
if (sameFile) return sameFile;
2974+
// The base is not declared in the child's file and its bare name is AMBIGUOUS
2975+
// (several classes share it across files — e.g. two unrelated `Logger` interfaces
2976+
// in different PHP namespaces). A global first-match would both fabricate a false
2977+
// override edge AND steal the real one from the correct twin, so skip rather than
2978+
// guess — false-negatives over false-positives.
2979+
if ((nameCount.get(parentName) ?? 0) > 1) return undefined;
2980+
return byName.get(parentName);
2981+
};
28712982

28722983
const inheritanceEdges: InheritanceEdge[] = [];
28732984
const seenEdges = new Set<string>();

0 commit comments

Comments
 (0)