|
| 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 | +} |
0 commit comments