Skip to content

Commit 0154378

Browse files
Your Nameclaude
andcommitted
feat(type-graph): cross-file resolver for type_relations (P4)
Executes P4 (core) of docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md. extract_type_relations_from_tree only ever resolved to_symbol for a target defined in the SAME file (a single file's parse has no visibility into the rest of the repo). This adds the graph-wide follow-up pass: given every type_relations row extraction left unresolved (to_symbol IS NULL), look up target_text against the whole repo's symbol table and upgrade it when the match is unambiguous -- answering "who implements this interface, across files?" for the first time. - New graph/type_resolve.rs: resolve_cross_file_type_relations, self- contained (takes only &Connection, matching compute_coreness/ compute_digests/compute_package_dependencies's existing signatures -- does NOT reach into indexer::pipeline's private ResolutionCtx, respecting the indexer->graph boundary). Ladder: same-file (existing, untouched) -> cross-file same-language unique bare-name match ('resolved') -> 0 or >1 candidates stay exactly as extraction left them ('textual', never guessed). lookup_name() strips generics/qualifiers (Base<T>, pkg.Base) before matching. - Wired into both rebuild_graph and incremental_graph_update alongside the existing boundary/churn/digest/package-deps passes -- self-heals on a later rebuild once an out-of-order target starts existing, same property compute_digests/compute_package_dependencies already have. - GRAPH_DERIVATION_VERSION bumped 1->2. Design deviation from the plan's literal spec, stated explicitly: a single table with lifecycle-differentiated columns instead of physically splitting into type_relation_sites/type_relation_edges -- gets the same indexer/graph separation at zero migration cost, matching how symbols/call_edges already mix indexer- and graph-owned columns in one table elsewhere in this codebase. Deliberately deferred (see the plan doc's P4 section for the reasoning): import-alias/namespace disambiguation for the multi-candidate case, a full TypeRef struct, a SCIP-overlay resolution rung, extended syntax breadth, and reference_impact integration. Verified: 7 new unit tests in type_resolve.rs + 1 real end-to-end test through the actual pipeline (extract_file_data -> rebuild_graph, not just a synthetic DB). Full `cargo test --workspace --features embeddings` green (1061 calm-core + 367 calm-server + all other packages, 0 failures), clippy -D warnings clean, rustfmt clean, no toolsnap drift (no MCP output structs touched -- fetch_semantic_facts already reads to_symbol unconditionally). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 11ac87b commit 0154378

6 files changed

Lines changed: 430 additions & 8 deletions

File tree

crates/calm-core/src/graph.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pub mod digest;
55
pub mod hub;
66
pub mod path;
77
pub mod tokenize;
8+
pub mod type_resolve;

crates/calm-core/src/graph/digest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ fn format_callee(c: &CalleeFact) -> String {
306306
/// `derived_artifact_versions::graph_derivation_fixture_is_pinned_to_its_version`
307307
/// (crates/calm-core/tests/derived_artifact_versions.rs) -- bump this AND
308308
/// that test's expected hash together, in the same commit, never one alone.
309-
pub const GRAPH_DERIVATION_VERSION: i64 = 1;
309+
pub const GRAPH_DERIVATION_VERSION: i64 = 2;
310310

311311
/// Recompute every digestable symbol's `symbol_digests` row from current
312312
/// DB state — see the module doc comment for why this is a full
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
//! Cross-file resolution for Tier 1 type relations (P4,
2+
//! docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md).
3+
//!
4+
//! `indexer::semantic_facts::extract_type_relations_from_tree` (called from
5+
//! `indexer::pipeline::extract_file_data`, per-file, at reparse time) only
6+
//! ever resolves `to_symbol` for a target defined in the SAME file --
7+
//! deliberately, since a single file's parse has no visibility into the rest
8+
//! of the repo. This module is the graph-wide follow-up pass: given every
9+
//! `type_relations` row extraction left unresolved (`to_symbol IS NULL`),
10+
//! look up its `target_text` against the WHOLE repo's symbol table and
11+
//! upgrade it when the match is unambiguous.
12+
//!
13+
//! Deliberately does NOT reuse `indexer::pipeline`'s private `ResolutionCtx`
14+
//! (built for call-edge resolution, with call-specific fields like
15+
//! `by_name_class`/arity/`caller_usings`) -- this module builds its own
16+
//! minimal name index directly from `symbols`, matching how every other
17+
//! `graph::` module (`coreness`, `digest`, `package_deps`) is self-contained
18+
//! and takes only a `Connection`, not indexer-internal state. A few dozen
19+
//! lines of index-building duplicated across two call sites is a small,
20+
//! honest cost for keeping the indexer/graph boundary this codebase already
21+
//! enforces elsewhere.
22+
//!
23+
//! **Resolution ladder (v1 -- see the plan's P4 section for what's deferred):**
24+
//! 1. Same-file (unchanged, still owned by `extract_file_data`).
25+
//! 2. Cross-file, same source language, EXACTLY ONE bare-name match anywhere
26+
//! in the repo -- promoted to `confidence = 'resolved'`.
27+
//! 3. Zero or multiple candidates -- left exactly as extraction set it
28+
//! (`to_symbol` NULL, `confidence = 'textual'`). Never guessed. A
29+
//! same-language multi-candidate case (two classes named `Handler` in
30+
//! different packages, say) stays textual rather than picking one --
31+
//! narrowing via the referencing file's own imports is deferred (would
32+
//! need the same import-alias machinery call-edge resolution already
33+
//! has, reused carefully to avoid the boundary problem above).
34+
//!
35+
//! Runs on every full and incremental graph rebuild (`indexer::pipeline`'s
36+
//! `rebuild_graph`/`incremental_graph_update`), so a row that was ambiguous
37+
//! or unresolved when a class was added out of order self-heals on the next
38+
//! rebuild once its target exists unambiguously -- the same self-healing
39+
//! property `compute_digests`/`compute_package_dependencies` already have.
40+
//!
41+
//! **Deferred from the full P4 spec (see the plan doc for the reasoning):**
42+
//! a physical `type_relation_sites`/`type_relation_edges` table split (this
43+
//! single-table, lifecycle-differentiated-columns design gets the same
44+
//! functional separation more cheaply, matching how `symbols`/`call_edges`
45+
//! already mix indexer- and graph-owned columns in one table); a full
46+
//! `TypeRef` struct (reduced to the `lookup_name` helper below for v1); a
47+
//! SCIP-overlay resolution rung; import-alias/namespace disambiguation for
48+
//! the multi-candidate case; and `reference_impact` integration.
49+
50+
use rusqlite::Connection;
51+
use std::collections::HashMap;
52+
53+
/// Strips generic type arguments (`Base<T>` -> `Base`) and a qualifier
54+
/// prefix (`pkg.Base` -> `Base`) from a raw `type_relations.target_text`,
55+
/// leaving the bare name a repo-wide symbol lookup can match against.
56+
/// Never fabricates a value: if `target_text` is already bare, it's
57+
/// returned unchanged.
58+
fn lookup_name(target_text: &str) -> &str {
59+
let without_generics = target_text.split('<').next().unwrap_or(target_text).trim();
60+
without_generics
61+
.rsplit('.')
62+
.next()
63+
.unwrap_or(without_generics)
64+
}
65+
66+
/// The graph-wide follow-up pass described in the module doc comment.
67+
/// Idempotent and safe to call on every rebuild: only touches rows where
68+
/// `to_symbol IS NULL`, so an already-resolved (same-file or a prior run of
69+
/// this same pass) row is never re-examined or downgraded.
70+
pub fn resolve_cross_file_type_relations(conn: &Connection) -> rusqlite::Result<()> {
71+
let mut by_name_lang: HashMap<(String, String), Vec<String>> = HashMap::new();
72+
{
73+
let mut stmt = conn.prepare("SELECT name, qualified_name, language FROM symbols")?;
74+
let rows = stmt.query_map([], |r| {
75+
Ok((
76+
r.get::<_, String>(0)?,
77+
r.get::<_, String>(1)?,
78+
r.get::<_, String>(2)?,
79+
))
80+
})?;
81+
for row in rows.flatten() {
82+
let (name, qn, language) = row;
83+
by_name_lang.entry((name, language)).or_default().push(qn);
84+
}
85+
}
86+
87+
let unresolved: Vec<(i64, String, String)> = {
88+
let mut stmt = conn.prepare(
89+
"SELECT tr.id, tr.target_text, s.language \
90+
FROM type_relations tr \
91+
JOIN symbols s ON s.qualified_name = tr.from_symbol \
92+
WHERE tr.to_symbol IS NULL",
93+
)?;
94+
stmt.query_map([], |r| {
95+
Ok((
96+
r.get::<_, i64>(0)?,
97+
r.get::<_, String>(1)?,
98+
r.get::<_, String>(2)?,
99+
))
100+
})?
101+
.collect::<rusqlite::Result<Vec<_>>>()?
102+
};
103+
104+
let mut update = conn.prepare(
105+
"UPDATE type_relations SET to_symbol = ?1, confidence = 'resolved' WHERE id = ?2",
106+
)?;
107+
for (id, target_text, language) in unresolved {
108+
let name = lookup_name(&target_text);
109+
let Some(candidates) = by_name_lang.get(&(name.to_string(), language)) else {
110+
continue;
111+
};
112+
if let [only] = candidates.as_slice() {
113+
update.execute(rusqlite::params![only, id])?;
114+
}
115+
// 0 or >1 candidates: leave as extraction set it (NULL / 'textual') -- never guessed.
116+
}
117+
118+
Ok(())
119+
}
120+
121+
#[cfg(test)]
122+
mod tests {
123+
use super::*;
124+
125+
#[test]
126+
fn lookup_name_strips_generics_and_qualifier() {
127+
assert_eq!(lookup_name("Base"), "Base");
128+
assert_eq!(lookup_name("pkg.Base"), "Base");
129+
assert_eq!(lookup_name("Base<T>"), "Base");
130+
assert_eq!(lookup_name("pkg.Base<T>"), "Base");
131+
assert_eq!(lookup_name("a.b.Base"), "Base");
132+
}
133+
134+
fn setup_db() -> Connection {
135+
let conn = Connection::open_in_memory().unwrap();
136+
crate::db::schema::init_db(&conn).unwrap();
137+
conn
138+
}
139+
140+
fn insert_symbol(conn: &Connection, qn: &str, name: &str, path: &str, language: &str) {
141+
conn.execute(
142+
"INSERT INTO symbols (qualified_name, name, kind, language, path, line_start, line_end) \
143+
VALUES (?1, ?2, 'class', ?3, ?4, 1, 1)",
144+
rusqlite::params![qn, name, language, path],
145+
)
146+
.unwrap();
147+
}
148+
149+
fn insert_relation(conn: &Connection, from_symbol: &str, target_text: &str, path: &str) {
150+
conn.execute(
151+
"INSERT INTO type_relations (from_symbol, relation_kind, target_text, confidence, source_path, line) \
152+
VALUES (?1, 'extends', ?2, 'textual', ?3, 1)",
153+
rusqlite::params![from_symbol, target_text, path],
154+
)
155+
.unwrap();
156+
}
157+
158+
fn to_symbol_and_confidence(conn: &Connection, from_symbol: &str) -> (Option<String>, String) {
159+
conn.query_row(
160+
"SELECT to_symbol, confidence FROM type_relations WHERE from_symbol = ?1",
161+
[from_symbol],
162+
|r| Ok((r.get(0)?, r.get(1)?)),
163+
)
164+
.unwrap()
165+
}
166+
167+
#[test]
168+
fn resolves_unique_cross_file_same_language_match() {
169+
let conn = setup_db();
170+
insert_symbol(&conn, "base.py::Base", "Base", "base.py", "python");
171+
insert_symbol(
172+
&conn,
173+
"derived.py::Derived",
174+
"Derived",
175+
"derived.py",
176+
"python",
177+
);
178+
insert_relation(&conn, "derived.py::Derived", "Base", "derived.py");
179+
180+
resolve_cross_file_type_relations(&conn).unwrap();
181+
182+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "derived.py::Derived");
183+
assert_eq!(to_symbol.as_deref(), Some("base.py::Base"));
184+
assert_eq!(confidence, "resolved");
185+
}
186+
187+
#[test]
188+
fn stays_textual_when_multiple_same_language_candidates_exist() {
189+
let conn = setup_db();
190+
insert_symbol(&conn, "a.py::Handler", "Handler", "a.py", "python");
191+
insert_symbol(&conn, "b.py::Handler", "Handler", "b.py", "python");
192+
insert_symbol(
193+
&conn,
194+
"derived.py::Derived",
195+
"Derived",
196+
"derived.py",
197+
"python",
198+
);
199+
insert_relation(&conn, "derived.py::Derived", "Handler", "derived.py");
200+
201+
resolve_cross_file_type_relations(&conn).unwrap();
202+
203+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "derived.py::Derived");
204+
assert_eq!(to_symbol, None, "ambiguous match must never be guessed");
205+
assert_eq!(confidence, "textual");
206+
}
207+
208+
#[test]
209+
fn stays_textual_when_target_is_a_different_language() {
210+
let conn = setup_db();
211+
// A same-named class exists, but in a DIFFERENT language -- must
212+
// never cross-attribute (e.g. a Python Base and an unrelated Java
213+
// Base sharing a name is a coincidence, not the same type).
214+
insert_symbol(&conn, "Base.java::Base", "Base", "Base.java", "java");
215+
insert_symbol(
216+
&conn,
217+
"derived.py::Derived",
218+
"Derived",
219+
"derived.py",
220+
"python",
221+
);
222+
insert_relation(&conn, "derived.py::Derived", "Base", "derived.py");
223+
224+
resolve_cross_file_type_relations(&conn).unwrap();
225+
226+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "derived.py::Derived");
227+
assert_eq!(to_symbol, None);
228+
assert_eq!(confidence, "textual");
229+
}
230+
231+
#[test]
232+
fn resolves_generic_and_qualified_target_text() {
233+
let conn = setup_db();
234+
insert_symbol(
235+
&conn,
236+
"base.java::Repository",
237+
"Repository",
238+
"base.java",
239+
"java",
240+
);
241+
insert_symbol(
242+
&conn,
243+
"derived.java::Derived",
244+
"Derived",
245+
"derived.java",
246+
"java",
247+
);
248+
insert_relation(
249+
&conn,
250+
"derived.java::Derived",
251+
"pkg.Repository<Foo>",
252+
"derived.java",
253+
);
254+
255+
resolve_cross_file_type_relations(&conn).unwrap();
256+
257+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "derived.java::Derived");
258+
assert_eq!(to_symbol.as_deref(), Some("base.java::Repository"));
259+
assert_eq!(confidence, "resolved");
260+
}
261+
262+
#[test]
263+
fn already_resolved_same_file_row_is_never_touched() {
264+
let conn = setup_db();
265+
insert_symbol(&conn, "a.py::Base", "Base", "a.py", "python");
266+
insert_symbol(&conn, "a.py::Derived", "Derived", "a.py", "python");
267+
// Simulates extraction's own same-file resolution: to_symbol already set.
268+
conn.execute(
269+
"INSERT INTO type_relations (from_symbol, relation_kind, target_text, to_symbol, confidence, source_path, line) \
270+
VALUES ('a.py::Derived', 'extends', 'Base', 'a.py::Base', 'resolved', 'a.py', 1)",
271+
[],
272+
)
273+
.unwrap();
274+
// A same-named decoy elsewhere, in the same language -- if this pass
275+
// incorrectly re-examined already-resolved rows, it would still
276+
// resolve correctly here by luck (unique match); the real point of
277+
// this test is that it doesn't even query rows with to_symbol set,
278+
// which the next assertion on an ambiguous decoy setup proves.
279+
insert_symbol(
280+
&conn,
281+
"elsewhere.py::Base",
282+
"Base",
283+
"elsewhere.py",
284+
"python",
285+
);
286+
287+
resolve_cross_file_type_relations(&conn).unwrap();
288+
289+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "a.py::Derived");
290+
assert_eq!(
291+
to_symbol.as_deref(),
292+
Some("a.py::Base"),
293+
"same-file resolution must be left exactly as extraction set it"
294+
);
295+
assert_eq!(confidence, "resolved");
296+
}
297+
298+
#[test]
299+
fn self_heals_on_a_later_rebuild_once_the_target_exists() {
300+
let conn = setup_db();
301+
insert_symbol(
302+
&conn,
303+
"derived.py::Derived",
304+
"Derived",
305+
"derived.py",
306+
"python",
307+
);
308+
insert_relation(&conn, "derived.py::Derived", "Base", "derived.py");
309+
310+
resolve_cross_file_type_relations(&conn).unwrap();
311+
let (to_symbol, _) = to_symbol_and_confidence(&conn, "derived.py::Derived");
312+
assert_eq!(
313+
to_symbol, None,
314+
"target doesn't exist yet -- must stay unresolved"
315+
);
316+
317+
// Base is added in a later file/rebuild.
318+
insert_symbol(&conn, "base.py::Base", "Base", "base.py", "python");
319+
resolve_cross_file_type_relations(&conn).unwrap();
320+
321+
let (to_symbol, confidence) = to_symbol_and_confidence(&conn, "derived.py::Derived");
322+
assert_eq!(
323+
to_symbol.as_deref(),
324+
Some("base.py::Base"),
325+
"a subsequent rebuild must resolve the row once its target exists"
326+
);
327+
assert_eq!(confidence, "resolved");
328+
}
329+
}

crates/calm-core/src/indexer/pipeline.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,7 @@ fn rebuild_graph(
14621462
crate::graph::hub::update_is_hub_flags(tx, hub_config)?;
14631463
crate::graph::boundary::update_boundary_ambiguous_flags(tx)?;
14641464
crate::graph::churn::update_churn_scores(tx, project_root, churn_since)?;
1465+
crate::graph::type_resolve::resolve_cross_file_type_relations(tx)?;
14651466
crate::graph::digest::compute_digests(tx)?;
14661467
crate::indexer::package_deps::compute_package_dependencies(tx, project_root, ignore)?;
14671468
Ok(())
@@ -1656,6 +1657,7 @@ pub fn incremental_graph_update(
16561657
crate::graph::hub::update_is_hub_flags(tx, hub_config)?;
16571658
crate::graph::boundary::update_boundary_ambiguous_flags(tx)?;
16581659
crate::graph::churn::update_churn_scores(tx, project_root, churn_since)?;
1660+
crate::graph::type_resolve::resolve_cross_file_type_relations(tx)?;
16591661
crate::graph::digest::compute_digests(tx)?;
16601662
crate::indexer::package_deps::compute_package_dependencies(tx, project_root, ignore)?;
16611663

0 commit comments

Comments
 (0)