diff --git a/crates/travsr-analysis/src/lib.rs b/crates/travsr-analysis/src/lib.rs index 8d463229..9a99c948 100644 --- a/crates/travsr-analysis/src/lib.rs +++ b/crates/travsr-analysis/src/lib.rs @@ -16,6 +16,7 @@ pub mod emit; pub mod ffi; pub mod generic; +pub mod live_detect; pub mod skeleton; pub mod snippet; pub mod test_role; diff --git a/crates/travsr-analysis/src/live_detect.rs b/crates/travsr-analysis/src/live_detect.rs new file mode 100644 index 00000000..eb49b527 --- /dev/null +++ b/crates/travsr-analysis/src/live_detect.rs @@ -0,0 +1,898 @@ +//! RFC-027 section 8.2 — detection-only reference set for the live LSP lane. +//! +//! The three languages with a native Phase B extractor (Rust, TypeScript/JS, +//! Python) get their on-save reference set from that extractor: a fully typed +//! record carrying the caller's identity, the receiver type where it could be +//! recovered, and a per-language signature key. The other thirteen have Phase A +//! structural nodes and no on-save reference-detection pass at all, which is the +//! single reason the live lane never reached them — resolution, node mapping, +//! target production, emit, ratification, and the precision meter are already +//! language-agnostic. +//! +//! This module closes exactly that gap and nothing else. It answers *"which +//! references exist in this buffer, where, and of what kind"* and stops there: +//! +//! - **No receiver-type recovery.** `x.Foo()` yields the name `Foo` and its +//! line. What `x` is at that position is the editor's language server's +//! question, and asking it is the whole point of the LSP lane (RFC-027 +//! section 7.3b). +//! - **No signature building.** A signature is a claim about identity, and +//! identity belongs to SCIP (section 8.2 fencing rule). Nothing here mints a +//! VName. +//! - **No resolution.** The daemon maps positions to nodes against the graph it +//! already owns. +//! +//! Because it produces no receiver type and no signature key, the fail-closed +//! lexical floor (section 7.3a) cannot consume this output, so these languages +//! run the **editor lane only** (section 8.3). With no language server installed +//! they abstain and keep today's commit-gated behavior exactly, which is the +//! RFC's zero-regression floor (section 7.3c). +//! +//! ## Adding a language +//! +//! One [`LangSpec`]: a tree-sitter query using the four standard captures, and +//! (only where the grammar reuses one node for both) the [`MemberShape`] that +//! tells a callee apart from a field read. Then an arm in the daemon's +//! `live_language` gate and an entry in the extension's `SUPPORTED_LANGUAGES`. +//! Each language is measured against the per-`(language, kind)` precision gate +//! (section 12) once it is emitting. + +use anyhow::Context as _; +use streaming_iterator::StreamingIterator as _; +use tree_sitter::{Node, Parser, Query, QueryCursor}; + +use travsr_core::{InheritanceRef, Language}; + +/// Upper bound on a buffer this pass will parse. +/// +/// Matches the Phase A parsers' own limit. A generated file past this size is +/// not what the live lane exists for, and parsing it on every save would cost +/// more than the freshness is worth. +const MAX_SOURCE_BYTES: usize = 10 * 1024 * 1024; + +/// One detected reference: the name as written and the line it sits on. +/// +/// The line, not a column: the editor recovers the column by finding `name` on +/// the line, which is far cheaper than teaching every detector to carry UTF-16 +/// offsets, and is what the daemon-driven target contract already asks of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveRef { + /// 1-based source line the reference appears on. + pub line: u32, + /// The referenced name exactly as written (`Foo` in `x.Foo()`). + pub name: String, +} + +/// Every reference in one buffer the live lane can act on, bucketed by the edge +/// kind it would become. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LiveRefs { + /// Call sites — `EdgeKind::RefCall`. + pub calls: Vec, + /// Field / property reads that are not calls — `EdgeKind::RefField`. + pub fields: Vec, + /// `extends` / `implements` style clauses — `EdgeKind::IsImplementation`. + pub inheritance: Vec, +} + +impl LiveRefs { + /// True when nothing was detected, so the caller can skip the whole pass. + pub fn is_empty(&self) -> bool { + self.calls.is_empty() && self.fields.is_empty() && self.inheritance.is_empty() + } +} + +/// How a language nests a member-access name inside a call. +/// +/// Several grammars use one node for both `x.foo(…)` and `x.foo` — the member +/// access is simply the callee of a surrounding call in the first case. A +/// tree-sitter query cannot express "not the callee of a call", so a name +/// captured as `@sel.name` is classified from the tree instead. Languages whose +/// grammar already has distinct nodes for the two (Java, PHP, Ruby) need no +/// shape and capture `@call.name` / `@field.name` directly. +/// +/// Getting this wrong costs correctness, not just tidiness: a field read that +/// became a `ref/call` would surface as a caller in `get_callers` and +/// `get_blast_radius` (#757). +struct MemberShape { + /// The member-access expression the captured name sits inside. The captured + /// node may be nested a level or two below it (Swift's `navigation_suffix`), + /// so this is found by walking up, not by taking the parent. + member_kind: &'static str, + /// The call expression that member may be the callee of. + call_kind: &'static str, + /// Field naming the callee on the call node, or `None` when the grammar + /// leaves it unlabelled and the callee is simply the first named child. + callee_field: Option<&'static str>, +} + +/// A language's detection queries and call shape. +struct LangSpec { + /// Tree-sitter query using the four standard captures: + /// + /// - `@call.name` — unambiguously a call site. + /// - `@sel.name` — a member-access name; [`MemberShape`] decides which it is. + /// - `@field.name` — unambiguously a field read. + /// - `@base.name` — the base type of an inheritance clause. + query: &'static str, + /// Required if and only if the query uses `@sel.name`. + member: Option, +} + +/// Detect the live lane's reference set in `source`, or an empty set for a +/// language with no detector. +/// +/// Returning empty rather than erroring for an unhandled language keeps the +/// daemon's per-language gate the single place a language is turned on: this +/// function is safe to call for anything, and adding a language here without +/// adding it there still ships nothing. +pub fn detect_live_refs(lang: Language, source: &[u8]) -> anyhow::Result { + if source.len() > MAX_SOURCE_BYTES { + return Ok(LiveRefs::default()); + } + let Some((grammar, spec)) = spec_for(lang) else { + return Ok(LiveRefs::default()); + }; + detect(lang, grammar, &spec, source) +} + +fn detect( + lang: Language, + grammar: tree_sitter::Language, + spec: &LangSpec, + source: &[u8], +) -> anyhow::Result { + let query = Query::new(&grammar, spec.query) + .with_context(|| format!("compiling {} live-detect query", lang.as_str()))?; + let capture_names: Vec = query + .capture_names() + .iter() + .map(|s| s.to_string()) + .collect(); + + let mut parser = Parser::new(); + parser + .set_language(&grammar) + .with_context(|| format!("loading {} grammar", lang.as_str()))?; + // A parse that does not complete yields no references, which abstains for + // the whole file. Fail-closed, and the commit-gated path still covers it. + let Some(tree) = parser.parse(source, None) else { + return Ok(LiveRefs::default()); + }; + + let mut out = LiveRefs::default(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, tree.root_node(), source); + while let Some(m) = iter.next() { + for cap in m.captures { + let Some(cap_name) = capture_names.get(cap.index as usize).map(String::as_str) else { + continue; + }; + let Ok(name) = cap.node.utf8_text(source) else { + continue; + }; + let line = cap.node.start_position().row as u32 + 1; + match cap_name { + "call.name" => out.calls.push(LiveRef { + line, + name: name.to_string(), + }), + "field.name" => out.fields.push(LiveRef { + line, + name: name.to_string(), + }), + "sel.name" => { + let r = LiveRef { + line, + name: name.to_string(), + }; + match spec.member.as_ref() { + Some(shape) if is_call_callee(cap.node, shape) => out.calls.push(r), + // No shape declared for a `@sel.name` capture is a + // detector bug, not a user's. Treat it as a field read, + // the conservative half: a missed call costs recall, + // while a field wrongly called a call is the #757 defect. + _ => out.fields.push(r), + } + } + "base.name" => out.inheritance.push(InheritanceRef { + base_name: name.to_string(), + line, + }), + _ => {} + } + } + } + Ok(out) +} + +/// True when the member access containing `name` is the callee of a call, so the +/// reference is `x.foo(…)` rather than a bare `x.foo`. +fn is_call_callee(name: Node<'_>, shape: &MemberShape) -> bool { + // Walk up to the member-access node. Bounded because the capture sits at a + // fixed, shallow depth inside it (one level for a `field:`-labelled name, + // two for Swift's `navigation_suffix`), and an unbounded walk would happily + // find an enclosing call several statements out and call it the callee. + let mut member = name; + for _ in 0..MEMBER_LOOKUP_DEPTH { + if member.kind() == shape.member_kind { + break; + } + match member.parent() { + Some(p) => member = p, + None => return false, + } + } + if member.kind() != shape.member_kind { + return false; + } + let Some(call) = member.parent() else { + return false; + }; + if call.kind() != shape.call_kind { + return false; + } + match shape.callee_field { + Some(field) => call.child_by_field_name(field) == Some(member), + // Unlabelled grammars put the callee first, ahead of the argument list. + None => call.named_child(0) == Some(member), + } +} + +/// How far above a captured name the member-access node may sit. +const MEMBER_LOOKUP_DEPTH: usize = 3; + +/// The grammar and detection spec for `lang`, or `None` when it has no detector. +fn spec_for(lang: Language) -> Option<(tree_sitter::Language, LangSpec)> { + let (grammar, query, member) = match lang { + Language::Go => ( + tree_sitter::Language::new(tree_sitter_go::LANGUAGE), + GO_QUERY, + Some(MemberShape { + member_kind: "selector_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Java => ( + tree_sitter::Language::new(tree_sitter_java::LANGUAGE), + JAVA_QUERY, + None, + ), + Language::CSharp => ( + tree_sitter::Language::new(tree_sitter_c_sharp::LANGUAGE), + CSHARP_QUERY, + Some(MemberShape { + member_kind: "member_access_expression", + call_kind: "invocation_expression", + callee_field: Some("function"), + }), + ), + Language::Cpp => ( + tree_sitter::Language::new(tree_sitter_cpp::LANGUAGE), + CPP_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::C => ( + tree_sitter::Language::new(tree_sitter_c::LANGUAGE), + C_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::ObjectiveC => ( + tree_sitter::Language::new(tree_sitter_objc::LANGUAGE), + OBJC_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Ruby => ( + tree_sitter::Language::new(tree_sitter_ruby::LANGUAGE), + RUBY_QUERY, + None, + ), + Language::Php => ( + tree_sitter::Language::new(tree_sitter_php::LANGUAGE_PHP), + PHP_QUERY, + None, + ), + Language::Kotlin => ( + tree_sitter::Language::new(tree_sitter_kotlin_ng::LANGUAGE), + KOTLIN_QUERY, + Some(MemberShape { + member_kind: "navigation_expression", + call_kind: "call_expression", + callee_field: None, + }), + ), + Language::Swift => ( + tree_sitter::Language::new(tree_sitter_swift::LANGUAGE), + SWIFT_QUERY, + Some(MemberShape { + member_kind: "navigation_expression", + call_kind: "call_expression", + callee_field: None, + }), + ), + Language::Dart => ( + tree_sitter::Language::new(tree_sitter_dart::LANGUAGE), + DART_QUERY, + Some(MemberShape { + member_kind: "member_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Scala => ( + tree_sitter::Language::new(tree_sitter_scala::LANGUAGE), + SCALA_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + _ => return None, + }; + Some((grammar, LangSpec { query, member })) +} + +// ── Per-language queries ───────────────────────────────────────────────────── +// +// Written against each grammar's real node names, not from memory. A wrong name +// fails `Query::new` loudly rather than silently matching nothing, and every +// language below is pinned by a test. + +/// **Go contributes no inheritance clauses, by design.** It has no `extends` / +/// `implements`: interface satisfaction is structural and implicit, and struct +/// embedding is composition, not implementation. No clause in the source asserts +/// "this type implements that interface", so emitting one would mean inferring +/// it — precisely the guess section 8.1 forbids. Go's implements relation stays +/// commit-gated, where the SCIP sidecar derives it from real type information. +const GO_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(selector_expression field: (field_identifier) @sel.name) +"#; + +/// Java's grammar already separates `method_invocation` from `field_access`, so +/// no shape is needed. `method_invocation` covers both `helper()` and `o.m()`. +const JAVA_QUERY: &str = r#" +(method_invocation name: (identifier) @call.name) +(field_access field: (identifier) @field.name) +(superclass (type_identifier) @base.name) +(superclass (generic_type (type_identifier) @base.name)) +(super_interfaces (type_list (type_identifier) @base.name)) +(super_interfaces (type_list (generic_type (type_identifier) @base.name))) +(extends_interfaces (type_list (type_identifier) @base.name)) +(extends_interfaces (type_list (generic_type (type_identifier) @base.name))) +"#; + +/// A C# base list holds plain, generic, and qualified names, and the class-vs- +/// interface distinction is not written in the source at all — both are valid +/// `is-implementation` targets, so both are emitted and the target-kind gate in +/// the daemon decides. +const CSHARP_QUERY: &str = r#" +(invocation_expression function: (identifier) @call.name) +(member_access_expression name: (identifier) @sel.name) +(base_list (identifier) @base.name) +(base_list (generic_name (identifier) @base.name)) +(base_list (qualified_name name: (identifier) @base.name)) +"#; + +/// `->` and `.` are both `field_expression` in C/C++, so one pattern covers a +/// pointer and a value receiver. +const CPP_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(call_expression function: (qualified_identifier name: (identifier) @call.name)) +(field_expression field: (field_identifier) @sel.name) +(base_class_clause (type_identifier) @base.name) +"#; + +/// C has no inheritance clause to detect. +const C_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(field_expression field: (field_identifier) @sel.name) +"#; + +/// A keyword message (`[o setX:1 y:2]`) yields one `method:` capture per keyword. +/// Each resolves to the same selector's definition, so the extra targets produce +/// a duplicate edge rather than a wrong one, and the daemon's upsert absorbs it. +const OBJC_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(message_expression method: (identifier) @call.name) +(field_expression field: (field_identifier) @sel.name) +(class_interface superclass: (identifier) @base.name) +"#; + +/// **Ruby has no field reads to detect**, and that is the language, not a gap: +/// `o.attr` is a method call on an attribute reader, which is what `call` here +/// captures. A bare receiverless `helper` is not detectable either — the grammar +/// parses it as a plain `identifier`, indistinguishable from a local variable or +/// a parameter, and emitting every identifier would flood the editor with +/// positions that resolve to locals. Both are recall costs with no precision +/// cost, which is the right side to err on. +/// +/// `include M` is Ruby's nearest analogue to `implements`, and it is deliberately +/// **not** treated as one: it parses as an ordinary `call`, so recognising it +/// would mean special-casing a method name and asserting a relation the grammar +/// does not state. That is an inference, not a detection (section 8.1). +const RUBY_QUERY: &str = r#" +(call method: (identifier) @call.name) +(superclass (constant) @base.name) +(superclass (scope_resolution name: (constant) @base.name)) +"#; + +/// PHP separates every call shape from `member_access_expression`, so no shape is +/// needed. `new Thing()` is included: a constructor call is a reference to the +/// class, the same treatment Python's `Foo()` gets. +const PHP_QUERY: &str = r#" +(function_call_expression function: (name) @call.name) +(member_call_expression name: (name) @call.name) +(scoped_call_expression name: (name) @call.name) +(object_creation_expression (name) @call.name) +(member_access_expression name: (name) @field.name) +(base_clause (name) @base.name) +(class_interface_clause (name) @base.name) +"#; + +/// tree-sitter-kotlin-ng labels neither the callee of a `call_expression` nor +/// the parts of a `navigation_expression`, so the callee is the first named +/// child and the accessed name is the second. `(_)` for the receiver keeps a +/// chained `a.b.c` matching at every level. +const KOTLIN_QUERY: &str = r#" +(call_expression (identifier) @call.name) +(navigation_expression (_) (identifier) @sel.name) +(delegation_specifier (user_type (identifier) @base.name)) +(delegation_specifier (constructor_invocation (user_type (identifier) @base.name))) +"#; + +/// Swift nests the accessed name one level deeper than the other grammars, in a +/// `navigation_suffix` inside the `navigation_expression`, which is why +/// [`is_call_callee`] walks up rather than taking the parent. +const SWIFT_QUERY: &str = r#" +(call_expression (simple_identifier) @call.name) +(navigation_suffix suffix: (simple_identifier) @sel.name) +(inheritance_specifier inherits_from: (user_type (type_identifier) @base.name)) +"#; + +/// Dart splits the three clause kinds into `superclass`, `mixins`, and +/// `interfaces`; all three name a type this class implements or derives from. +const DART_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(member_expression property: (identifier) @sel.name) +(superclass type: (type (type_identifier) @base.name)) +(mixins (type (type_identifier) @base.name)) +(interfaces (type (type_identifier) @base.name)) +"#; + +/// Scala folds `extends B with T with U` into one `extends_clause` holding a +/// `type:` child per base, so one pattern covers every half. +/// +/// The base is matched **without** naming the `type:` field on purpose: a field +/// name binds a query to the *first* child carrying it, so `type: (…)` would +/// capture `B` and silently drop every `with` clause after it. Verified against +/// the grammar, not assumed. +const SCALA_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(field_expression field: (identifier) @sel.name) +(extends_clause (type_identifier) @base.name) +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn names(refs: &[LiveRef]) -> Vec<(u32, &str)> { + refs.iter().map(|r| (r.line, r.name.as_str())).collect() + } + + fn bases(refs: &[InheritanceRef]) -> Vec<&str> { + refs.iter().map(|r| r.base_name.as_str()).collect() + } + + /// Every language's query must compile against its real grammar. A wrong + /// node name is a `Query::new` error, not a silent zero-match detector, and + /// this is what keeps that true as grammars are upgraded. + #[test] + fn every_registered_query_compiles() { + for lang in [ + Language::Go, + Language::Java, + Language::CSharp, + Language::Cpp, + Language::C, + Language::ObjectiveC, + Language::Ruby, + Language::Php, + Language::Kotlin, + Language::Swift, + Language::Dart, + Language::Scala, + ] { + let (grammar, spec) = spec_for(lang).expect("a registered language has a spec"); + Query::new(&grammar, spec.query) + .unwrap_or_else(|e| panic!("{} query must compile: {e}", lang.as_str())); + // A `@sel.name` capture is meaningless without the shape that + // classifies it, and the fallback would silently call every method + // call a field read. + assert_eq!( + spec.query.contains("@sel.name"), + spec.member.is_some(), + "{} must declare a MemberShape if and only if it captures @sel.name", + lang.as_str() + ); + } + } + + // ── Go ─────────────────────────────────────────────────────────────────── + + #[test] + fn go_separates_calls_from_field_reads() { + let src = br#" +package main + +func run(s *Session) int { + s.Start() + helper() + n := s.count + return n +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(5, "Start"), (6, "helper")]); + assert_eq!(names(&refs.fields), vec![(7, "count")]); + } + + #[test] + fn go_detects_package_qualified_calls() { + // `pkg.Foo()` is the shape that carries most of Go's cross-file value: + // the receiver is a package, not a value, and only the server knows + // which package the alias binds to. + let src = br#" +package main + +import svc "example.com/m/service" + +func run() { + svc.Start() +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(7, "Start")]); + assert!(refs.fields.is_empty()); + } + + #[test] + fn go_emits_no_inheritance_clauses() { + // Interface satisfaction is implicit in Go and embedding is composition, + // so there is no clause to detect and nothing may be inferred. + let src = br#" +package main + +type Reader interface { Read() } + +type Base struct{ n int } + +type Derived struct { + Base +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert!(refs.inheritance.is_empty()); + } + + #[test] + fn a_method_declaration_is_not_a_reference() { + let src = br#" +package main + +type T struct{} + +func (t *T) Foo() {} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert!( + refs.is_empty(), + "declarations are definitions, not references" + ); + } + + // ── Java ───────────────────────────────────────────────────────────────── + + #[test] + fn java_detects_calls_fields_and_both_clause_kinds() { + let src = br#"class C extends Base implements Runnable, Closeable { + void f(Order o) { + helper(); + o.submit(1); + int n = o.total; + } +} +"#; + let refs = detect_live_refs(Language::Java, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!( + bases(&refs.inheritance), + vec!["Base", "Runnable", "Closeable"] + ); + } + + #[test] + fn java_detects_an_interface_extends_clause() { + let refs = detect_live_refs(Language::Java, b"interface I extends J, K {}").unwrap(); + assert_eq!(bases(&refs.inheritance), vec!["J", "K"]); + } + + // ── C# ─────────────────────────────────────────────────────────────────── + + #[test] + fn csharp_separates_calls_from_field_reads() { + let src = br#"class C : Base, N.IRunnable { + void F(Order o) { + Helper(); + o.Submit(1); + var n = o.Total; + } +} +"#; + let refs = detect_live_refs(Language::CSharp, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "Helper"), (4, "Submit")]); + assert_eq!(names(&refs.fields), vec![(5, "Total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "IRunnable"]); + } + + // ── C / C++ ────────────────────────────────────────────────────────────── + + #[test] + fn cpp_detects_qualified_calls_and_both_receiver_forms() { + let src = br#"class C : public Base { + void f(Order o, Order* p) { + helper(); + ns::build(); + o.submit(1); + p->submit(2); + int n = o.total; + } +}; +"#; + let refs = detect_live_refs(Language::Cpp, src).unwrap(); + assert_eq!( + names(&refs.calls), + vec![(3, "helper"), (4, "build"), (5, "submit"), (6, "submit")] + ); + assert_eq!(names(&refs.fields), vec![(7, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + #[test] + fn c_detects_calls_and_fields_and_has_no_inheritance() { + let src = br#"void f(struct S s, struct S* p) { + helper(); + int a = s.total; + int b = p->total; +} +"#; + let refs = detect_live_refs(Language::C, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(2, "helper")]); + assert_eq!(names(&refs.fields), vec![(3, "total"), (4, "total")]); + assert!(refs.inheritance.is_empty()); + } + + // ── Objective-C ────────────────────────────────────────────────────────── + + #[test] + fn objc_detects_message_sends_and_the_superclass() { + let src = br#"@interface C : Base +@end +void f(Order* o, struct S s) { + helper(); + [o submit:1]; + int n = s.total; +} +"#; + let refs = detect_live_refs(Language::ObjectiveC, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(4, "helper"), (5, "submit")]); + assert_eq!(names(&refs.fields), vec![(6, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + // ── Ruby ───────────────────────────────────────────────────────────────── + + #[test] + fn ruby_treats_attribute_reads_as_the_method_calls_they_are() { + let src = br#"class C < A::Base + def f(o) + o.submit(1) + o.total + end +end +"#; + let refs = detect_live_refs(Language::Ruby, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "submit"), (4, "total")]); + assert!( + refs.fields.is_empty(), + "Ruby has no field-read expression; an attribute read is a call" + ); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + #[test] + fn ruby_does_not_infer_implements_from_include() { + // `include M` parses as an ordinary call, so recognising it would mean + // special-casing a method name to assert a relation the grammar does not + // state. That is an inference, not a detection (section 8.1). + let refs = detect_live_refs(Language::Ruby, b"class C\n include M\nend\n").unwrap(); + assert!(refs.inheritance.is_empty()); + assert_eq!(names(&refs.calls), vec![(2, "include")]); + } + + // ── PHP ────────────────────────────────────────────────────────────────── + + #[test] + fn php_detects_every_call_shape_and_both_clause_kinds() { + let src = br#"submit(1); + Factory::build(); + $x = new Thing(); + return $o->total; + } +} +"#; + let refs = detect_live_refs(Language::Php, src).unwrap(); + assert_eq!( + names(&refs.calls), + vec![(4, "helper"), (5, "submit"), (6, "build"), (7, "Thing"),] + ); + assert_eq!(names(&refs.fields), vec![(8, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Kotlin ─────────────────────────────────────────────────────────────── + + #[test] + fn kotlin_separates_calls_from_property_reads() { + let src = br#"class C : Base(), Runnable { + fun f(o: Order) { + helper() + o.submit(1) + val n = o.total + } +} +"#; + let refs = detect_live_refs(Language::Kotlin, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Swift ──────────────────────────────────────────────────────────────── + + #[test] + fn swift_separates_calls_from_property_reads() { + let src = br#"class C: Base, Runnable { + func f(o: Order) { + helper() + o.submit(1) + let n = o.total + } +} +"#; + let refs = detect_live_refs(Language::Swift, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Dart ───────────────────────────────────────────────────────────────── + + /// A clause that can name several types must yield all of them. A + /// tree-sitter field name binds to the *first* child carrying it, so a + /// pattern written as `type: (…)` over a repeated field silently keeps only + /// the first base. This pins that none of the multi-base clauses do that. + #[test] + fn a_clause_naming_several_types_yields_all_of_them() { + let dart = detect_live_refs( + Language::Dart, + b"class C extends B with M1, M2 implements I1, I2 {}", + ) + .unwrap(); + assert_eq!(bases(&dart.inheritance), vec!["B", "M1", "M2", "I1", "I2"]); + + let scala = + detect_live_refs(Language::Scala, b"class C extends B with R with O { }").unwrap(); + assert_eq!(bases(&scala.inheritance), vec!["B", "R", "O"]); + + let php = detect_live_refs( + Language::Php, + b", +) -> anyhow::Result> { + let language = tree_sitter::Language::new(tree_sitter_python::LANGUAGE); + let inherit_q = Query::new(&language, INHERIT_QUERY).context("python inherit query")?; + + let walked; + let file_pairs: &[(PathBuf, String)] = match files { + Some(f) => f, + None => { + walked = collect_source_files(root, &["py", "pyi"]); + &walked + } + }; + + let mut out: Vec = Vec::new(); + for (abs_path, _vname_path) in file_pairs { + let Ok(source) = std::fs::read(abs_path) else { + continue; + }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { + continue; + } + let Some(tree) = parser.parse(&source, None) else { + continue; + }; + let cap_names: Vec = inherit_q + .capture_names() + .iter() + .map(|s| s.to_string()) + .collect(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&inherit_q, tree.root_node(), source.as_slice()); + while let Some(m) = iter.next() { + for &cap in m.captures { + if cap_names.get(cap.index as usize).map(String::as_str) != Some("base.name") { + continue; + } + let Ok(text) = cap.node.utf8_text(source.as_slice()) else { + continue; + }; + out.push(travsr_core::InheritanceRef { + base_name: text.to_string(), + line: cap.node.start_position().row as u32 + 1, + }); + } + } + } + Ok(out) +} + // ── Per-file analysis ───────────────────────────────────────────────────────── fn extract_file_edges( @@ -541,6 +603,33 @@ fn walk(root: &Path, dir: &Path, exts: &[&str], out: &mut Vec<(PathBuf, String)> mod tests { use super::*; + /// RFC-027 live IsImplementation lane: `class Dog(Animal, Mixin)` bases come + /// back as unresolved references, one per base, carrying the base name and + /// the line it is written on. + #[test] + fn class_bases_come_back_as_inheritance_refs() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("animals.py"); + std::fs::write( + &file, + b"class Animal:\n pass\n\nclass Dog(Animal, Mixin):\n pass\n", + ) + .unwrap(); + let files = vec![(file.clone(), "animals.py".to_string())]; + let refs = extract_unresolved_inheritance(dir.path(), Some(&files)).unwrap(); + + // Both bases are on line 4 (the `class Dog(...)` line). + assert!( + refs.iter().any(|r| r.base_name == "Animal" && r.line == 4), + "base Animal captured at its line, got {refs:?}", + ); + assert!( + refs.iter().any(|r| r.base_name == "Mixin" && r.line == 4), + "base Mixin captured at its line, got {refs:?}", + ); + assert_eq!(refs.len(), 2, "exactly the two bases: {refs:?}"); + } + /// Bare-call tagging carries both candidate names, so a PascalCase free /// function is not silently lost (#716 review). /// diff --git a/crates/travsr-analysis/src/phase_b_rust.rs b/crates/travsr-analysis/src/phase_b_rust.rs index 27037e9f..c84d3cf9 100644 --- a/crates/travsr-analysis/src/phase_b_rust.rs +++ b/crates/travsr-analysis/src/phase_b_rust.rs @@ -176,6 +176,23 @@ pub fn extract_native_phase_b( Ok((nodes, edges, unresolved, refs)) } +// RFC-027 live IsImplementation lane, Rust — DEFERRED, not implemented here. +// +// Rust's `impl Trait for Type` does not fit the live model TS/Python use, for +// reasons that are structural, not effort: +// 1. The `impl` block is a separate item from the type, so the natural edge +// source (`struct:Foo`) lives in a different file than the clause. Saving +// the impl file would not invalidate that edge, so deleting an impl would +// leave a stale (wrong) edge until the next commit — the exact §8.1 failure. +// 2. Rust's own `impl:Foo` node carries no span and collapses every impl of a +// type into one node, so it cannot serve as a per-impl source either. +// 3. Rust native Phase B emits no `IsImplementation` edge at all, so a live one +// has nothing to ratify against (swept every commit) and no oracle for the +// precision meter. +// Making it correct requires teaching Rust's committed Phase B to emit +// IsImplementation from a properly-spanned per-impl node first — a change to the +// deterministic pipeline, tracked separately. + // ── Cargo.toml dependency graph ─────────────────────────────────────────────── fn extract_cargo_deps(corpus: &str, root: &Path) -> anyhow::Result<(Vec, Vec)> { diff --git a/crates/travsr-analysis/src/phase_b_typescript.rs b/crates/travsr-analysis/src/phase_b_typescript.rs index b547b39a..04db72f8 100644 --- a/crates/travsr-analysis/src/phase_b_typescript.rs +++ b/crates/travsr-analysis/src/phase_b_typescript.rs @@ -109,6 +109,93 @@ pub fn extract_native_phase_b( Ok((nodes, edges, unresolved)) } +/// RFC-027 live IsImplementation lane: the `extends`/`implements` clauses in +/// `files`, as unresolved references (base type name + line). +/// +/// Unlike [`extract_native_phase_b`], which emits `IsImplementation` edges under +/// a same-file assumption (`ts_vname(corpus, vname_path, ...)` for the base, so a +/// cross-file base dangles), this returns the raw clause so the daemon can +/// resolve the base against the *real* node table — lexically when it is unique +/// repo-wide, or via the editor's definition provider — and abstain otherwise. +/// It never resolves and never mints identity. +pub fn extract_unresolved_inheritance( + root: &Path, + files: Option<&[(PathBuf, String)]>, +) -> anyhow::Result> { + let language = tree_sitter::Language::new(tree_sitter_typescript::LANGUAGE_TYPESCRIPT); + let extends_q = Query::new(&language, EXTENDS_QUERY).context("ts extends query")?; + let implements_q = Query::new(&language, IMPLEMENTS_QUERY).context("ts implements query")?; + + let walked; + let file_pairs: &[(PathBuf, String)] = match files { + Some(f) => f, + None => { + walked = collect_source_files(root, &["ts", "tsx", "mts", "cts"]); + &walked + } + }; + + let mut out: Vec = Vec::new(); + for (abs_path, _vname_path) in file_pairs { + let Ok(source) = std::fs::read(abs_path) else { + continue; + }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { + continue; + } + let Some(tree) = parser.parse(&source, None) else { + continue; + }; + collect_inheritance( + &extends_q, + "extends.base", + tree.root_node(), + &source, + &mut out, + ); + collect_inheritance( + &implements_q, + "implements.iface", + tree.root_node(), + &source, + &mut out, + ); + } + Ok(out) +} + +/// Push one `InheritanceRef` per match of `base_cap` in `q`, carrying the base +/// name's text and its 1-based line. +fn collect_inheritance( + q: &Query, + base_cap: &str, + root: tree_sitter::Node, + source: &[u8], + out: &mut Vec, +) { + let cap_names: Vec = q.capture_names().iter().map(|s| s.to_string()).collect(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(q, root, source); + while let Some(m) = iter.next() { + for &cap in m.captures { + let Some(name) = cap_names.get(cap.index as usize) else { + continue; + }; + if name != base_cap { + continue; + } + let Ok(text) = cap.node.utf8_text(source) else { + continue; + }; + out.push(travsr_core::InheritanceRef { + base_name: text.to_string(), + line: cap.node.start_position().row as u32 + 1, + }); + } + } +} + // ── Per-file analysis ───────────────────────────────────────────────────────── fn extract_file_edges( @@ -757,6 +844,36 @@ class App { assert_eq!(recv_type_for_call(source, "run"), None); } + /// RFC-027 live IsImplementation lane: `extends`/`implements` clauses come + /// back as unresolved references carrying the base type's name and the line + /// it is written on, so the daemon can resolve them against the real node + /// table rather than the same-file assumption `extract_native_phase_b` makes. + #[test] + fn extends_and_implements_come_back_as_inheritance_refs() { + let dir = std::env::temp_dir().join(format!("rfc027_inherit_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("order.ts"); + std::fs::write( + &file, + b"import { Base } from './base';\nexport class Order extends Base implements Shape {\n area() { return 0; }\n}\n", + ) + .unwrap(); + let files = vec![(file.clone(), "order.ts".to_string())]; + let refs = extract_unresolved_inheritance(&dir, Some(&files)).unwrap(); + + // Both clauses are on line 2 (the class declaration line). + assert!( + refs.iter().any(|r| r.base_name == "Base" && r.line == 2), + "extends Base captured at its line, got {refs:?}", + ); + assert!( + refs.iter().any(|r| r.base_name == "Shape" && r.line == 2), + "implements Shape captured at its line, got {refs:?}", + ); + assert_eq!(refs.len(), 2, "exactly the two clauses, no more: {refs:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn new_expression_emits_class_unresolved_call() { let dir = std::env::temp_dir().join(format!("e4_ts_{}", std::process::id())); diff --git a/crates/travsr-cli/src/status.rs b/crates/travsr-cli/src/status.rs index e86c10c0..fbaad78d 100644 --- a/crates/travsr-cli/src/status.rs +++ b/crates/travsr-cli/src/status.rs @@ -15,6 +15,41 @@ use crate::repo::find_git_root; /// #583: equal markers are not sufficient evidence of freshness. A watcher /// reindex rewrites a file's Phase A nodes and drops that file's `ref/call` /// edges without moving HEAD, so both markers still agree while `get_callers` +/// RFC-027 section 12: render the cumulative live-lane precision reading. +/// +/// Returns `None` when nothing has been measured, so the line never appears on a +/// repo that has not exercised the lane. +/// +/// Coverage is always shown beside precision. A precision figure alone invites +/// the reading "1.00 means it is perfect", when it may mean "two of four hundred +/// claims were checkable and both happened to be right". The gate is on +/// precision; coverage is what tells you whether the gate saw anything. +fn live_precision_line(store: &travsr_store::SqliteStore) -> Option { + let raw = store.get_meta("live_precision").ok().flatten()?; + let parts: Vec = raw + .split(',') + .filter_map(|p| p.trim().parse::().ok()) + .collect(); + let [agree, disagree, unverifiable] = parts.as_slice() else { + return None; + }; + let total = agree + disagree + unverifiable; + if total == 0 { + return None; + } + let verified = agree + disagree; + let precision = if verified > 0 { + format!("{:.4}", *agree as f64 / verified as f64) + } else { + // Not "1.0000": nothing was checked, and saying so is the point. + "n/a".to_string() + }; + Some(format!( + "live lane: precision {precision} over {verified}/{total} verifiable claims \ + ({disagree} disagreed with semantic analysis)" + )) +} + /// and `get_blast_radius` answer from a graph degraded below the committed /// snapshot. Reporting `complete` there is the actual harm; the edges /// themselves return on the next commit's Phase B run. @@ -172,6 +207,22 @@ pub fn run() -> anyhow::Result<()> { payload.nodes, payload.edges, payload.schema, last_commit, phase_b_state, rerank_segment ); + // RFC-027 section 12: the live lane's measured precision, so the per-language + // shipping gate has a number a human can read rather than a log line that + // scrolled away. + // + // Read straight from the store rather than added to `StatusPayload`: this is + // a diagnostic, and threading it through the query payload would mean a + // protocol bump that every mixed CLI/daemon pair then has to survive. + // + // Silent when the lane has never claimed anything, which is every repo that + // has not used it — a counter of zero is not news. + if let Ok(store) = daemon_client::open_read_store(&db_path) { + if let Some(line) = live_precision_line(&store) { + println!("{line}"); + } + } + // #645 WS-B: the freshness markers only ever compare against each other, // never against the repository. Compare the caller's live HEAD (read at cwd, // above) to the index's last_commit so a checkout at a different revision — @@ -399,6 +450,52 @@ mod tests { /// `Command::output()`. These pin the two answers it must give without /// hanging for either: a real repo reports a short SHA, a directory that is /// not a repo reports nothing. + /// The line never appears on a repo that has not used the live lane, and + /// never reports a precision it did not measure. + #[test] + fn live_precision_line_is_silent_until_something_is_measured() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + assert_eq!(live_precision_line(&store), None, "no reading, no line"); + + store.set_meta("live_precision", "0,0,0").unwrap(); + assert_eq!( + live_precision_line(&store), + None, + "an empty tally is not news" + ); + + store.set_meta("live_precision", "garbage").unwrap(); + assert_eq!( + live_precision_line(&store), + None, + "a corrupt value is not a reading" + ); + } + + /// Coverage is always shown, and an unverified sample says so instead of + /// rendering a perfect score it did not earn. + #[test] + fn live_precision_line_reports_coverage_beside_precision() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + store.set_meta("live_precision", "99,1,100").unwrap(); + let line = live_precision_line(&store).unwrap(); + assert!(line.contains("0.9900"), "precision: {line}"); + assert!(line.contains("100/200"), "coverage must be visible: {line}"); + assert!( + line.contains("1 disagreed"), + "false positives named: {line}" + ); + + // Nothing checkable: must not read as perfect. + store.set_meta("live_precision", "0,0,40").unwrap(); + let line = live_precision_line(&store).unwrap(); + assert!( + line.contains("n/a"), + "forty unchecked claims must not render as 1.0000: {line}" + ); + } + #[test] fn head_at_reports_a_sha_inside_a_repo_and_none_outside() { let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/crates/travsr-core/src/lib.rs b/crates/travsr-core/src/lib.rs index 5e6748f3..896a0d0e 100644 --- a/crates/travsr-core/src/lib.rs +++ b/crates/travsr-core/src/lib.rs @@ -1013,7 +1013,7 @@ pub fn is_scip_anonymous_local(sig: &str) -> bool { } /// A directed, typed edge between two nodes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, Serialize, Deserialize)] pub struct Edge { pub src: NodeId, pub dst: NodeId, @@ -1022,6 +1022,32 @@ pub struct Edge { /// `None` for all non-FFI edges. Stored in `edges.confidence` (migration v6). #[serde(default, skip_serializing_if = "Option::is_none")] pub confidence: Option, + /// How this edge was derived: the `edges.provenance` tag required by + /// ADR-002 Rule 1 (`tree-sitter` / `lsif` / `scip` / `bridge:`). + /// + /// This is a **read-side** field, populated by the store's `iter_edges_*` + /// readers so consumers (the MCP surface, `travsr graph --format json`) can + /// report an edge's true origin instead of assuming `tree-sitter` + /// (DEBT-75). It is `None` on an edge that was constructed rather than read. + /// Writers are unaffected: every insert path still takes its provenance as + /// an explicit argument, so there is exactly one source of truth on write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +/// Equality is over `(src, dst, kind, confidence)` and deliberately **excludes** +/// `provenance`: an edge's identity in the store is its `(src, dst, kind)` +/// primary key, and provenance is metadata about how that one edge was derived, +/// not a second edge. Two `Edge` values that differ only in provenance denote +/// the same edge, so comparing a constructed edge against a read-back one stays +/// meaningful. +impl PartialEq for Edge { + fn eq(&self, other: &Self) -> bool { + self.src == other.src + && self.dst == other.dst + && self.kind == other.kind + && self.confidence == other.confidence + } } impl Edge { @@ -1031,9 +1057,17 @@ impl Edge { dst, kind, confidence: None, + provenance: None, } } + /// Attach a read-side provenance tag. Used by the store readers; see the + /// `provenance` field docs. + pub fn with_provenance(mut self, provenance: impl Into) -> Self { + self.provenance = Some(provenance.into()); + self + } + /// Build a cross-language FFI edge with a confidence score (RFC-005). /// /// `confidence` must be in `0..=100`. Panics in debug builds if violated. @@ -1047,6 +1081,7 @@ impl Edge { dst, kind: EdgeKind::FFICall, confidence: Some(confidence), + provenance: None, } } } @@ -1110,6 +1145,26 @@ pub struct UnresolvedCall { pub recv_type: Option, } +/// An `extends`/`implements` clause the native extractor found but does not +/// resolve cross-file, for RFC-027's live `IsImplementation` lane. +/// +/// Like [`UnresolvedCall`] it carries no identity — only the base type's simple +/// name and the line it is written on, which is exactly what the live lane needs +/// to resolve it (lexically against a unique repo-wide definition, or via the +/// editor's definition provider) and to abstain otherwise. The daemon derives +/// the edge's source (the implementing class) from the line and owns both node +/// ids, so nothing here mints a VName. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct InheritanceRef { + /// The base type's simple name (the edge *target*), exactly as written: the + /// superclass in `class C extends B`, the interface in `implements I`. + pub base_name: String, + /// 1-based line the base name appears on — the class declaration line, which + /// the daemon maps to the implementing class (the edge source) via + /// `enclosing_definition_at`. + pub line: u32, +} + // ── Import Resolution ──────────────────────────────────────────────────────── // // Query-time bridge over missing `ResolvesTo` edges. diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index e23ab6fd..609d47a8 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod hook; +pub mod live_resolve; pub mod logfile; mod phase_b_sched; mod query_cache; @@ -3620,6 +3621,719 @@ fn run_with_phase_b_finish_guard( /// tick), and [`AllCrashed`](phase_b_sched::RunOutcome::AllCrashed) when every /// sidecar crashed, which increments the retry-cap counter in /// `PhaseBScheduler`. +/// RFC-027 section 7.3a: emit the unambiguous-lexical live overlay for one file. +/// +/// Re-runs the native Phase B call-site extractor over just this file to get its +/// [`UnresolvedCall`]s, then hands them to the precision-first live resolver. +/// Reusing the extractor (rather than a second call-site parser) is what keeps +/// the live lane detecting exactly the references Phase B will later ratify. +/// +/// Only languages with a native Phase B call-site extractor participate; every +/// other language keeps today's commit-gated behavior exactly, which is the +/// RFC's zero-regression floor. +/// +/// **Called from the save path only, never from the commit path.** It lives +/// beside `reindex_files` rather than inside it because `reindex_files` is +/// shared by the watcher and the commit hook, and on commit this work is pure +/// waste: the hook arms a Phase B refresh immediately afterwards, which +/// re-derives the same edges and ratifies them. Doing it there would buy +/// nothing and would put a second tree-sitter parse per changed file directly +/// in `git commit`'s latency, which the parse timeout in the Phase A parsers +/// exists to protect. +/// +/// It re-resolves the **whole** file, not only references that look new, +/// because `reindex_replace` has just deleted every outbound edge the file +/// owned. `put_edge_live` upserts, so re-emitting is idempotent. +/// +/// Fail-open: a live edge is a freshness gain, never a correctness dependency, +/// so every error here is logged at debug and dropped. +/// Upper bound on files re-resolved by one interface edit (RFC-027 Risk R4). +/// +/// A save must stay a save. Editing a hot utility can dirty hundreds of files, +/// and re-parsing all of them on every keystroke would cost more than the +/// freshness is worth. Beyond the cap those files keep their commit-gated +/// behavior, which is a recall cost the next Phase B run repairs. +const LIVE_CLOSURE_FILE_CAP: usize = 32; + +/// Which live lanes a language's on-save reference detection can feed +/// (RFC-027 sections 7.3 and 8.3). +enum LiveLane { + /// A native Phase B extractor detects the references. Its records carry the + /// caller's identity, the receiver type where it could be recovered, and a + /// per-language signature key, so the fail-closed lexical floor (§7.3a) and + /// the editor lane (§7.3b) both run. + Native, + /// The generic tree-sitter detector detects the references. It recovers no + /// receiver type and builds no signature key by design, so the lexical floor + /// has nothing to match on and these languages run the **editor lane + /// alone** (§8.3). With no language server they abstain, which is today's + /// commit-gated behavior exactly (§7.3c, zero regression). + EditorOnly, +} + +/// The language of `abs_path` and the live lane it participates in, or `None` +/// when the live lane does not apply to it at all. +/// +/// This is the one place a language is switched on, and it must stay in step +/// with two others: `travsr_analysis::live_detect::detect_live_refs`, which has +/// the query, and the extension's `SUPPORTED_LANGUAGES`, which gates the request +/// that reaches here. A language listed here but missing from either is inert +/// rather than wrong. +/// +/// `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there is no separate +/// JavaScript variant, and the native extractor handles both. +/// +/// The data/config formats (JSON, YAML, TOML, XML) and Markdown are absent by +/// design: they carry no call or inheritance semantics, so there is nothing for +/// a language server to resolve. +/// +/// Every editor-only language is subject to the per-`(language, kind)` precision +/// gate (RFC-027 §12), which disables one on a measured adverse reading. A +/// language absent here costs nothing on save and keeps commit-gated behavior. +fn live_language(abs_path: &Path) -> Option<(travsr_core::Language, LiveLane)> { + use travsr_core::Language as L; + let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + match L::from_extension(ext)? { + lang @ (L::TypeScript | L::Rust | L::Python) => Some((lang, LiveLane::Native)), + lang @ (L::Go + | L::Java + | L::CSharp + | L::Cpp + | L::C + | L::ObjectiveC + | L::Ruby + | L::Php + | L::Kotlin + | L::Swift + | L::Dart + | L::Scala) => Some((lang, LiveLane::EditorOnly)), + _ => None, + } +} + +/// The references detected in a saved file, in whichever shape its detector +/// produces. +enum LiveRefSet { + /// [`LiveLane::Native`]: the native extractor's typed reference records plus + /// the inheritance clauses its dedicated detector found. + Native { + unresolved: Vec, + inheritance: Vec, + }, + /// [`LiveLane::EditorOnly`]: positions and names only. + Generic(travsr_analysis::live_detect::LiveRefs), +} + +/// The reference set for a saved file and its repo-relative path, or `None` when +/// the live lane does not apply (unsupported language, the precision gate +/// disabled it, detection failed, or the file holds no references). +/// +/// Shared by the save-path lexical lane ([`live_resolve_file`]) and the editor's +/// target request (RFC-027 daemon-driven positions), so the two agree on exactly +/// which references exist in a dirty file. Takes `&SqliteStore` — detection and +/// the gate are read-only — so a `&mut` caller can reborrow and keep writing. +fn extract_live_unresolved( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Option<(String, LiveRefSet)> { + let vname_path = abs_path + .strip_prefix(repo_root) + .unwrap_or(abs_path) + .to_string_lossy() + .replace('\\', "/"); + // Detection is the only per-language stage; everything downstream + // (`resolve_unambiguous_lexical`, `candidate_signatures`, node mapping, + // target production, emit, ratification, the meter) is language-agnostic. + let (lang, lane) = live_language(abs_path)?; + // RFC-027 section 12: a language the cumulative meter has measured below the + // shipping bar is disabled — the fail-closed floor (§7.3c) is better than an + // un-ratified wrong edge. Detection is skipped entirely so a disabled + // language costs nothing on save. + if !live_lane_enabled_for(store, lang.as_str()) { + tracing::debug!( + path = %vname_path, + language = lang.as_str(), + "live: lane disabled for language by the precision gate" + ); + return None; + } + if let LiveLane::EditorOnly = lane { + // RFC-027 section 8.2: detection only — no receiver-type recovery, no + // signature building, no resolution. The editor's language server + // answers each position; the daemon owns both node ids (§8.2 fencing). + let source = std::fs::read(abs_path).ok()?; + let refs = match travsr_analysis::live_detect::detect_live_refs(lang, &source) { + Ok(refs) => refs, + Err(e) => { + tracing::debug!(path = %vname_path, error = %e, "live: reference detection failed"); + return None; + } + }; + if refs.is_empty() { + return None; + } + return Some((vname_path, LiveRefSet::Generic(refs))); + } + let files = [(abs_path.to_path_buf(), vname_path.clone())]; + // The extractors return different arities: TypeScript and Python are + // `(nodes, edges, unresolved)`, Rust is `(nodes, edges, unresolved, refs)` + // (the same-file `refs` the live lane does not consume). + let extraction = match lang { + travsr_core::Language::TypeScript => { + travsr_analysis::phase_b_typescript::extract_native_phase_b( + corpus, + repo_root, + Some(&files), + ) + .map(|(_, _, unresolved)| unresolved) + } + travsr_core::Language::Rust => { + travsr_analysis::phase_b_rust::extract_native_phase_b(corpus, repo_root, Some(&files)) + .map(|(_, _, unresolved, _)| unresolved) + } + travsr_core::Language::Python => { + travsr_analysis::phase_b_python::extract_native_phase_b(corpus, repo_root, Some(&files)) + .map(|(_, _, unresolved)| unresolved) + } + // Unreachable: the gate above admits only the three arms handled here. + _ => return None, + }; + let unresolved = match extraction { + Ok(unresolved) => unresolved, + Err(e) => { + tracing::debug!(path = %vname_path, error = %e, "live: call-site extraction failed"); + return None; + } + }; + // RFC-027 live edge-kind scope: the IsImplementation lane's `extends` / + // `implements` clauses. TypeScript only for now — the resolver is + // language-agnostic, but the detector is per-language and is measured before + // each language joins (RFC-027 live edge-kind scope §6.2). Never fails the + // whole pass: a detector error is a freshness miss, not a correctness one. + // TS and Python only. Rust's `impl Trait for Type` does not fit the live + // IsImplementation model and is deferred (see phase_b_rust.rs). + let inheritance = match lang { + travsr_core::Language::TypeScript => { + travsr_analysis::phase_b_typescript::extract_unresolved_inheritance( + repo_root, + Some(&files), + ) + .unwrap_or_default() + } + travsr_core::Language::Python => { + travsr_analysis::phase_b_python::extract_unresolved_inheritance(repo_root, Some(&files)) + .unwrap_or_default() + } + _ => Vec::new(), + }; + if unresolved.is_empty() && inheritance.is_empty() { + return None; + } + Some(( + vname_path, + LiveRefSet::Native { + unresolved, + inheritance, + }, + )) +} + +/// RFC-027 sections 8.3 and 9.2: record every reference an editor-only language +/// detected as `pending`, replacing the file's previous rows. +/// +/// "There is a reference here and its target is not yet known" is true and +/// useful, and saying it is what makes fail-closed honest rather than merely +/// quiet. It is also the row the editor lane upgrades in place when its provider +/// answers, which is how a language with no lexical floor still produces the +/// claims the precision meter scores (§12). +/// +/// A reference with no enclosing definition has no node to own the row and is +/// skipped, exactly as the resolver would abstain on it. +fn record_generic_pendings( + store: &mut SqliteStore, + corpus: &str, + path: &str, + detected: &travsr_analysis::live_detect::LiveRefs, +) { + let named = detected + .calls + .iter() + .chain(detected.fields.iter()) + .map(|r| (r.line, r.name.as_str())) + .chain( + detected + .inheritance + .iter() + .map(|r| (r.line, r.base_name.as_str())), + ); + let mut states: Vec = Vec::new(); + for (line, name) in named { + let Ok(Some(src)) = store.enclosing_definition_at(corpus, path, line) else { + continue; + }; + states.push(travsr_store::RefResolution { + src, + ref_line: line, + // 0, matching the native lane, so the editor's answer for this same + // reference upgrades this row rather than adding a second one. + ref_col: 0, + name: name.to_string(), + state: "pending", + resolved_dst: None, + }); + } + if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { + tracing::debug!(error = %e, "recording ref_resolution_state failed"); + } +} + +fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, abs_path: &Path) { + let Some((vname_path, refs)) = extract_live_unresolved(store, corpus, repo_root, abs_path) + else { + return; + }; + let (unresolved, inheritance) = match refs { + LiveRefSet::Native { + unresolved, + inheritance, + } => (unresolved, inheritance), + // RFC-027 section 8.3: an editor-only language has no lexical floor — + // the generic detector recovers no receiver type and builds no signature + // key, so there is nothing to resolve without a server. What this pass + // still owes the file is the honest abstention record (§9.2): every + // detected reference starts `pending`, and the editor lane upgrades the + // ones its provider answers. Writing them here is also what clears the + // rows describing the pre-edit text, which `reindex_replace` leaves + // alone. + LiveRefSet::Generic(detected) => { + record_generic_pendings(store, corpus, &vname_path, &detected); + return; + } + }; + let outcome = live_resolve::resolve_unambiguous_lexical( + store, + corpus, + &vname_path, + &unresolved, + &inheritance, + ); + if outcome.emitted > 0 { + tracing::debug!( + event = "live.file", + path = %vname_path, + emitted = outcome.emitted, + pending = outcome.pending, + "live overlay refreshed for saved file" + ); + } +} + +/// RFC-027 daemon-driven positions: the references in `abs_path` the editor +/// should resolve with a language provider, computed from the same native +/// extractor the save-path lexical lane uses. +/// +/// The daemon owns *which* references and *which* edge kind; the editor owns +/// only the exact column and the provider round-trip. This is what replaces the +/// editor's blind `identifier(` scan and lets the lane reach every language the +/// native extractor covers, not just what an English-shaped regex could spot. +fn live_resolution_targets( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Vec { + let Some((vname_path, refs)) = extract_live_unresolved(store, corpus, repo_root, abs_path) + else { + return Vec::new(); + }; + match refs { + LiveRefSet::Native { + unresolved, + inheritance, + } => { + let mut targets = live_resolve::targets_needing_editor(store, &unresolved); + targets.extend(live_resolve::inheritance_targets_needing_editor( + store, + corpus, + &vname_path, + &inheritance, + )); + targets + } + // RFC-027 section 8.3: no lexical floor runs for these languages, so + // there is no lane to partition against and every detected reference is + // an editor target. + LiveRefSet::Generic(refs) => live_resolve::generic_targets_needing_editor(&refs), + } +} + +/// RFC-027 section 8.7.5: the interface-edit closure, as editor targets. +/// +/// When the saved file adds or renames a symbol other files reference by name, +/// their edges into it were stranded — `reindex_replace` invalidated them and +/// nothing restores them, because the editor only ever publishes the document +/// that was saved. This computes those dependents (a file with a `pending` +/// reference the saved file can now satisfy) and returns each one's editor +/// targets, so the same target request re-resolves them alongside the saved +/// file. The editor keeps the initiator role (§10.1). +/// +/// The dependent set is derived from durable `pending` rows plus the saved +/// file's current parse, so it does not depend on this save's watcher event +/// having already computed a closure — the two run on independent clocks and a +/// request can arrive first. +/// +/// Bounded at `LIVE_CLOSURE_FILE_CAP` files; the editor bounds the *total* +/// provider round trips across the saved file and every dependent, so one rename +/// in a hot utility cannot turn a save into an unbounded storm of queries. A +/// dependent dirty in another editor tab is skipped by the editor, not here: the +/// daemon reads the file from disk and cannot see an unsaved buffer. +fn dependent_resolution_targets( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Vec { + // Only a live-lane file the gate has not disabled can have dependents worth + // restoring; a disabled or unsupported language costs nothing here. + let Some((lang, _lane)) = live_language(abs_path) else { + return Vec::new(); + }; + if !live_lane_enabled_for(store, lang.as_str()) { + return Vec::new(); + } + let vname_path = abs_path + .strip_prefix(repo_root) + .unwrap_or(abs_path) + .to_string_lossy() + .replace('\\', "/"); + let dependents = store + .dependents_pending_on_file(corpus, &vname_path, lang.as_str(), LIVE_CLOSURE_FILE_CAP) + .unwrap_or_default(); + let mut out = Vec::new(); + for dep in dependents { + let dep_abs = repo_root.join(&dep); + let targets = live_resolution_targets(store, corpus, repo_root, &dep_abs); + // A dependent with nothing for the editor to do (its references all + // settle lexically, or it holds none) is not worth sending. + if !targets.is_empty() { + out.push(travsr_ipc::message::DependentTargets { file: dep, targets }); + } + } + out +} + +/// RFC-027 section 12: the continuous precision meter. +/// +/// "Is the live lane worse than nothing?" is answered empirically, not by +/// assertion, and ground truth is on tap every commit. Each run compares what +/// the lane claimed against what Phase B derived at the same call sites, and +/// records the result so the per-language shipping gate has a number to read. +/// +/// Persisted to `meta` as well as logged. A log line scrolls away; the gate +/// needs a value `travsr status` can show and a human can act on. +/// +/// The reading is cumulative across runs rather than last-run-only: a single +/// commit can carry two claims, and a gate computed from a sample that small +/// would swing between 0.0 and 1.0 on noise. Disagreements are logged +/// individually at warn, because one wrong edge is the failure this whole design +/// is built to avoid, and it should be visible the moment it happens rather than +/// averaged into a ratio. +fn measure_live_precision(store: &mut SqliteStore) { + let by_lang = match store.live_precision_sample_by_language() { + Ok(s) => s, + Err(e) => { + tracing::debug!("live precision sample failed: {e:#}"); + return; + } + }; + if by_lang.is_empty() { + return; + } + + // Accumulate each language's counters under its own key, and roll the same + // deltas into the corpus-wide `live_precision` key `travsr status` reads. + // The per-language keys are what the shipping gate (`live_lane_enabled_for`) + // consults; the aggregate stays a faithful sum so the status line is + // unchanged. + let (mut run_agree, mut run_disagree, mut run_unverifiable) = (0u64, 0u64, 0u64); + for (lang, sample) in &by_lang { + if sample.claims() == 0 { + continue; + } + let prior = read_precision_totals_for(store, Some(lang)); + let _ = store.set_meta( + &live_precision_meta_key(Some(lang)), + &format!( + "{},{},{}", + prior.0 + sample.agree, + prior.1 + sample.disagree, + prior.2 + sample.unverifiable + ), + ); + + if sample.disagree > 0 { + tracing::warn!( + event = "live.precision.disagreement", + language = %lang, + disagreed = sample.disagree, + agreed = sample.agree, + "the live lane resolved a reference differently from Phase B" + ); + } + tracing::info!( + event = "live.precision", + language = %lang, + agree = sample.agree, + disagree = sample.disagree, + unverifiable = sample.unverifiable, + precision = ?sample.precision(), + coverage = sample.coverage(), + "live overlay scored against Phase B" + ); + + run_agree += sample.agree; + run_disagree += sample.disagree; + run_unverifiable += sample.unverifiable; + } + + let prior = read_precision_totals(store); + let _ = store.set_meta( + LIVE_PRECISION_META, + &format!( + "{},{},{}", + prior.0 + run_agree, + prior.1 + run_disagree, + prior.2 + run_unverifiable + ), + ); +} + +/// Cumulative corpus-wide `(agree, disagree, unverifiable)` recorded so far. +fn read_precision_totals(store: &SqliteStore) -> (u64, u64, u64) { + read_precision_totals_for(store, None) +} + +/// Cumulative `(agree, disagree, unverifiable)` for one language, or the +/// corpus-wide aggregate when `language` is `None`. +/// +/// A malformed or absent value reads as zeroes: the meter is diagnostic, and a +/// corrupt counter must never fail a commit or wrongly disable a language. +fn read_precision_totals_for(store: &SqliteStore, language: Option<&str>) -> (u64, u64, u64) { + let raw = store + .get_meta(&live_precision_meta_key(language)) + .ok() + .flatten() + .unwrap_or_default(); + let parts: Vec = raw + .split(',') + .filter_map(|p| p.trim().parse::().ok()) + .collect(); + match parts.as_slice() { + [a, d, u] => (*a, *d, *u), + _ => (0, 0, 0), + } +} + +/// The `meta` key holding cumulative live-lane precision counters. The +/// corpus-wide aggregate is `live_precision`; each language namespaces under it +/// as `live_precision.` (matching `nodes.language`). +fn live_precision_meta_key(language: Option<&str>) -> String { + match language { + Some(lang) => format!("{LIVE_PRECISION_META}.{lang}"), + None => LIVE_PRECISION_META.to_string(), + } +} + +/// Cumulative live-lane precision counters, as `agree,disagree,unverifiable`. +const LIVE_PRECISION_META: &str = "live_precision"; + +/// RFC-027 section 12: the per-language shipping bar. Below this the lane is +/// disabled for a language ("a measured decision, not a guess"). +const LIVE_PRECISION_GATE: f64 = 0.99; + +/// Minimum verified live claims before the gate may DISABLE a language. +/// +/// Below this the sample is too small to act on as an *adverse* reading, so an +/// already-shipped language keeps running and gathering evidence rather than +/// being silenced for good by one early disagreement. Precision-first, but not +/// trigger-shy. +const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; + +/// RFC-027 sections 12 and 8.7.5: the languages the live lane ships ENABLED, +/// each vouched at measured precision ≥ 0.99. +/// +/// This is the strict-gate opt-in (§8.7.6 decision). RFC §12 says a language +/// ships enabled only at measured precision ≥ 0.99, but the earlier policy left +/// an *unmeasured* language enabled so it could earn a reading — which, when +/// eleven non-native languages joined at once, meant nine shipped enabled with +/// no reading at all. Strict gating inverts that default: a language is disabled +/// until it is on this list, and it joins the list only after a real server plus +/// its ratification oracle measured it at ≥ 0.99 (the §11.3 procedure). Adding a +/// `nodes.language` value here is therefore the per-language opt-in the RFC asks +/// for, auditable in git history against the reading that earned it. +/// +/// `nodes.language` values (what the meter keys on). JavaScript has no value of +/// its own — `.js`/`.jsx` map to `typescript` — so one entry covers both. +/// +/// The list grows as §11.3 fills in. Each entry's earning reading: +/// - `typescript`, `python` — native, gated at ship since RFC §14 Phase 0–4. +/// - `rust` — native; re-measured end to end at 1.0000 (`4,0,0`, §11.3). +/// - `go` — gopls, 1.0000 (`4,0,0`). +/// - `dart` — Dart analysis server + travsr-lang-dart, 1.0000 (`3,0,1`). +/// - `swift` — sourcekit-lsp + travsr-swift-index-emitter, 1.0000 (`3,0,1`). +/// - `cpp` — clangd + scip-clang, 1.0000 (`1,0,0`); recall capped by §8.7.3 +/// (out-of-line method defs abstain), so the field read is what it emits and +/// that is correct. +/// - `java` — jdtls + scip-java, 1.0000 (`3,0,0`, call/field/is-impl); scip-java +/// needs a Maven/Gradle build to emit references (a build-less fixture swept +/// and read `0,0,4`). +/// - `csharp` — csharp-ls + scip-dotnet, 1.0000 (`6,0,0`, call/field/is-impl). +/// Live edges resolve via csharp-ls with no extra setup. The oracle needs +/// `DOTNET_ROOT`; the plugin host resolves and injects it when `dotnet` is on +/// the daemon's PATH, and the travsr-lang csharp emitter now self-resolves it +/// from well-known installs when a minimal-PATH daemon leaves the host unable +/// to (travsr-lang `fix/csharp-dotnet-root-sandbox-path`). If the oracle still +/// cannot run, the edges persist as `live` rather than ratifying — honest, +/// not wrong. +/// +/// Deliberately absent: +/// - `c`, `objectivec` — edges ratify correctly but scip-clang writes no +/// matching `edge_sites`, so the meter reads `0,0,2` unverifiable (§10 item 4). +/// - `ruby` — the LSP lane is fundamentally weak: an untyped receiver +/// (`def run(s); s.start; end`) gives ruby-lsp nothing to resolve, so it +/// abstains. This is the §8.4 dynamic-language limit, not an install gap. +/// - `php` — the LSP lane *works* with a typed receiver (`run(Session $s)`) via +/// Intelephense, but the oracle scip-php needs Composer (absent here), so no +/// reading yet. Opt in once measured. +/// - `scala`, `kotlin` — blocked by JVM build-toolchain setup, not the live +/// lane: Scala's SemanticDB oracle needs `sbt compile`; Kotlin's KLS (its +/// oracle *and* its live server) needs a working Gradle build, which the +/// Gradle/Kotlin/JDK version matrix on this machine would not produce. +const LIVE_LANE_SHIPPED: &[&str] = &[ + "typescript", + "rust", + "python", + "go", + "dart", + "swift", + "cpp", + "java", + "csharp", +]; + +/// Force-enable a language for measurement, bypassing the strict opt-in gate +/// (but never the adverse-meter safety below). +/// +/// A language absent from [`LIVE_LANE_SHIPPED`] cannot run, so it can never +/// produce the claims the meter needs to measure it — the deadlock the strict +/// gate would otherwise create. `TRAVSR_LIVE_LANE_MEASURE` (comma-separated +/// `nodes.language` values) lifts the gate for exactly the languages being +/// measured, so the §11.3 harness can drive one long enough to earn a reading. +/// Set on the daemon running a fixture; never in production, where the shipped +/// list is authoritative. +fn live_lane_measure_forced(language: &str) -> bool { + std::env::var("TRAVSR_LIVE_LANE_MEASURE") + .ok() + .is_some_and(|v| v.split(',').any(|l| l.trim() == language)) +} + +/// RFC-027 section 12: is the live lane enabled for `language`? +/// +/// Two independent conditions, both of which must hold: +/// +/// 1. **No adverse reading.** The cumulative per-language meter must not carry a +/// statistically meaningful adverse sample — at least +/// [`LIVE_PRECISION_MIN_SAMPLE`] verified claims *and* precision below +/// [`LIVE_PRECISION_GATE`]. This disables even a shipped language whose +/// measured precision has degraded, and it is self-healing: a later run whose +/// Phase B agreement lifts the cumulative precision back over the bar +/// re-enables it on its own. +/// 2. **Vouched to ship** (§8.7.6). The language is on [`LIVE_LANE_SHIPPED`], or +/// force-enabled for measurement ([`live_lane_measure_forced`]). An +/// unmeasured language is disabled until it earns a reading and is opted in, +/// which makes "ships enabled only at measured precision ≥ 0.99" literally +/// true rather than aspirational. +fn live_lane_enabled_for(store: &SqliteStore, language: &str) -> bool { + let (agree, disagree, unverifiable) = read_precision_totals_for(store, Some(language)); + let sample = travsr_store::LivePrecision { + agree, + disagree, + unverifiable, + }; + let verified = agree + disagree; + if let Some(p) = sample.precision() { + if verified >= LIVE_PRECISION_MIN_SAMPLE && p < LIVE_PRECISION_GATE { + return false; + } + } + LIVE_LANE_SHIPPED.contains(&language) || live_lane_measure_forced(language) +} + +/// RFC-027 section 8.3: retire the live overlay and clear resolved pendings. +/// +/// Extracted so the convergence property test can drive ratification directly +/// instead of racing the background scheduler. +/// +/// Fail-open on both steps: a sweep that errors leaves `live` rows labeled as +/// what they are, which is stale but honest, and never wrong. +fn ratify_live_overlay(store: &mut SqliteStore, ratified: &[String]) { + // RFC-027 section 12: score the overlay before retiring it. Order matters — + // after the Phase B writes, because that is the truth being compared + // against, and before the sweep, because the sweep discards the evidence. + measure_live_precision(store); + match store.sweep_live_edges_for_languages(ratified) { + Ok(0) => {} + Ok(n) => tracing::debug!( + event = "live.swept", + swept = n, + languages = ?ratified, + "retired live edges Phase B did not re-derive" + ), + Err(e) => tracing::warn!("live overlay sweep failed: {e:#}"), + } + // A reference whose enclosing node now has an outgoing edge is no longer + // pending, whoever resolved it. + if let Err(e) = store.clear_resolved_pending_refs() { + tracing::debug!("clearing resolved pending refs failed: {e:#}"); + } +} + +/// The `nodes.language` values whose live overlay this Phase B run may retire. +/// +/// `report.ran` names the languages whose analyzers completed. Two adjustments +/// map that onto how nodes are actually labeled: +/// +/// - JavaScript has no `nodes.language` of its own. `Language::from_extension` +/// maps `.js`/`.jsx` to `TypeScript`, so a run that analysed JavaScript +/// ratifies rows labeled `typescript`. +/// - The LSIF pass is TypeScript's and is not represented in `report.ran`, so +/// `typescript` is added whenever it produced edges. +/// +/// A language absent from this set keeps its live edges. That is the #712 +/// partial-success case: the marker advances because *something* progressed, +/// but a crashed sidecar's truth was never re-derived, and discarding its +/// overlay would take away precision without replacing it. +fn ratified_languages(report: &PhaseBReport, lsif_ran: bool) -> Vec { + let mut langs: Vec = Vec::with_capacity(report.ran.len() + 1); + for lang in &report.ran { + // JavaScript nodes are labeled `typescript`; see above. + let mapped = if lang == "javascript" { + "typescript" + } else { + lang.as_str() + }; + if !langs.iter().any(|l| l == mapped) { + langs.push(mapped.to_string()); + } + } + if lsif_ran && !langs.iter().any(|l| l == "typescript") { + langs.push("typescript".to_string()); + } + langs +} + fn run_background_phase_b_inner( repo_root: &Path, store: &std::sync::Mutex, @@ -3766,6 +4480,26 @@ fn run_background_phase_b_inner( // --force`, not on an endless background loop. let made_progress = report.crashed.is_empty() || !report.ran.is_empty() || !lsif_edges.is_empty(); + + // RFC-027 section 8.3: ratify the live overlay. + // + // Position matters twice over. This runs *after* every SCIP/LSIF/native + // write above, so any live edge Phase B re-derived has already been + // relabelled in place by those writes and what remains marked `live` is + // exactly what Phase B did not re-derive. And it runs *before* the marker + // advance below, while the same store lock is still held, so no in-process + // reader can observe the graph between ratification and the marker. + // + // Not one WAL transaction, and it does not need to be. The Phase B write + // path is already several self-committing statements under a process-local + // mutex, so there is no single transaction to append this to. Because the + // order is insert-then-delete, the only state an out-of-process reader can + // catch mid-flight is a superset of the ratified graph, never a gap. The + // hazard the RFC worried about was the gap; the ordering dissolves it. + if made_progress { + ratify_live_overlay(&mut s, &ratified_languages(&report, !lsif_edges.is_empty())); + } + if made_progress { let _ = s.set_meta("phase_b_commit", &target_sha); // #583: the semantic layer now matches the working tree again. @@ -3999,6 +4733,8 @@ pub fn reindex_files( let mut any_changed = false; // Accumulate Tier-0 dirty callers across all files in this batch. let mut callers_all = travsr_core::DirtySet::default(); + // Paths this batch rewrote, for the end-of-batch orphan sweep below. + let mut written_paths: Vec = Vec::new(); // L5b: unlike `index_paths_parallel`'s full-repo `paths`, this commit-hook // batch only contains changed files — a `.m`/`.mm` sibling may not be in it @@ -4104,6 +4840,7 @@ pub fn reindex_files( callers_all.extend(report.callers); } any_changed = true; + written_paths.push(vname_path.clone()); // Collect FFI markers for the repo-level pass (RFC-005). all_ffi_markers.extend(out.ffi_markers); // Collect Cargo workspace dep markers for the A2 repo-level pass. @@ -4229,6 +4966,29 @@ pub fn reindex_files( // The LSIF pass now runs only from `init_repo` (full initial index). // Per-commit LSIF delta is tracked as DEBT(travsr-25). + // Bring the incremental path up to the init path's "no orphan edges" + // invariant. `flush_staging_to_production` drops staged edges with a + // missing endpoint once every node in the batch has landed; nothing did the + // equivalent here, so speculative import candidates (`./user` becomes + // `user.ts`/`user.tsx`/`user.js`, only one of which exists) survived every + // incremental write. A full index and an incremental index of the same tree + // therefore disagreed by two edges per TypeScript import, and `fsck` + // reported orphans on a healthy repo. + // + // After the loop, not inside it: an edge from an already-processed file to + // one later in this same batch is legitimately dangling until that file's + // nodes land. This is the same position the staging flush occupies. + if any_changed { + match store.sweep_orphan_edges_for_paths(&corpus, &written_paths) { + Ok(0) => {} + Ok(n) => tracing::debug!( + swept = n, + "reindex: dropped speculative edges with no destination node" + ), + Err(e) => tracing::warn!("reindex: orphan edge sweep failed: {e}"), + } + } + Ok(callers_all) } @@ -7371,64 +8131,1242 @@ mod tests { ); } - /// §9 CI invariant: incremental delete + reindex must produce the same - /// node and edge counts as a full rebuild on the mutated tree. + /// RFC-027 section 12 / Phase 3 gate: measured precision on a fixture corpus. /// - /// This is the executable form of principle #4 ("full reindex and an - /// incremental reindex of the same codebase produce identical graphs") and - /// would have caught #402. + /// This is the number that decides whether the lane ships. The bar is 0.99 + /// with a target of zero false positives, because for a product whose thesis + /// is "zero structural hallucinations" a wrong edge is not a quality + /// regression but a breach of the value proposition. + /// + /// The fixture is deliberately adversarial rather than a happy path: one + /// unambiguous callee the lexical lane should resolve, and one name defined + /// on two different classes, which is exactly the method-on-receiver case it + /// must refuse. A meter that only ever sees resolvable calls proves nothing. #[test] - fn incremental_delete_matches_full_rebuild() { + fn measured_live_precision_clears_the_shipping_gate() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let tmp = tempfile::tempdir().unwrap(); git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); - std::fs::write(tmp.path().join("svc.ts"), "export class Svc { run() {} }\n").unwrap(); - std::fs::write(tmp.path().join("app.ts"), "export class App { go() {} }\n").unwrap(); + // Unambiguous: only one `charge` in the corpus. + std::fs::write( + tmp.path().join("src/billing.ts"), + "export class Billing {\n charge(): void {}\n}\n", + ) + .unwrap(); + // Ambiguous: `ping` is defined on two classes, so the lexical lane must + // abstain rather than pick one. + std::fs::write( + tmp.path().join("src/a.ts"), + "export class A {\n ping(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/b.ts"), + "export class B {\n ping(): void {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("src/caller.ts"); + std::fs::write( + &caller, + "import { Billing } from \"./billing\";\n export function run(bill: Billing, x: any): void {\n \x20 bill.charge();\n \x20 x.ping();\n }\n", + ) + .unwrap(); std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); init_repo(tmp.path()).unwrap(); std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); - // Incremental path: delete svc.ts on disk, then run reindex_files. - let svc_path = tmp.path().join("svc.ts"); - std::fs::remove_file(&svc_path).unwrap(); + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + + // A save, then the overlay: this is the mid-edit state. { - let db_path = tmp.path().join(".travsr/graph.db"); + let mut text = std::fs::read_to_string(&caller).unwrap(); + text.push_str("\nexport function again(bill: Billing): void {\n bill.charge();\n}\n"); + std::fs::write(&caller, text).unwrap(); let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); - reindex_files(std::slice::from_ref(&svc_path), tmp.path(), &mut store).unwrap(); + reindex_files(std::slice::from_ref(&caller), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); } - let (inc_nodes, inc_edges) = { - let db_path = tmp.path().join(".travsr/graph.db"); - let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - (store.node_count().unwrap(), store.edge_count().unwrap()) - }; - // Full-rebuild path: wipe .travsr and re-init on the same (now mutated) tree. - std::fs::remove_dir_all(tmp.path().join(".travsr")).unwrap(); - std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); - init_repo(tmp.path()).unwrap(); - std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); - let (full_nodes, full_edges) = { - let db_path = tmp.path().join(".travsr/graph.db"); + // The ambiguous call must never have produced a claim at all: an + // abstention is not a low-confidence answer, it is no answer. + { let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - (store.node_count().unwrap(), store.edge_count().unwrap()) - }; - - assert_eq!( - inc_nodes, full_nodes, - "incremental delete + reindex must yield same node count as full rebuild \ - (incremental={inc_nodes}, full={full_nodes})" - ); - assert_eq!( - inc_edges, full_edges, - "incremental delete + reindex must yield same edge count as full rebuild \ - (incremental={inc_edges}, full={full_edges})" - ); - } + let pending = store + .pending_refs_in_file(&corpus, "src/caller.ts") + .unwrap(); + assert!( + pending.iter().any(|(name, _)| name == "ping"), + "the ambiguous receiver must abstain, got pending {pending:?}" + ); + } - /// #376 Phase 1: `travsr init` on a docs fixture produces exactly one - /// `file` node and one `doc-chunk` node per heading section, with the + // Now commit and let Phase B run for real. The meter lives at + // ratification for a reason that only shows up here: `reindex_replace` + // deletes the edited file's `edge_sites` on save, so between the save and + // the next Phase B run there is no call-site evidence to check anything + // against. Measuring earlier does not produce a pessimistic number, it + // produces no number at all. + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "edit", + ]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let sha = read_head_commit_sha(tmp.path()).expect("HEAD after commit"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.set_meta("last_commit", &sha).unwrap(); + } + let store_mutex = std::sync::Mutex::new(travsr_store::SqliteStore::open(&db_path).unwrap()); + run_background_phase_b_inner(tmp.path(), &store_mutex); + drop(store_mutex); + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let sample = store.live_precision_sample().unwrap(); + + assert!( + sample.claims() > 0, + "precondition: the lane must have claimed something to score" + ); + assert!( + sample.coverage() > 0.0, + "precondition: Phase B must have left call-site evidence, or this gate \ + asserts nothing. Got {sample:?}" + ); + assert_eq!( + sample.disagree, 0, + "a live edge that disagrees with Phase B is a false positive, and the \ + target is zero: {sample:?}" + ); + let precision = sample + .precision() + .expect("a verified sample must yield a precision"); + assert!( + precision >= 0.99, + "measured precision {precision} is below the 0.99 shipping gate: {sample:?}" + ); + } + + /// RFC-027 Phase 4 gate: the same shipping bar, measured for **Rust**, split + /// out by language. This is the differential the phase turns on — the live + /// lane's Rust claims scored against what the commit-time Phase B (native + /// tree-sitter resolution, and rust-analyzer LSIF when present) derived at + /// the same call sites. + /// + /// The fixture is cross-file on purpose: a same-file call is resolved by + /// Phase A and never becomes an `UnresolvedCall`, so the live lane would have + /// nothing to claim. It exercises the three Rust shapes the lane resolves: + /// `Zoo::assemble()` (associated call — the already-qualified `method:Zoo.*` + /// sig used verbatim), `z.describe()` (method call whose receiver type the + /// extractor recovers, rebuilt to `method:Zoo.describe`), and `helper()` (a + /// bare free function). The names are deliberately NOT in the extractor's + /// `NOISE_NAMES` set (`new`, `from`, `clone`, …), which would drop them + /// before the lane ever saw them. All are unambiguous repo-wide, so §7.3a + /// resolves them with no language server, which keeps the test hermetic. + #[test] + fn measured_rust_live_precision_clears_the_per_language_gate() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + std::fs::write( + tmp.path().join("src/zoo.rs"), + "pub struct Zoo;\n\nimpl Zoo {\n pub fn assemble() -> Zoo {\n Zoo\n }\n pub fn describe(&self) -> i32 {\n 7\n }\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/util.rs"), + "pub fn helper() -> i32 {\n 42\n}\n", + ) + .unwrap(); + let main = tmp.path().join("src/main.rs"); + std::fs::write( + &main, + "mod zoo;\nmod util;\nuse zoo::Zoo;\nuse util::helper;\n\nfn run(z: &Zoo) {\n let _a = Zoo::assemble();\n let _d = z.describe();\n let _n = helper();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + + // A save, then the overlay: the mid-edit state. The appended function + // adds a third cross-file call the live lane must resolve. + { + let mut text = std::fs::read_to_string(&main).unwrap(); + text.push_str("\nfn run_again(z: &Zoo) {\n let _a = Zoo::assemble();\n let _d = z.describe();\n}\n"); + std::fs::write(&main, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&main), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &main); + } + + // Commit and let Phase B run for real, so the meter has ratified + // call-site evidence to score against (see the TS gate test for why the + // meter must run here and not on save). + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "edit", + ]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let sha = read_head_commit_sha(tmp.path()).expect("HEAD after commit"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.set_meta("last_commit", &sha).unwrap(); + } + let store_mutex = std::sync::Mutex::new(travsr_store::SqliteStore::open(&db_path).unwrap()); + run_background_phase_b_inner(tmp.path(), &store_mutex); + drop(store_mutex); + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let by_lang = store.live_precision_sample_by_language().unwrap(); + let rust = by_lang + .get("rust") + .copied() + .unwrap_or_else(|| panic!("no rust bucket in the meter; got {by_lang:?}")); + + assert!( + rust.claims() > 0, + "precondition: the Rust lane must have claimed something to score: {rust:?}" + ); + assert!( + rust.coverage() > 0.0, + "precondition: Phase B must have left Rust call-site evidence, or this \ + gate asserts nothing. Got {rust:?}" + ); + assert_eq!( + rust.disagree, 0, + "a live Rust edge that disagrees with Phase B is a false positive, and \ + the target is zero: {rust:?}" + ); + let precision = rust + .precision() + .expect("a verified Rust sample must yield a precision"); + assert!( + precision >= LIVE_PRECISION_GATE, + "measured Rust precision {precision} is below the {LIVE_PRECISION_GATE} \ + per-language shipping gate: {rust:?}" + ); + + // The gate is per-language: the enable switch reads Rust's own reading, + // which this run has just written above the bar. + assert!( + live_lane_enabled_for(&store, "rust"), + "a Rust reading at or above the bar must keep the lane enabled" + ); + } + + /// The enable switch is measured, not vacuous: it disables a language ONLY on + /// a meaningful adverse sample, and re-enables when the cumulative reading + /// recovers. Driven directly through the per-language meta keys so it does not + /// depend on a live Phase B run. + #[test] + fn the_precision_gate_disables_a_language_only_on_a_meaningful_adverse_sample() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + // No reading at all, but Rust is a shipped/vouched language, so enabled. + assert!(live_lane_enabled_for(&store, "rust")); + + // Below the bar but too few verified claims to act on: still enabled + // (Rust is shipped, and the sample is not a meaningful adverse reading). + store.set_meta("live_precision.rust", "4,1,0").unwrap(); + assert!( + live_lane_enabled_for(&store, "rust"), + "5 verified claims is below the min sample; must not disable yet" + ); + + // Enough claims AND below the bar: disabled. (18 agree, 5 disagree => + // 23 verified, precision ~0.78.) + store.set_meta("live_precision.rust", "18,5,3").unwrap(); + assert!( + !live_lane_enabled_for(&store, "rust"), + "a meaningful sample below 0.99 must disable the language" + ); + + // A disagreement-free follow-up lifts the cumulative reading back over + // the bar: self-healing, re-enabled. (198 agree, 2 disagree => 0.99.) + store.set_meta("live_precision.rust", "198,2,0").unwrap(); + assert!( + live_lane_enabled_for(&store, "rust"), + "recovered precision at the bar must re-enable the language" + ); + + // The switch is per-language: a disabled Rust must not disable TypeScript. + store.set_meta("live_precision.rust", "18,5,3").unwrap(); + assert!(!live_lane_enabled_for(&store, "rust")); + assert!( + live_lane_enabled_for(&store, "typescript"), + "the gate must be scoped per language, not corpus-wide" + ); + } + + /// RFC-027 section 8.7.6: an unmeasured, un-vouched language ships DISABLED. + /// + /// This is the strict-gate decision. The earlier policy left an unmeasured + /// language enabled to earn a reading; when eleven non-native languages + /// joined at once, that meant nine shipped enabled with no reading. Now a + /// language is disabled until it is on `LIVE_LANE_SHIPPED`, and the + /// measurement harness lifts the gate for exactly the language it is + /// measuring via `TRAVSR_LIVE_LANE_MEASURE`. + #[test] + fn an_unmeasured_language_ships_disabled_until_opted_in() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + // A vouched language with no reading is enabled; an un-vouched one is not. + assert!(live_lane_enabled_for(&store, "go"), "go is shipped"); + assert!( + !live_lane_enabled_for(&store, "scala"), + "an unmeasured non-shipped language must be disabled by the strict gate" + ); + assert!(!live_lane_enabled_for(&store, "kotlin")); + + // Force-enabling for measurement lifts the gate for exactly that language. + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "scala,kotlin"); + assert!(live_lane_enabled_for(&store, "scala")); + assert!(live_lane_enabled_for(&store, "kotlin")); + assert!( + !live_lane_enabled_for(&store, "ruby"), + "the force list is per-language, not a blanket override" + ); + std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); + + // The adverse-meter safety still wins over a force-enable: a measured + // language below the bar stays disabled even while being measured. + let mut store = store; + store.set_meta("live_precision.scala", "18,5,0").unwrap(); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "scala"); + assert!( + !live_lane_enabled_for(&store, "scala"), + "a meaningful adverse reading disables a language even under force" + ); + std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); + } + + /// The gate must not be clearable by an empty sample. + /// + /// A lane that resolved nothing verifiable has not earned a perfect score, + /// and reporting 1.0 there would let "we measured nothing" pass as "we + /// measured perfectly" — which is exactly how a precision gate stops meaning + /// anything. + #[test] + fn an_unverifiable_sample_reports_no_precision_rather_than_a_perfect_one() { + let empty = travsr_store::LivePrecision::default(); + assert_eq!(empty.precision(), None); + assert_eq!(empty.coverage(), 0.0); + + let all_unverifiable = travsr_store::LivePrecision { + agree: 0, + disagree: 0, + unverifiable: 40, + }; + assert_eq!( + all_unverifiable.precision(), + None, + "forty unchecked claims must not read as perfect precision" + ); + assert_eq!(all_unverifiable.coverage(), 0.0); + + let mixed = travsr_store::LivePrecision { + agree: 99, + disagree: 1, + unverifiable: 100, + }; + assert_eq!(mixed.precision(), Some(0.99)); + assert_eq!( + mixed.coverage(), + 0.5, + "coverage must expose the unchecked half" + ); + } + + /// The cumulative counter survives a malformed value rather than failing a + /// commit over a diagnostic. + #[test] + fn the_precision_counter_tolerates_a_corrupt_value() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + assert_eq!(read_precision_totals(&store), (0, 0, 0)); + store.set_meta(LIVE_PRECISION_META, "not,a,number").unwrap(); + assert_eq!(read_precision_totals(&store), (0, 0, 0)); + store.set_meta(LIVE_PRECISION_META, "3,1,7").unwrap(); + assert_eq!(read_precision_totals(&store), (3, 1, 7)); + } + + /// RFC-027 Phase 2 gate, Invariant #4: ratifying the live overlay returns + /// the graph to exactly what it was before the overlay existed. + /// + /// ```text + /// graph(G) --overlay--> G' --ratify--> G + /// ``` + /// + /// This is the load-bearing half of the convergence argument. The overlay + /// is allowed to be non-deterministic — it depends on which language server + /// a developer happens to be running — and this is the fence that makes + /// that safe: whatever it contained, retiring it cannot leave a trace. + /// + /// It holds because the overlay is purely **additive**. `put_edge_live` + /// creates edges that were absent and refreshes its own, but never relabels + /// a row another lane wrote, so every row the sweep can delete is one the + /// live lane created. An earlier version relabelled `tree-sitter` rows, + /// and this test is what caught it: the sweep then deleted pre-existing + /// truth instead of returning the graph to it. + /// + /// Fingerprints compare provenance as well as endpoints, so a `live` row + /// left sitting where a ratified one belongs fails here rather than + /// passing on a matching count. + #[test] + fn ratifying_the_overlay_restores_the_pre_overlay_graph() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let order = tmp.path().join("src/order.ts"); + + // Phase A only, exactly what a save does: content changes, the file's + // outgoing edges are rebuilt from the parse, and the new call site has + // no semantic edge. This is the mid-edit degradation RFC-027 covers. + { + let mut text = std::fs::read_to_string(&order).unwrap(); + text.push_str("\nexport function expressOrder(u: User): void {\n u.save();\n}\n"); + std::fs::write(&order, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&order), tmp.path(), &mut store).unwrap(); + } + let degraded = graph_fingerprint(&db_path); + + // Overlay. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + let live_rows = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(); + assert!( + live_rows > 0, + "precondition: the overlay must be non-empty or this asserts nothing" + ); + assert_ne!( + graph_fingerprint(&db_path), + degraded, + "precondition: the overlay must actually have changed the graph" + ); + + // Ratify with nothing re-derived: the strictest case for the sweep. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(), + 0, + "no live edge may survive the run that ratifies it" + ); + assert_eq!( + graph_fingerprint(&db_path), + degraded, + "retiring the overlay must restore the graph exactly, not merely its size" + ); + } + + /// The destructive case the additive rule exists to prevent. + /// + /// An interface edit re-resolves the files that *reference* the edited one + /// (section 6.3). Those files were never re-parsed, so their existing + /// `tree-sitter` edges are still in place. If the overlay relabelled them + /// `live`, the ratification sweep would delete pre-existing truth rather + /// than returning the graph to it, and a caller edge would simply vanish. + /// + /// So: run the overlay over a file that was *not* re-indexed, then ratify, + /// and require the graph to be unchanged throughout. + #[test] + fn the_overlay_cannot_destroy_an_edge_it_did_not_create() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let order = tmp.path().join("src/order.ts"); + + // order.ts keeps every edge the full index gave it. This is a closure + // re-resolution, not a save. + let before = graph_fingerprint(&db_path); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + assert_eq!( + graph_fingerprint(&db_path), + before, + "resolving a file whose edges already exist must change nothing" + ); + + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + assert_eq!( + graph_fingerprint(&db_path), + before, + "ratification must not delete an edge the overlay did not create" + ); + } + + /// The other half: an overlay edge Phase B *does* re-derive is ratified in + /// place and survives the sweep, now carrying the ratified provenance. + /// + /// This is why the sweep can be a blunt delete. By the time it runs, every + /// edge Phase B re-derived has already been relabelled by the ratification + /// write, so what is still marked `live` is exactly what Phase B did not + /// re-derive. + #[test] + fn an_overlay_edge_phase_b_rederives_is_ratified_in_place() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + edit_and_reindex_order(&tmp, &db_path, &corpus); + let overlay: Vec = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_edges(&store) + }; + assert!(!overlay.is_empty(), "precondition: an overlay edge exists"); + + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + // Stand in for Phase B re-deriving these edges natively. This is the + // same call the daemon makes after a Phase B run. + store + .write_phase_b_batch(&[], &overlay, "tree-sitter") + .unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!( + store.count_edges_with_provenance("live").unwrap(), + 0, + "the row is no longer live once Phase B has re-derived it" + ); + for e in &overlay { + let survived = store + .iter_edges_from(e.src) + .unwrap() + .into_iter() + .any(|x| x.dst == e.dst && x.kind == e.kind); + assert!( + survived, + "an edge Phase B re-derived must survive ratification, not be swept" + ); + } + } + + /// The sweep is scoped to languages that completed, so a crashed sidecar's + /// overlay survives rather than being discarded with nothing to replace it + /// (#712 partial success advances the marker regardless). + #[test] + fn ratification_spares_a_language_that_did_not_complete() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + edit_and_reindex_order(&tmp, &db_path, &corpus); + let before = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(); + assert!(before > 0, "precondition: an overlay exists"); + + // Rust "completed"; TypeScript did not. The TypeScript overlay stays. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["rust".to_string()]); + } + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(), + before, + "a language whose truth was never re-derived must keep its overlay" + ); + } + + /// `ratified_languages` maps analyzer names onto how nodes are labeled. + #[test] + fn ratified_languages_maps_javascript_and_the_lsif_pass_onto_typescript() { + let js_only = PhaseBReport { + ran: vec!["javascript".to_string()], + ..PhaseBReport::default() + }; + assert_eq!( + ratified_languages(&js_only, false), + vec!["typescript".to_string()], + "JavaScript nodes are labeled typescript, so that is what ratifies" + ); + + // The LSIF pass is TypeScript's and never appears in `ran`. + let nothing_ran = PhaseBReport::default(); + assert_eq!( + ratified_languages(¬hing_ran, true), + vec!["typescript".to_string()] + ); + + // Nothing ran and no LSIF edges: sweep nothing at all. + assert!(ratified_languages(¬hing_ran, false).is_empty()); + + // No duplicate when both signals point at typescript. + let both = PhaseBReport { + ran: vec!["typescript".to_string(), "javascript".to_string()], + ..PhaseBReport::default() + }; + assert_eq!( + ratified_languages(&both, true), + vec!["typescript".to_string()] + ); + } + + /// Append a second caller to `order.ts` and re-index it, the way a save + /// lands: content changes, so `reindex_files` does not skip it on the hash, + /// Phase A rewrites the file's outgoing edges, and the new call site has no + /// semantic edge until something resolves it. + /// + /// Returns the overlay outcome so a caller can assert on it. + fn edit_and_reindex_order( + tmp: &tempfile::TempDir, + db_path: &std::path::Path, + corpus: &str, + ) -> std::path::PathBuf { + let order = tmp.path().join("src/order.ts"); + let mut text = std::fs::read_to_string(&order).unwrap(); + text.push_str("\nexport function expressOrder(u: User): void {\n u.save();\n}\n"); + std::fs::write(&order, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(db_path).unwrap(); + reindex_files(std::slice::from_ref(&order), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, corpus, tmp.path(), &order); + order + } + + /// A two-file TypeScript repo where `order.ts` calls into `user.ts`, + /// indexed. Shared by the RFC-027 Phase 1 and Phase 2 tests. + fn repo_with_a_call() -> (tempfile::TempDir, std::path::PathBuf, String) { + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write( + tmp.path().join("src/user.ts"), + "export class User {\n save(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/order.ts"), + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + (tmp, db_path, corpus) + } + + /// Every `provenance='live'` edge currently in the store. + /// + /// `all_edges` returns `(src, dst, kind, provenance)` tuples, so rebuild the + /// core `Edge` from them for the ratification write. + fn live_edges(store: &travsr_store::SqliteStore) -> Vec { + store + .all_edges() + .unwrap() + .into_iter() + .filter(|(_, _, _, prov)| prov == "live") + .filter_map(|(src, dst, kind, _)| { + travsr_core::EdgeKind::from_str(&kind).map(|k| travsr_core::Edge::new(src, dst, k)) + }) + .collect() + } + + /// A content fingerprint of the whole graph: every node, and every edge with + /// its provenance, in a stable order. Comparing counts alone would miss a + /// live edge sitting where a ratified one belongs, which is the exact + /// failure the convergence property exists to rule out. + fn graph_fingerprint(db_path: &std::path::Path) -> (Vec, Vec) { + let store = travsr_store::SqliteStore::open(db_path).unwrap(); + ( + store.node_fingerprint().unwrap(), + store.edge_fingerprint().unwrap(), + ) + } + + /// Invariant #4, orphan dimension: an incremental edit must leave the graph + /// as free of dangling edges as a full index would. + /// + /// TypeScript import resolution is speculative by construction — `./user` + /// emits a `resolves-to` candidate for `user.ts`, `user.tsx` and `user.js` + /// because it cannot know which exists without touching the store. The init + /// path drops the losers when staging is flushed, which its own comment + /// calls making "no orphan edges" a store invariant. The incremental path + /// had no equivalent, so every ordinary edit to a file with a relative + /// import left two dead edges behind and `fsck` reported orphans on a + /// perfectly healthy repo. + #[test] + fn an_incremental_edit_leaves_no_orphan_edges() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + let user = tmp.path().join("src/user.ts"); + let order = tmp.path().join("src/order.ts"); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "precondition: a full index leaves no orphans" + ); + + // Edit both files, the way a rename lands incrementally. + std::fs::write(&user, "export class User {\n persist(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.persist();\n}\n", + ) + .unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(&[user.clone(), order.clone()], tmp.path(), &mut store).unwrap(); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "an incremental edit must not leave dangling edges the full path drops" + ); + } + + /// The sweep runs after the whole batch, so an edge into a file that is + /// created later in the same batch survives. Sweeping per file would delete + /// it, since its destination node does not exist yet at that point. + #[test] + fn the_orphan_sweep_spares_a_forward_reference_within_one_batch() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + // Only the importer exists at index time. + let order = tmp.path().join("src/order.ts"); + std::fs::write(&order, "export function placeOrder(): void {}\n").unwrap(); + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + // Now add both the import and its target in one batch, importer first. + let user = tmp.path().join("src/user.ts"); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {}\n", + ) + .unwrap(); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + + let db_path = tmp.path().join(".travsr/graph.db"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(&[order.clone(), user.clone()], tmp.path(), &mut store).unwrap(); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!(store.count_orphans().unwrap(), 0, "no orphans either way"); + let resolves: u64 = store.count_edges_with_provenance("tree-sitter").unwrap(); + assert!( + resolves > 0, + "the real import edge must survive a same-batch forward reference" + ); + } + + /// RFC-027 Phase 1 gate: a rename must leave no ghost live edge. + /// + /// The hazard the live lane introduces is an edge to a symbol that no + /// longer exists. `reindex_replace` deletes the inbound edges of a symbol + /// whose NodeId vanished, so a `live` edge is cleaned up exactly as a + /// `tree-sitter` or `scip` one is. This test is what keeps that true, and + /// it is the difference between honest staleness and a fabricated target. + #[test] + fn a_rename_leaves_no_ghost_live_edge() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let user = tmp.path().join("src/user.ts"); + // A real save adds a call site the overlay then resolves, which is the + // only way a live edge exists to be renamed away from. + edit_and_reindex_order(&tmp, &db_path, &corpus); + assert!( + count_live_edges(&db_path) > 0, + "precondition: a live edge must exist to rename away from" + ); + + // Rename User.save -> User.persist. The old NodeId vanishes, so every + // inbound edge to it, live included, must go with it. + std::fs::write(&user, "export class User {\n persist(): void {}\n}\n").unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "a rename must leave no edge pointing at a node that no longer exists" + ); + } + + /// A deleted file takes its inbound live edges with it, the same way + /// `delete_file` takes both directions for every other provenance. + #[test] + fn a_delete_leaves_no_ghost_live_edge() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let user = tmp.path().join("src/user.ts"); + edit_and_reindex_order(&tmp, &db_path, &corpus); + assert!( + count_live_edges(&db_path) > 0, + "precondition: a live edge exists" + ); + + std::fs::remove_file(&user).unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "a deleted file must take its inbound live edges with it" + ); + } + + /// RFC-027 section 9.2: an abstention is recorded, not dropped. + /// + /// `save` here is ambiguous (two classes define it), which is exactly the + /// method-on-receiver case the lexical lane must refuse. Refusing quietly + /// and refusing honestly look identical in the edge table; they differ in + /// whether anything can later say *why* the answer is thin. + /// RFC-027 section 8: a non-native language reaches the live lane through + /// on-save reference *detection* alone. Go has Phase A nodes and no native + /// Phase B extractor, so its references become editor targets — a + /// same-package cross-file call the graph could never resolve on its own, + /// and a field read as `ref/field` so it never reads as a caller (#757). + #[test] + fn a_go_file_yields_editor_targets_from_the_generic_detector() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("session.go"), + "package main\n\ntype Session struct {\n\tcount int\n}\n\nfunc (s *Session) Start() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) int {\n\ts.Start()\n\treturn s.count\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); + let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); + + let start = targets + .iter() + .find(|t| t.name == "Start") + .expect("the method call must be an editor target"); + assert_eq!(start.ref_line, 4); + assert_eq!(start.edge_kind, "ref/call"); + assert_eq!(start.provider, "definition"); + + let count = targets + .iter() + .find(|t| t.name == "count") + .expect("the field read must be an editor target"); + assert_eq!(count.ref_line, 5); + assert_eq!(count.edge_kind, "ref/field"); + } + + /// RFC-027 section 8: the generic path is one path, not a Go path. Java + /// exercises the two halves Go cannot: a grammar with *separate* call and + /// field nodes (so no callee disambiguation runs), and an `extends` / + /// `implements` clause, which Go has none of. The clause sits on the + /// implementing class's own declaration, so its edge source is that class in + /// the saved file and save-invalidation self-heals it (§8.4) — the property + /// Rust's detached `impl` block lacks (§7). + #[test] + fn a_java_file_yields_call_field_and_implements_targets() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("Base.java"), + "public class Base {\n public int total;\n public void submit() {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("Order.java"); + std::fs::write( + &caller, + "public class Order extends Base {\n public int run(Base b) {\n b.submit();\n return b.total;\n }\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); + // Java is on LIVE_LANE_SHIPPED (measured 3,0,0 → 1.0000, §11.3), so the + // gate admits it with no reading and no force flag needed. + let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); + + let by_name = |name: &str| { + targets + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("{name} must be an editor target, got {targets:?}")) + }; + assert_eq!(by_name("submit").edge_kind, "ref/call"); + assert_eq!(by_name("total").edge_kind, "ref/field"); + let base = by_name("Base"); + assert_eq!(base.edge_kind, "is-implementation"); + assert_eq!(base.ref_line, 1, "the clause is on the class declaration"); + assert!(targets.iter().all(|t| t.provider == "definition")); + } + + /// RFC-027 section 8.7.5: the interface-edit closure reaches the editor lane. + /// + /// `run.go` references a method the graph cannot settle, so its save records + /// the reference as `pending`. When `session.go` — which defines that method + /// — is saved, its target request must name `run.go` as a dependent so the + /// editor re-resolves it, rather than leaving `run.go`'s live edge stranded + /// until `run.go` is itself saved. Before the fix `dependent_resolution_ + /// targets` did not exist and the request answered with the saved file alone. + #[test] + fn saving_a_definition_names_its_pending_dependents_as_targets() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + let session = tmp.path().join("session.go"); + std::fs::write( + &session, + "package main\n\ntype Session struct{}\n\nfunc (s *Session) Helper() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) {\n\ts.Helper()\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + + // `run.go`'s save records its reference to `Helper` as pending: a generic + // language has no lexical floor, so every detected reference abstains + // (§8.3) and the pending row is the honest record the editor upgrades. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + // A save of `session.go` (which defines `Helper`) must surface `run.go` + // as a dependent carrying that reference as an editor target. + let dependents = dependent_resolution_targets(&store, &corpus, tmp.path(), &session); + let run = dependents + .iter() + .find(|d| d.file == "run.go") + .unwrap_or_else(|| panic!("run.go must be a dependent, got {dependents:?}")); + assert!( + run.targets.iter().any(|t| t.name == "Helper"), + "the stranded reference must be an editor target, got {:?}", + run.targets + ); + + // A file that defines nothing any pending reference names has no + // dependents — a body edit stays local (§6.1), no closure fan-out. + let none = dependent_resolution_targets(&store, &corpus, tmp.path(), &caller); + assert!( + none.iter().all(|d| d.file != "session.go"), + "run.go defines nothing session.go is pending on, got {none:?}" + ); + } + + /// The lexical floor is native-only (section 8.3): the generic detector + /// recovers no receiver type and builds no signature key, so there is + /// nothing for it to match on and the save path must not parse the file to + /// discard the result. + #[test] + fn a_go_save_emits_nothing_without_an_editor() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("session.go"), + "package main\n\ntype Session struct{}\n\nfunc (s *Session) Start() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) {\n\ts.Start()\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!( + store.count_edges_with_provenance("live").unwrap(), + 0, + "an editor-only language must emit nothing on save" + ); + // Abstention is recorded, not dropped (§9.2): every detected reference + // is a pending row the editor lane later upgrades in place, and it is + // what gives the precision meter a claim to score for this language. + let pending = store.pending_refs_in_file(&corpus, "run.go").unwrap(); + assert!( + pending + .iter() + .any(|(name, line)| name == "Start" && *line == 4), + "the unresolved call must leave a pending row, got {pending:?}" + ); + } + + #[test] + fn an_abstention_is_recorded_as_pending() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + std::fs::write( + tmp.path().join("src/a.ts"), + "export class A {\n ping(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/b.ts"), + "export class B {\n ping(): void {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("src/caller.ts"); + std::fs::write( + &caller, + "export function go(x: any): void {\n x.ping();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let pending = store + .pending_refs_in_file(&corpus, "src/caller.ts") + .unwrap(); + assert!( + pending.iter().any(|(name, _)| name == "ping"), + "an untyped receiver must leave a pending row, got {pending:?}" + ); + } + + /// Count `provenance='live'` rows. Shared by the Phase 1 ghost-edge tests. + fn count_live_edges(db_path: &std::path::Path) -> u64 { + travsr_store::SqliteStore::open(db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap() + } + + /// §9 CI invariant: incremental delete + reindex must produce the same + /// node and edge counts as a full rebuild on the mutated tree. + /// + /// This is the executable form of principle #4 ("full reindex and an + /// incremental reindex of the same codebase produce identical graphs") and + /// would have caught #402. + #[test] + fn incremental_delete_matches_full_rebuild() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write(tmp.path().join("svc.ts"), "export class Svc { run() {} }\n").unwrap(); + std::fs::write(tmp.path().join("app.ts"), "export class App { go() {} }\n").unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + // Incremental path: delete svc.ts on disk, then run reindex_files. + let svc_path = tmp.path().join("svc.ts"); + std::fs::remove_file(&svc_path).unwrap(); + { + let db_path = tmp.path().join(".travsr/graph.db"); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&svc_path), tmp.path(), &mut store).unwrap(); + } + let (inc_nodes, inc_edges) = { + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + (store.node_count().unwrap(), store.edge_count().unwrap()) + }; + + // Full-rebuild path: wipe .travsr and re-init on the same (now mutated) tree. + std::fs::remove_dir_all(tmp.path().join(".travsr")).unwrap(); + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + let (full_nodes, full_edges) = { + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + (store.node_count().unwrap(), store.edge_count().unwrap()) + }; + + assert_eq!( + inc_nodes, full_nodes, + "incremental delete + reindex must yield same node count as full rebuild \ + (incremental={inc_nodes}, full={full_nodes})" + ); + assert_eq!( + inc_edges, full_edges, + "incremental delete + reindex must yield same edge count as full rebuild \ + (incremental={inc_edges}, full={full_edges})" + ); + } + + /// #376 Phase 1: `travsr init` on a docs fixture produces exactly one + /// `file` node and one `doc-chunk` node per heading section, with the /// signature format matching the plan's anchor scheme. #[test] fn init_repo_indexes_markdown_docs_with_exact_counts() { @@ -9987,7 +11925,30 @@ fn handle_watch_event( WatchEvent::Upsert(path) => { let mut s = store.lock().unwrap_or_else(|e| e.into_inner()); match reindex_files(std::slice::from_ref(&path), repo_root, &mut s) { - Ok(callers) => enqueue_dirty_callers(callers, repo_root, index_tx), + Ok(callers) => { + // RFC-027 sections 6 and 7.3a: refresh this file's live + // overlay, and on an interface edit the overlay of the files + // that reference it. + // + // Only here, on the save path — the commit path arms a Phase + // B refresh that re-derives the same edges, so doing it + // there would be waste inside `git commit`'s latency. + // + // `callers` is non-empty exactly when a symbol other files + // reference vanished, which is the interface-edit signal + // (section 6.2). Their edges into this file were deleted by + // `reindex_replace`, so re-resolving them is what stops a + // rename from silently dropping every inbound live edge. + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + live_resolve_file(&mut s, &corpus, repo_root, &path); + for dependent in callers.iter().take(LIVE_CLOSURE_FILE_CAP) { + let abs = repo_root.join(dependent); + if abs != path && abs.is_file() { + live_resolve_file(&mut s, &corpus, repo_root, &abs); + } + } + enqueue_dirty_callers(callers, repo_root, index_tx) + } Err(e) => tracing::warn!(path=%path.display(), err=%e, "watcher reindex failed"), } } @@ -10212,6 +12173,90 @@ fn handle_control_message( enqueue_dirty_callers(dirty, repo_root, index_tx); (ControlResponse::ok(None), false) } + Ok(ControlMessage::RequestLiveResolutionTargets { + repo_root: reported_root, + session, + file, + buffer_version: _, + }) => { + // Same identity guard as ReportLiveResolution: discovery enumerates a + // namespace, so the request has to say which repo it is for. + let reported = travsr_ipc::normalize_repo_root(std::path::Path::new(&reported_root)); + if reported != travsr_ipc::normalize_repo_root(repo_root) { + return ( + ControlResponse::err("request is for a different repo".to_string()), + false, + ); + } + + let s = store.lock().unwrap_or_else(|e| e.into_inner()); + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + let abs_path = repo_root.join(&file); + let own = live_resolution_targets(&s, &corpus, repo_root, &abs_path); + // RFC-027 section 8.7.5: also re-resolve the files whose edges this + // save can restore, so a rename in one file heals its dependents + // without each being saved in turn. + let dependents = dependent_resolution_targets(&s, &corpus, repo_root, &abs_path); + drop(s); + + tracing::debug!( + event = "live.targets", + session = %session, + file = %file, + count = own.len(), + dependents = dependents.len(), + "live resolution targets computed" + ); + let mut resp = ControlResponse::ok(None); + resp.result = serde_json::to_value(travsr_ipc::message::LiveResolutionTargets { + own, + dependents, + }) + .ok(); + (resp, false) + } + Ok(ControlMessage::ReportLiveResolution { + repo_root: reported_root, + session, + file, + resolutions, + }) => { + // Same identity guard as ReportLspDiagnostics (#698 review, P1): + // discovery enumerates a namespace, not a repo, so a report has to + // say who it is for. Here the stakes are higher than for the editor + // plane, because these positions become graph edges: accepting + // another repo's report would write edges between nodes chosen by + // paths that mean something else in this graph. + let reported = travsr_ipc::normalize_repo_root(std::path::Path::new(&reported_root)); + if reported != travsr_ipc::normalize_repo_root(repo_root) { + return ( + ControlResponse::err("report is for a different repo".to_string()), + false, + ); + } + + let mut s = store.lock().unwrap_or_else(|e| e.into_inner()); + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + let outcome = + live_resolve::apply_live_resolutions(&mut s, &corpus, &file, &resolutions); + drop(s); + + tracing::debug!( + event = "live.report", + session = %session, + file = %file, + emitted = outcome.emitted, + pending = outcome.pending, + "live resolution report applied" + ); + ( + ControlResponse::ok(Some(format!( + "{} live, {} pending", + outcome.emitted, outcome.pending + ))), + false, + ) + } Ok(ControlMessage::ReportLspDiagnostics { repo_root: reported_root, session, diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs new file mode 100644 index 00000000..b2443f9c --- /dev/null +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -0,0 +1,1752 @@ +//! RFC-027 live semantic resolution — the between-commits overlay. +//! +//! Phase B (SCIP) is commit-gated on purpose, so between commits only Phase A +//! runs and a cross-file reference stays unresolved: Tree-sitter knows that +//! `user.save()` is a call, not *which* `save`. This module closes that window +//! by emitting `provenance='live'` edges for references it can resolve +//! **precisely**, and abstaining otherwise. +//! +//! The division of labour is the whole design (RFC-027 section 5): +//! +//! ```text +//! Tree-sitter -> DETECTS a reference exists +//! SCIP graph -> OWNS node identity (Kythe VName) +//! LSP -> DISAMBIGUATES a specific position +//! Commit SCIP -> RATIFIES the region, heals drift +//! ``` +//! +//! Two emit lanes, both fail-closed (section 8.1): +//! +//! - [`resolve_unambiguous_lexical`] (section 7.3a) needs no language server. +//! A name with exactly one definition in the graph has only one thing it can +//! mean, so resolving it is a lookup, not a guess. +//! - [`apply_live_resolutions`] (section 7.3b) consumes positions an editor's +//! language provider resolved. The editor answers "what does this position +//! point at"; this module maps both ends to nodes itself, so identity stays +//! SCIP's (section 8.2 fencing rule). Nothing here mints a VName. +//! +//! Anything ambiguous, unmappable, or cross-corpus abstains. A missing edge +//! fails safe (fall back to grep); a wrong edge fails silent and dangerous, +//! which for a "zero structural hallucinations" product is a breach of the +//! value proposition rather than a quality regression. + +use travsr_core::{Edge, EdgeKind, InheritanceRef, NodeId, UnresolvedCall}; +use travsr_ipc::message::{LiveResolution, LiveResolutionTarget}; +use travsr_store::{SqliteStore, Store}; + +/// What one live-resolution pass did, for logging and the Phase 3 precision +/// meter. `pending` is not a failure: it is the fail-closed path working. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LiveOutcome { + /// Edges written with `provenance='live'`. + pub emitted: usize, + /// References that could not be resolved precisely and were abstained on. + pub pending: usize, +} + +impl LiveOutcome { + fn emit(&mut self) { + self.emitted += 1; + } + fn abstain(&mut self) { + self.pending += 1; + } +} + +/// Section 7.3b: turn editor-resolved positions into `live` edges. +/// +/// `file` is the dirty file the references live in, repo-relative with forward +/// slashes, matching the graph's path keys. +/// +/// Each resolution is mapped independently and a failure abstains rather than +/// aborting the batch: one unmappable position must not cost the others their +/// freshness. +pub fn apply_live_resolutions( + store: &mut SqliteStore, + corpus: &str, + file: &str, + resolutions: &[LiveResolution], +) -> LiveOutcome { + let mut outcome = LiveOutcome::default(); + // Section 12: what this lane claimed, so the precision meter can score it. + // Without a claim row an editor-resolved edge is invisible to the meter, and + // a language that runs this lane alone (every non-native one, §8.3) could + // never earn its per-language gate a reading. + let mut states: Vec = Vec::with_capacity(resolutions.len()); + for r in resolutions { + // The claim is keyed on the reference, so the edge's own source is the + // key's `src`. A resolution whose line maps to no enclosing definition + // has no reference to record and abstains below anyway. + let src = store + .enclosing_definition_at(corpus, file, r.ref_line) + .ok() + .flatten(); + let claimed = match resolve_one(store, corpus, file, r) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => Some(edge.dst), + Err(e) => { + // A write failure is a freshness loss, never a correctness + // one: the commit-gated path still ratifies this region. + tracing::debug!(error = %e, "live edge write failed"); + None + } + }, + None => None, + }; + if claimed.is_some() { + outcome.emit(); + } else { + outcome.abstain(); + } + if let Some(src) = src { + // `ref_col` is 0, not the column the editor sent, so this row + // collides with the save-path pass's row for the same reference and + // upgrades it in place. Keying on the real column would fork one + // reference into a stale `pending` row and a `resolved` one. + states.push(travsr_store::RefResolution { + src, + ref_line: r.ref_line, + ref_col: 0, + name: r.name.clone(), + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); + } + } + if let Err(e) = store.upsert_ref_resolution_states(&states) { + // Losing the claim costs the meter its evidence, never the graph its + // correctness. + tracing::debug!(error = %e, "recording editor-lane ref_resolution_state failed"); + } + if outcome.emitted > 0 || outcome.pending > 0 { + tracing::debug!( + event = "live.resolved", + file = %file, + emitted = outcome.emitted, + pending = outcome.pending, + "live semantic resolution pass complete" + ); + } + outcome +} + +/// Map one editor resolution to an edge, or `None` to abstain. +fn resolve_one(store: &SqliteStore, corpus: &str, file: &str, r: &LiveResolution) -> Option { + // The editor names the edge kind it resolved (RFC-027 live edge-kind scope). + // Restricted to the Bucket-B kinds the lane may emit, so a malformed or + // hostile report cannot make it write a kind it was never scoped to. + let edge = live_edge_kind(&r.edge_kind)?; + // The reference's enclosing definition is the edge's source. A reference at + // top level (no enclosing function) has no caller node to attach to. + let src = store + .enclosing_definition_at(corpus, file, r.ref_line) + .ok() + .flatten()?; + // Section 7.5: the node the editor pointed at, restricted to the kinds valid + // for this edge kind (a field ref lands on a `field` node, an implements + // clause on an interface/trait, a call on a definition). The kind set is the + // gate: a target of the wrong kind, or a position in no matching span (a + // node_modules file, a generated stub, an unindexed file), maps to nothing + // and abstains (§8.1). + let dst = store + .enclosing_node_at(corpus, &r.target_path, r.target_line, target_kinds(edge)) + .ok() + .flatten()?; + edge_if_sound(store, src, dst, edge) +} + +/// RFC-027 section 6: what an edit actually invalidates. +/// +/// The distinction is not cosmetic. *Outgoing* edges (edited file to others) are +/// recomputable from the edited file alone. *Incoming* edges (others to the +/// edited file) are not, because their sources live elsewhere — and they stay +/// correct only while the referenced surface is unchanged. That asymmetry is +/// the whole reason a body edit can be resolved locally and an interface edit +/// cannot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditKind { + /// No symbol's referenced surface changed. Re-resolve this file's outgoing + /// references and touch nothing else. This is the common case, and the one + /// that has to stay cheap or the live lane is not worth having. + Body, + /// A symbol other files reference was renamed, removed, or replaced. Their + /// edges into this file may now be wrong, so the reverse closure is + /// re-resolved (section 6.3). + Interface, +} + +/// Classify an edit from the report `reindex_replace` already returns. +/// +/// Deliberately does not compute a second diff. `ReplaceReport` is derived +/// inside the same transaction that rewrote the file, from the authoritative +/// old-versus-new NodeId sets, so any diff computed here would be a less +/// reliable copy of it. `removed_count` counts symbols whose id vanished, and +/// `callers` names the files that had edges into them. +/// +/// Conservative by construction: a rename is a remove plus an add, so it lands +/// in `Interface` without needing to be told apart from a delete. Section 16.2 +/// notes that mis-classifying toward `Interface` costs recall and never +/// correctness, and this is where that choice is made. +pub fn classify_edit(report: &travsr_core::ReplaceReport) -> EditKind { + if report.removed_count > 0 || !report.callers.is_empty() { + EditKind::Interface + } else { + EditKind::Body + } +} + +/// Section 6.3: the files whose edges into `changed` may now be wrong. +/// +/// Reverse lookup over `iter_edges_to`, which the covering index +/// `idx_edges_dst_kind_cov` serves without touching the nodes table for the +/// edge scan itself. +/// +/// **Depth one, not transitive** (Risk R4). A rename invalidates the edges that +/// point *at* the renamed symbol; it does not invalidate the callers of those +/// callers, whose own referenced surface did not change. Walking transitively +/// would pull in most of a repo from one edit to a hot file, for no additional +/// correctness. The existing Tier-0 propagation makes the same choice for the +/// same reason. +/// +/// `MAX_CLOSURE_FILES` bounds the result even at depth one: a symbol with +/// thousands of callers is a hot utility, and re-resolving every one of them on +/// each keystroke would cost more than the freshness is worth. Truncation costs +/// recall, which the commit-gated path then repairs. +pub fn reverse_closure( + store: &SqliteStore, + changed: &[NodeId], +) -> std::collections::HashSet { + let mut files = std::collections::HashSet::new(); + for &id in changed { + let Ok(incoming) = store.iter_edges_to(id) else { + continue; + }; + for edge in incoming { + if files.len() >= MAX_CLOSURE_FILES { + return files; + } + if let Ok(Some(node)) = store.get_node(edge.src) { + files.insert(node.vname.path); + } + } + } + files +} + +/// Upper bound on files pulled into one reverse closure (Risk R4). +const MAX_CLOSURE_FILES: usize = 64; + +/// Section 7.3a: emit for calls whose callee signature is unambiguous repo-wide. +/// +/// This lane needs no language server and is the zero-regression floor: with no +/// editor attached it is the only lane that runs. It is seeded by the +/// [`UnresolvedCall`]s the native Phase B call-site extractor already produces, +/// so the live lane detects references with the same machinery Phase B does +/// rather than inventing a second extractor. +/// +/// **It deliberately does not reuse the daemon's `resolve_unresolved_calls`.** +/// That resolver is recall-biased by design: its own contract says +/// "overconnection is safe: when multiple nodes share a signature all matches +/// are emitted. PPR damping absorbs the noise." The live lane has the opposite +/// policy (section 8.1): it is precision-first and abstains on ambiguity, +/// because an un-ratified wrong edge is exactly the failure the RFC forbids. +/// The two policies are both correct for their lane, and they must not be +/// merged. +/// +/// `lookup_nodes_exact(sig, None)` returns the candidate set; only a set of +/// size one is emitted. `alt_callee_sig` is tried when the primary misses (the +/// #709 PascalCase class-vs-function ambiguity), under the same exactly-one +/// rule, never to widen the net. +pub fn resolve_unambiguous_lexical( + store: &mut SqliteStore, + corpus: &str, + path: &str, + unresolved: &[UnresolvedCall], + inheritance: &[InheritanceRef], +) -> LiveOutcome { + let mut outcome = LiveOutcome::default(); + // Section 9.2: every reference this pass saw, and what became of it. An + // abstention is recorded, not dropped — "there is a call here and its + // target is not yet known" is true and useful, and saying it is what makes + // fail-closed honest rather than merely quiet. + // + // Calls and inheritance clauses share one `states` vector because + // `replace_ref_resolution_states` clears the whole file's rows before + // inserting: recording them in two passes would make the second wipe the + // first. One pass, one replace. + let mut states: Vec = + Vec::with_capacity(unresolved.len() + inheritance.len()); + + for call in unresolved { + let name = travsr_core::ident::leaf_of(&call.callee_sig).to_string(); + // The target this lane claimed, kept whatever later happens to the edge. + // Section 12's meter needs the claim itself: by ratification a re-derived + // live edge has been relabelled and an unratified one is about to be + // swept, so the edges table can no longer say what the lane decided. + let claimed = match lexical_one(store, call) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => Some(edge.dst), + Err(e) => { + tracing::debug!(error = %e, "live lexical edge write failed"); + None + } + }, + None => None, + }; + if claimed.is_some() { + outcome.emit(); + } else { + outcome.abstain(); + } + // `ref_col` is 0: the native extractor records the call's line but not + // its column. The PK tolerates it, and a second call to the same name + // on one line is the only collision it can cause, which merges two + // identical facts rather than losing one. + states.push(travsr_store::RefResolution { + src: call.src, + ref_line: call.caller_line, + ref_col: 0, + name, + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); + } + + // RFC-027 live edge-kind scope: the IsImplementation floor. An `extends` / + // `implements` clause whose base type has exactly one definition repo-wide + // resolves with no language server, exactly as the call floor above does. An + // ambiguous or cross-file-only base abstains and becomes an editor target. + for r in inheritance { + // The clause sits on the implementing class's own declaration, so the + // edge source is the definition enclosing that line. + let src = store + .enclosing_definition_at(corpus, path, r.line) + .ok() + .flatten(); + let claimed = match src.and_then(|src| inheritance_edge(store, src, &r.base_name)) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => Some(edge.dst), + Err(e) => { + tracing::debug!(error = %e, "live inheritance edge write failed"); + None + } + }, + None => None, + }; + if claimed.is_some() { + outcome.emit(); + } else { + outcome.abstain(); + } + if let Some(src) = src { + states.push(travsr_store::RefResolution { + src, + ref_line: r.line, + ref_col: 0, + name: r.base_name.clone(), + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); + } + } + + if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { + // Losing the pending record costs the freshness note its detail, never + // the graph its correctness. + tracing::debug!(error = %e, "recording ref_resolution_state failed"); + } + if outcome.emitted > 0 || outcome.pending > 0 { + tracing::debug!( + event = "live.lexical", + path = %path, + emitted = outcome.emitted, + pending = outcome.pending, + "unambiguous-lexical live resolution complete" + ); + } + outcome +} + +fn lexical_one(store: &SqliteStore, call: &UnresolvedCall) -> Option { + let edge = lexical_edge_kind(call); + let dst = candidate_signatures(call) + .into_iter() + .find_map(|sig| unique_definition(store, &sig, edge))?; + edge_if_sound(store, call.src, dst, edge) +} + +/// The edge kind a native call-site record resolves to. The extractor encodes a +/// field access as `field:...` (with a recovered receiver type); everything else +/// it emits is a call. A field read must become `ref/field`, never `ref/call`, +/// so it never surfaces as a caller in `get_callers` / `get_blast_radius` (#757) +/// while still appearing as a use site in `find_references`. +fn lexical_edge_kind(call: &UnresolvedCall) -> EdgeKind { + if call.callee_sig.starts_with("field:") { + EdgeKind::RefField + } else { + EdgeKind::RefCall + } +} + +/// RFC-027 daemon-driven positions: the references the editor should resolve via +/// a language provider, from the native extractor's reference set. +/// +/// Fail-closed and surgical, the two properties §7.6 asks of the LSP lane: +/// +/// - **A reference the lexical lane can settle is skipped.** `lexical_one` +/// already resolves it repo-wide with no server, so sending it to the editor +/// would waste a provider round trip on an answer the save-path lane produces +/// deterministically. The two lanes therefore partition the reference set with +/// no overlap: lexical takes the unambiguous ones, the editor takes the rest. +/// - **Only method/field references go to the editor.** A bare free-function or +/// constructor call the lexical lane could not resolve has no unique definition +/// in the graph; the editor's provider would resolve it to that same +/// (missing-or-ambiguous) target and the daemon would abstain anyway. Method +/// and field references on a receiver whose type the extractor could not +/// recover are exactly the disambiguation §7.3b exists for. +/// +/// The editor is handed the line and the name, not a column: the native +/// extractor records the reference's line but not its column, and the editor can +/// recover the column by finding `name` on the line far more cheaply than the +/// extractor could be taught to carry UTF-16 offsets. +pub fn targets_needing_editor( + store: &SqliteStore, + unresolved: &[UnresolvedCall], +) -> Vec { + unresolved + .iter() + .filter_map(|call| { + // The lexical lane owns anything it can resolve without a server. + if lexical_one(store, call).is_some() { + return None; + } + // Only a method or field reference benefits from LSP disambiguation. + let is_field = call.callee_sig.starts_with("field:"); + if !(call.is_method_call || is_field) { + return None; + } + // No line means no position for the editor to query (older + // extractors record `0`); skip rather than send an unusable target. + if call.caller_line == 0 { + return None; + } + Some(LiveResolutionTarget { + ref_line: call.caller_line, + name: travsr_core::ident::leaf_of(&call.callee_sig).to_string(), + edge_kind: lexical_edge_kind(call).as_str().to_string(), + // Calls and fields are both answered by the definition provider; + // the implementation provider is for implements/override targets, + // which arrive through a different detector. + provider: "definition".to_string(), + }) + }) + .collect() +} + +/// RFC-027 daemon-driven positions, IsImplementation half: the `extends` / +/// `implements` clauses the lexical floor could not settle, as editor targets. +/// +/// A clause whose base type is unique repo-wide is the lexical floor's job +/// (`resolve_unambiguous_lexical`), so it is skipped here for the same +/// no-overlap reason the call path skips resolvable calls. What remains — an +/// ambiguous base, or one defined only in another file — is what the editor's +/// **definition** provider resolves: the reference names the base type, and we +/// want where that type is *defined* (the class/interface node), not its +/// implementors, so the provider is `definition`, not `implementation`. +pub fn inheritance_targets_needing_editor( + store: &SqliteStore, + corpus: &str, + path: &str, + inheritance: &[InheritanceRef], +) -> Vec { + inheritance + .iter() + .filter_map(|r| { + let src = store.enclosing_definition_at(corpus, path, r.line).ok()??; + // The lexical floor owns any base with a unique repo-wide definition. + if inheritance_edge(store, src, &r.base_name).is_some() { + return None; + } + Some(LiveResolutionTarget { + ref_line: r.line, + name: r.base_name.clone(), + edge_kind: EdgeKind::IsImplementation.as_str().to_string(), + provider: "definition".to_string(), + }) + }) + .collect() +} + +/// RFC-027 section 8.2/8.3: editor targets for a language with no native +/// Phase B extractor (Go, Java, C#, C/C++, and the rest of the sixteen). +/// +/// Where the native path partitions its reference set between two lanes — the +/// lexical floor takes what it can settle repo-wide, the editor takes the rest — +/// there is nothing to partition here. The generic detector deliberately +/// recovers no receiver type and builds no signature key, so +/// [`resolve_unambiguous_lexical`] has nothing to match on and never runs for +/// these languages. Every detected reference is therefore an editor target: +/// filtering one out on a guess about what the floor "might" have resolved would +/// drop the reference outright rather than hand it to the other lane. +/// +/// With no language server installed nothing is answered and every reference +/// abstains, which is exactly section 7.3c — today's commit-gated behavior, zero +/// regression. +/// +/// Duplicates on `(line, name, kind)` are collapsed. The editor recovers a +/// target's column by finding the name on its line, so two references to the +/// same name on one line resolve to the same position; sending both would buy a +/// second provider round trip for an answer already in hand. +pub fn generic_targets_needing_editor( + refs: &travsr_analysis::live_detect::LiveRefs, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(refs.calls.len() + refs.fields.len()); + let mut push = |line: u32, name: &str, kind: EdgeKind| { + // A reference with no line has no position for the editor to query. + if line == 0 || !seen.insert((line, name.to_string(), kind)) { + return; + } + out.push(LiveResolutionTarget { + ref_line: line, + name: name.to_string(), + edge_kind: kind.as_str().to_string(), + // All three kinds resolve to where the target is *defined*, which is + // the definition provider. `implementation` answers the opposite + // question (who implements this) and is not what any of these want. + provider: "definition".to_string(), + }); + }; + for c in &refs.calls { + push(c.line, &c.name, EdgeKind::RefCall); + } + for f in &refs.fields { + push(f.line, &f.name, EdgeKind::RefField); + } + for i in &refs.inheritance { + push(i.line, &i.base_name, EdgeKind::IsImplementation); + } + out +} + +/// The unique `IsImplementation` edge from `src` (the implementing class) to the +/// base type named `base_name`, or `None` to abstain. +fn inheritance_edge(store: &SqliteStore, src: NodeId, base_name: &str) -> Option { + let dst = unique_base_definition(store, base_name)?; + edge_if_sound(store, src, dst, EdgeKind::IsImplementation) +} + +/// The single class / interface / trait / … named `base_name` repo-wide, or +/// `None` when there are zero or more than one. +/// +/// Tries the exact signature under each kind valid for an `IsImplementation` +/// target (`class:Foo`, `interface:Foo`, …), which is how Phase A keys these +/// nodes, and requires exactly one match across all of them. Two definitions of +/// the same name — the cross-file ambiguity the editor's provider exists for — +/// abstain rather than guess, the same precision guarantee as the call floor. +fn unique_base_definition(store: &SqliteStore, base_name: &str) -> Option { + let kinds = target_kinds(EdgeKind::IsImplementation); + let mut found: Option = None; + for kind in kinds { + let sig = format!("{kind}:{base_name}"); + let Ok(nodes) = store.lookup_nodes_exact(&sig, None) else { + continue; + }; + for n in nodes { + if !kinds.contains(&n.kind.as_str()) { + continue; + } + match found { + Some(id) if id != n.id => return None, + Some(_) => {} + None => found = Some(n.id), + } + } + } + found +} + +/// The exact signatures a call could name, most specific first. +/// +/// This mirrors how the native extractor encodes a call site, which is *not* +/// simply `callee_sig`. For `u.save()` the extractor emits +/// `callee_sig = "fn:save"` with `is_method_call = true` and the receiver type +/// recovered into `recv_type`, while the definition node is `method:User.save`. +/// Looking up `callee_sig` alone therefore finds nothing. +/// +/// A method or field access **without** a recovered receiver type yields no +/// candidate at all, and so abstains. That is deliberate: an untyped receiver +/// is exactly the method-on-receiver ambiguity section 7.3a cannot settle and +/// section 7.3b's LSP lane exists for. Guessing by leaf name here is what the +/// recall-biased Phase B resolver does, and it is the wrong policy for an +/// un-ratified edge. +fn candidate_signatures(call: &UnresolvedCall) -> Vec { + let leaf = travsr_core::ident::leaf_of(&call.callee_sig); + if let Some(recv) = call.recv_type.as_deref() { + // Positive type evidence: resolve against the qualified definition. + let kind = if call.callee_sig.starts_with("field:") { + "field" + } else { + "method" + }; + return vec![format!("{kind}:{recv}.{leaf}")]; + } + if call.is_method_call || call.callee_sig.starts_with("field:") { + return Vec::new(); + } + // A free function or constructor names its definition directly. + let mut sigs = vec![call.callee_sig.clone()]; + // #709: a second exact try for the genuinely ambiguous PascalCase shape, + // never a widening of the net. + if let Some(alt) = call.alt_callee_sig.clone() { + sigs.push(alt); + } + sigs +} + +/// The single node named by `signature` and valid as a `edge` target, or `None` +/// when there are zero or more than one. +/// +/// `lookup_nodes_exact(sig, None)` documents exactly this contract: one row is +/// an unambiguous match, more than one is "genuinely ambiguous; caller must +/// disambiguate". Candidates whose kind is not valid for `edge` are filtered out +/// first, so the count is over real targets. Ambiguity abstains, which is the +/// whole precision guarantee of section 7.3a. +fn unique_definition(store: &SqliteStore, signature: &str, edge: EdgeKind) -> Option { + let candidates = store.lookup_nodes_exact(signature, None).ok()?; + let kinds = target_kinds(edge); + let defs: Vec<&travsr_core::Node> = candidates + .iter() + .filter(|n| kinds.contains(&n.kind.as_str())) + .collect(); + match defs.as_slice() { + [only] => Some(only.id), + _ => None, + } +} + +/// Shared soundness gate for both lanes. +/// +/// Section 8.2 fencing rule: live edges are intra-corpus only, so they can +/// never violate RFC-005's `src.corpus == dst.corpus` invariant or feed a +/// synthetic symbol into the bridge registry (ADR-009 Rule 4). Self-edges are +/// dropped because a symbol referencing itself carries no traversal value and +/// would show up as a spurious self-loop in `get_callers`. +/// +/// `kind` is the edge kind to emit (RFC-027 live edge-kind scope): the lane is +/// no longer call-only, so the caller names it. The target-kind gate lives in +/// [`target_kinds`], applied before this by whichever lane found `dst`. +fn edge_if_sound(store: &SqliteStore, src: NodeId, dst: NodeId, kind: EdgeKind) -> Option { + if src == dst { + return None; + } + let src_node = store.get_node(src).ok().flatten()?; + let dst_node = store.get_node(dst).ok().flatten()?; + if src_node.vname.corpus != dst_node.vname.corpus { + return None; + } + Some(Edge::new(src, dst, kind)) +} + +/// The edge kinds the live lane is permitted to emit (Bucket B). Anything else — +/// a structural kind, a cross-language `ffi/call`, an unknown string — is refused +/// so a malformed or hostile editor report cannot make the lane write an edge +/// kind it was never scoped to. +fn live_edge_kind(s: &str) -> Option { + let kind = EdgeKind::from_str(s)?; + matches!( + kind, + EdgeKind::RefCall + | EdgeKind::RefField + | EdgeKind::RefImports + | EdgeKind::IsImplementation + | EdgeKind::Overrides + ) + .then_some(kind) +} + +/// The node kinds a live edge of `edge` may point at. This is the whole +/// precision gate on the *target*: a candidate of any other kind is filtered out +/// and the reference abstains rather than mint an edge to the wrong node kind +/// (§8.1). The *source* is always a code body, gated separately by +/// `enclosing_definition_at`. +fn target_kinds(edge: EdgeKind) -> &'static [&'static str] { + match edge { + EdgeKind::RefCall => DEFINITION_KINDS, + EdgeKind::RefField => &["field"], + // A named import specifier resolves to the exported definition it binds. + EdgeKind::RefImports => DEFINITION_KINDS, + // `class C implements I` / `impl Trait for T` points at the contract. + EdgeKind::IsImplementation => &["interface", "trait", "class", "struct", "protocol"], + // A subclass method overriding a base method points at the base method. + EdgeKind::Overrides => &["method", "fn", "function"], + // The live lane emits no other kind; `live_edge_kind` already refused it. + _ => &[], + } +} + +/// Kinds a call / import reference can resolve to. Mirrors the store's +/// `ENCLOSING_DEFINITION_KINDS` and `definition_node_ids_in_file`, so all three +/// agree on what counts as a definition. Deliberately excludes `field`, which is +/// a `ref/field` target, not a `ref/call` one. +const DEFINITION_KINDS: &[&str] = &[ + "function", + "method", + "fn", + "class", + "interface", + "struct", + "trait", + "enum", + "type", + "typedef", + "union", + "object", + "protocol", + "mixin", + "extension", + "namespace", + "init", +]; + +#[cfg(test)] +mod tests { + use super::*; + use travsr_core::{Node, VName}; + + const CORPUS: &str = "testrepo"; + + fn store_with(nodes: &[(&str, &str, &str, u32, u32)]) -> SqliteStore { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + for (path, sig, kind, line, end_line) in nodes { + let vname = VName::new(CORPUS, "", *path, "typescript", *sig); + let mut node = Node::new(vname, *kind); + node.line = Some(*line); + node.end_line = Some(*end_line); + store.put_node(&node).expect("put_node"); + } + store + } + + fn node_id(path: &str, sig: &str) -> NodeId { + VName::new(CORPUS, "", path, "typescript", sig).id() + } + + fn resolution( + ref_line: u32, + name: &str, + target_path: &str, + target_line: u32, + ) -> LiveResolution { + LiveResolution { + ref_line, + ref_col: 4, + name: name.to_string(), + target_path: target_path.to_string(), + target_line, + buffer_version: 1, + edge_kind: "ref/call".to_string(), + } + } + + /// Like [`resolution`] but naming a specific edge kind, for the Bucket-B + /// kinds beyond `ref/call` (RFC-027 live edge-kind scope). + fn resolution_kind( + ref_line: u32, + name: &str, + target_path: &str, + target_line: u32, + edge_kind: &str, + ) -> LiveResolution { + LiveResolution { + edge_kind: edge_kind.to_string(), + ..resolution(ref_line, name, target_path, target_line) + } + } + + fn provenance_of(store: &SqliteStore, src: NodeId, dst: NodeId) -> Option { + store + .iter_edges_from(src) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == dst) + .and_then(|e| e.provenance) + } + + /// The happy path (section 7.3b): an editor resolves `save()` inside + /// `placeOrder` to `User.save`, and a live edge appears between the two + /// enclosing definitions. + #[test] + fn an_editor_resolution_becomes_a_live_edge() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "fn:placeOrder"), + node_id("src/user.ts", "method:User.save"), + ) + .as_deref(), + Some("live"), + "the edge must be tagged live, never blended into ratified truth" + ); + } + + /// Section 8.1: a definition position that lands outside every known span + /// (node_modules, a generated stub, an unindexed file) abstains. It must + /// not fall back to any nearest-node heuristic. + #[test] + fn a_target_outside_the_graph_abstains_instead_of_guessing() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "node_modules/lib/index.d.ts", 4)], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + assert!( + store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .is_empty(), + "abstention must write no edge at all" + ); + } + + /// A reference at top level has no enclosing definition to hang an edge + /// from, so it abstains rather than attaching to an arbitrary node. + #[test] + fn a_reference_with_no_enclosing_definition_abstains() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + // Line 2 is outside placeOrder's 10..30 span. + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(2, "save", "src/user.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + } + + /// One unmappable resolution must not cost the others their freshness. + #[test] + fn a_bad_resolution_does_not_abort_the_batch() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[ + resolution(18, "nope", "src/ghost.ts", 3), + resolution(19, "save", "src/user.ts", 17), + ], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 1 + } + ); + } + + fn call(src: NodeId, callee_sig: &str, line: u32) -> UnresolvedCall { + UnresolvedCall { + src, + callee_sig: callee_sig.to_string(), + caller_line: line, + ..UnresolvedCall::default() + } + } + + /// Section 7.3a: a signature with exactly one definition repo-wide resolves + /// with no language server. This is the zero-regression floor. + #[test] + fn an_unambiguous_name_resolves_without_a_language_server() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[call(src, "method:User.save", 18)], + &[], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "fn:placeOrder"), + node_id("src/user.ts", "method:User.save"), + ) + .as_deref(), + Some("live") + ); + } + + /// Section 7.3a's precision guarantee: two definitions sharing a signature + /// is exactly the method-on-receiver case lexical matching cannot settle, + /// so it abstains rather than picking one. + /// + /// This is also where the live lane deliberately diverges from the daemon's + /// `resolve_unresolved_calls`, whose contract is to emit *all* matches and + /// let PPR damping absorb the noise. + #[test] + fn an_ambiguous_name_abstains_rather_than_picking_one() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:save", "method", 15, 20), + ("src/draft.ts", "method:save", "method", 5, 9), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[call(src, "method:save", 18)], + &[], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + "two candidates must abstain, never guess" + ); + assert!(store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .is_empty()); + } + + /// `alt_callee_sig` is the #709 PascalCase class-vs-function ambiguity: the + /// extractor names both shapes and lets the resolver decide on evidence. It + /// is a second exact try, not a widening of the net, so it is still subject + /// to the exactly-one rule. + #[test] + fn the_alt_signature_is_a_second_exact_try_not_a_wider_net() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/field.ts", "fn:Field", "function", 3, 8), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let mut c = call(src, "class:Field", 18); + c.alt_callee_sig = Some("fn:Field".to_string()); + + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[c], &[]); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of(&store, src, node_id("src/field.ts", "fn:Field")).as_deref(), + Some("live") + ); + } + + /// Section 8.2 fencing rule: live edges are intra-corpus only, so they can + /// never violate RFC-005's `src.corpus == dst.corpus` invariant. + #[test] + fn a_cross_corpus_target_is_fenced_off() { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + let mut caller = Node::new( + VName::new(CORPUS, "", "src/order.ts", "typescript", "fn:placeOrder"), + "function", + ); + caller.line = Some(10); + caller.end_line = Some(30); + let mut foreign = Node::new( + VName::new( + "othercorpus", + "", + "src/user.ts", + "typescript", + "method:User.save", + ), + "method", + ); + foreign.line = Some(15); + foreign.end_line = Some(20); + store.put_node(&caller).expect("put_node"); + store.put_node(&foreign).expect("put_node"); + + // Resolving within CORPUS cannot see the foreign node's path at all, + // which is the fence doing its job one layer earlier. + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + assert!(store + .iter_edges_from(caller.id) + .expect("iter_edges_from") + .is_empty()); + } + + /// A symbol referencing itself carries no traversal value and would surface + /// as a spurious self-loop in `get_callers`. + #[test] + fn a_self_reference_emits_no_edge() { + let mut store = store_with(&[("src/rec.ts", "fn:walk", "function", 4, 12)]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/rec.ts", + &[resolution(8, "walk", "src/rec.ts", 5)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + } + + /// Plan R5: `reindex_replace` deletes every outbound edge of a file on each + /// save, so the engine re-emits whole-file. Re-emitting must be idempotent + /// rather than accumulating duplicates or failing. + #[test] + fn re_emitting_after_a_save_is_idempotent() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let batch = [resolution(18, "save", "src/user.ts", 17)]; + for _ in 0..3 { + let out = apply_live_resolutions(&mut store, CORPUS, "src/order.ts", &batch); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + } + assert_eq!( + store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .len(), + 1, + "re-emitting must not accumulate duplicate edges" + ); + } + + // ── Phase 4: Rust call-site encoding (RFC-027 §14) ────────────────────── + // + // `candidate_signatures` was written against TypeScript's encoding; Rust's + // differs in three ways (see the fn doc). These tests pin each, so a change + // to the extractor or the signature builder cannot silently regress Rust + // recall or, worse, start double-qualifying an already-qualified sig. + + fn rust_store_with(nodes: &[(&str, &str, &str, u32, u32)]) -> SqliteStore { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + for (path, sig, kind, line, end_line) in nodes { + let vname = VName::new(CORPUS, "", *path, "rust", *sig); + let mut node = Node::new(vname, *kind); + node.line = Some(*line); + node.end_line = Some(*end_line); + store.put_node(&node).expect("put_node"); + } + store + } + + fn rust_node_id(path: &str, sig: &str) -> NodeId { + VName::new(CORPUS, "", path, "rust", sig).id() + } + + /// `Type::method()` (an associated call like `Zoo::new()`) is emitted by the + /// Rust extractor ALREADY QUALIFIED — `callee_sig = "method:Zoo.new"`, with + /// no receiver type and `is_method_call = false` (phase_b_rust.rs, the + /// `call.scoped` uppercase-qualifier arm). It must reach `unique_definition` + /// verbatim: rebuilding `method:{recv}.{leaf}` would double-qualify it. A + /// `None` recv_type and `!is_method_call` route it to the free-function + /// branch, which uses the sig as-is. The plan flagged this as "correct by + /// accident"; this pins it as correct on purpose. + #[test] + fn rust_associated_call_signature_is_used_verbatim_not_rebuilt() { + let src = rust_node_id("src/main.rs", "fn:main"); + let c = call(src, "method:Zoo.new", 3); + assert_eq!(candidate_signatures(&c), vec!["method:Zoo.new".to_string()]); + } + + /// `recv.method()` (e.g. `zoo.add()`) is emitted as `callee_sig = "fn:add"` + /// with `is_method_call = true` and the receiver type recovered into + /// `recv_type`. The lane must rebuild `method:{recv}.{leaf}` to match the + /// definition node `method:Zoo.add`; `fn:add` alone names no method. + #[test] + fn rust_method_call_with_receiver_type_rebuilds_the_qualified_sig() { + let src = rust_node_id("src/zoo.rs", "method:Zoo.announce"); + let mut c = call(src, "fn:add", 5); + c.is_method_call = true; + c.recv_type = Some("Zoo".to_string()); + assert_eq!(candidate_signatures(&c), vec!["method:Zoo.add".to_string()]); + } + + /// A method call with no recovered receiver type is the ambiguity §7.3a + /// cannot settle, so it yields no candidate and abstains — the LSP lane + /// (§7.3b) is what exists for it. Guessing by leaf name here is the + /// recall-biased Phase B policy, wrong for an un-ratified edge. + #[test] + fn rust_method_call_without_receiver_type_yields_no_candidate() { + let src = rust_node_id("src/zoo.rs", "method:Zoo.announce"); + let mut c = call(src, "fn:add", 5); + c.is_method_call = true; + c.recv_type = None; + assert!(candidate_signatures(&c).is_empty()); + } + + /// A bare free-function call `helper()` names its definition directly. + #[test] + fn rust_bare_function_call_names_its_definition_directly() { + let src = rust_node_id("src/main.rs", "fn:main"); + let c = call(src, "fn:helper", 2); + assert_eq!(candidate_signatures(&c), vec!["fn:helper".to_string()]); + } + + /// End to end on a Rust-labeled store: `Zoo::new()` resolves to the + /// associated-function definition with a `live` edge and no double-qualify. + #[test] + fn rust_associated_call_resolves_to_a_live_edge() { + let mut store = rust_store_with(&[ + ("src/main.rs", "fn:main", "function", 1, 6), + ("src/zoo.rs", "method:Zoo.new", "method", 4, 9), + ]); + let src = rust_node_id("src/main.rs", "fn:main"); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/main.rs", + &[call(src, "method:Zoo.new", 3)], + &[], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of(&store, src, rust_node_id("src/zoo.rs", "method:Zoo.new")).as_deref(), + Some("live"), + ); + } + + /// Rust field access `x.count` is emitted with `callee_sig = "field:count"` + /// AND a recovered `recv_type` — the extractor only emits field refs when + /// the receiver type is recoverable (phase_b_rust.rs guards + /// `recv_type.is_none()`). So `candidate_signatures` builds `field:Zoo.count` + /// and the field definition node is present. + /// + /// RFC-027 live edge-kind scope: a field read now resolves to a `ref/field` + /// edge, not abstention. The edge kind matters — it must be `ref/field`, never + /// `ref/call`, so a field read never surfaces as a caller in `get_callers` / + /// `get_blast_radius` (#757) while still appearing as a use site in + /// `find_references`. Both the emission and the kind are pinned here. + #[test] + fn rust_field_access_resolves_to_a_ref_field_edge() { + let mut c = call( + rust_node_id("src/zoo.rs", "method:Zoo.tally"), + "field:count", + 5, + ); + c.recv_type = Some("Zoo".to_string()); + assert_eq!( + candidate_signatures(&c), + vec!["field:Zoo.count".to_string()], + "the owner-qualified field sig is built; recv_type is present", + ); + assert_eq!( + lexical_edge_kind(&c), + EdgeKind::RefField, + "a field:… call-site record resolves to ref/field, not ref/call", + ); + + let mut store = rust_store_with(&[ + ("src/zoo.rs", "method:Zoo.tally", "method", 4, 9), + ("src/zoo.rs", "field:Zoo.count", "field", 2, 2), + ]); + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/zoo.rs", &[c], &[]); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(rust_node_id("src/zoo.rs", "method:Zoo.tally")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == rust_node_id("src/zoo.rs", "field:Zoo.count")) + .expect("a live edge to the field must exist"); + assert_eq!( + edge.kind, + EdgeKind::RefField, + "the edge kind must be ref/field" + ); + assert_eq!( + edge.provenance.as_deref(), + Some("live"), + "and it must be tagged live", + ); + } + + /// Section 7.3b for a field target: an editor resolves `zoo.count` to the + /// field's declaration line, and the daemon maps that position to the + /// `field` node via `enclosing_node_at` (which the call-target kind set of + /// `enclosing_definition_at` would have excluded) and emits `ref/field`. + #[test] + fn an_editor_field_resolution_becomes_a_ref_field_edge() { + let mut store = rust_store_with(&[ + ("src/zoo.rs", "method:Zoo.tally", "method", 4, 9), + ("src/zoo.rs", "field:Zoo.count", "field", 2, 2), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/zoo.rs", + // The reference is on line 5 (inside tally); the field decl is line 2. + &[resolution_kind(5, "count", "src/zoo.rs", 2, "ref/field")], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(rust_node_id("src/zoo.rs", "method:Zoo.tally")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == rust_node_id("src/zoo.rs", "field:Zoo.count")) + .expect("a live edge to the field must exist"); + assert_eq!(edge.kind, EdgeKind::RefField); + } + + /// RFC-027 daemon-driven positions: the editor is handed exactly the + /// references the lexical lane could not settle, and no others. A resolvable + /// call is the lexical lane's job; a method call with no recovered receiver + /// type is the disambiguation §7.3b exists for, so only the latter becomes a + /// target — carrying the line, the leaf name, its edge kind, and the + /// definition provider. + #[test] + fn targets_are_only_the_references_the_lexical_lane_cannot_settle() { + let store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + + // Resolvable associated call — lexical owns it, so no editor target. + let resolvable = call(src, "method:User.save", 18); + // Method call with no recovered receiver type — lexical abstains. + let mut ambiguous = call(src, "fn:save", 19); + ambiguous.is_method_call = true; + + let targets = targets_needing_editor(&store, &[resolvable, ambiguous]); + assert_eq!( + targets.len(), + 1, + "only the unsettleable reference is a target" + ); + assert_eq!(targets[0].ref_line, 19); + assert_eq!(targets[0].name, "save"); + assert_eq!(targets[0].edge_kind, "ref/call"); + assert_eq!(targets[0].provider, "definition"); + } + + /// A field access with no recovered receiver type becomes a `ref/field` + /// target for the definition provider — the daemon names the kind, the editor + /// runs the provider. + #[test] + fn a_field_reference_becomes_a_ref_field_target() { + let store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let field = call(src, "field:count", 20); + let targets = targets_needing_editor(&store, &[field]); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].edge_kind, "ref/field"); + assert_eq!(targets[0].name, "count"); + assert_eq!(targets[0].provider, "definition"); + } + + /// A bare free-function call the lexical lane could not resolve has no unique + /// definition in the graph; the editor's provider would resolve it to that + /// same missing target and abstain, so it is not sent (stay surgical, §7.6). + #[test] + fn an_unresolvable_free_call_is_not_sent_to_the_editor() { + let store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let bare = call(src, "fn:nowhere", 21); + assert!(targets_needing_editor(&store, &[bare]).is_empty()); + } + + /// RFC-027 section 12: the editor lane records what it claimed, so the + /// precision meter can score it. Without a claim row an editor-resolved edge + /// is invisible to the meter, and a language that runs this lane alone + /// (every non-native one, §8.3) could never earn its gate a reading. + #[test] + fn an_editor_resolution_is_recorded_as_a_scorable_claim() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + let sample = store + .live_precision_sample_by_language() + .expect("precision sample"); + let bucket = sample + .get("typescript") + .expect("the claim must be attributed to the source node's language"); + assert_eq!( + bucket.claims(), + 1, + "the editor lane's resolution must be a claim the meter can see" + ); + } + + /// An editor answer the daemon could not map abstains, and the abstention is + /// recorded rather than dropped: "there is a reference here and its target is + /// not yet known" is true and useful (§9.2). + #[test] + fn an_editor_abstention_is_recorded_as_pending() { + let mut store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/nowhere.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + ); + let pending = store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file"); + assert_eq!(pending, vec![("save".to_string(), 18)]); + } + + /// The editor lane must upgrade the save-path pass's row for the same + /// reference, not fork it into a stale `pending` beside a `resolved`. Both + /// lanes key on `ref_col = 0` for exactly this reason. + #[test] + fn an_editor_answer_upgrades_the_save_paths_pending_row() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + // The save-path pass abstains on a method call with no receiver type. + let mut ambiguous = call(src, "fn:save", 18); + ambiguous.is_method_call = true; + resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[ambiguous], &[]); + assert_eq!( + store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file") + .len(), + 1, + ); + + apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + assert!( + store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file") + .is_empty(), + "the resolved reference must leave no stale pending row behind" + ); + assert_eq!( + store + .live_precision_sample_by_language() + .expect("precision sample") + .get("typescript") + .map(|b| b.claims()), + Some(1), + "one reference must produce one claim, not two rows", + ); + } + + /// RFC-027 section 8.3: a language with no native extractor has no lexical + /// floor, so every detected reference becomes an editor target — a call as + /// `ref/call`, a field read as `ref/field`, each for the definition + /// provider. Nothing is filtered: dropping one would lose the reference + /// outright rather than hand it to another lane. + #[test] + fn every_generic_reference_becomes_an_editor_target() { + let refs = travsr_analysis::live_detect::LiveRefs { + calls: vec![ + travsr_analysis::live_detect::LiveRef { + line: 12, + name: "Start".to_string(), + }, + travsr_analysis::live_detect::LiveRef { + line: 13, + name: "helper".to_string(), + }, + ], + fields: vec![travsr_analysis::live_detect::LiveRef { + line: 14, + name: "count".to_string(), + }], + inheritance: Vec::new(), + }; + let targets = generic_targets_needing_editor(&refs); + assert_eq!(targets.len(), 3); + assert_eq!( + targets + .iter() + .map(|t| (t.ref_line, t.name.as_str(), t.edge_kind.as_str())) + .collect::>(), + vec![ + (12, "Start", "ref/call"), + (13, "helper", "ref/call"), + (14, "count", "ref/field"), + ], + ); + assert!(targets.iter().all(|t| t.provider == "definition")); + } + + /// The editor recovers a target's column by finding the name on its line, so + /// two references to the same name on one line resolve to the same position. + /// Sending both would buy a second provider round trip for an answer already + /// in hand. + #[test] + fn generic_targets_are_deduplicated_per_line_and_name() { + let refs = travsr_analysis::live_detect::LiveRefs { + calls: vec![ + travsr_analysis::live_detect::LiveRef { + line: 7, + name: "Get".to_string(), + }, + travsr_analysis::live_detect::LiveRef { + line: 7, + name: "Get".to_string(), + }, + ], + fields: Vec::new(), + inheritance: Vec::new(), + }; + assert_eq!(generic_targets_needing_editor(&refs).len(), 1); + } + + /// A live edge for a non-native language rides exactly the same emit path as + /// a native one: the daemon maps the reference line to its enclosing + /// definition and the editor's answer to a node, and neither endpoint is + /// minted here (§8.2). This is a Go file with no native extractor at all. + #[test] + fn a_generic_language_edge_emits_through_the_shared_path() { + let mut store = store_with(&[ + ("cmd/run.go", "fn:Run", "function", 10, 20), + ("svc/session.go", "method:Session.Start", "method", 30, 40), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "cmd/run.go", + &[resolution_kind( + 12, + "Start", + "svc/session.go", + 31, + "ref/call", + )], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("cmd/run.go", "fn:Run")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("svc/session.go", "method:Session.Start")) + .expect("a live edge to the Go method must exist"); + assert_eq!(edge.kind, EdgeKind::RefCall); + } + + /// An editor report naming an edge kind outside Bucket B (a structural kind, + /// a cross-language `ffi/call`, or an unknown string) is refused: the lane + /// emits only the kinds it was scoped to, so a malformed report abstains + /// rather than writing an out-of-scope edge. + #[test] + fn an_out_of_scope_edge_kind_is_refused() { + assert!(live_edge_kind("ffi/call").is_none()); + assert!(live_edge_kind("depends").is_none()); + assert!(live_edge_kind("not-a-kind").is_none()); + assert_eq!(live_edge_kind("ref/field"), Some(EdgeKind::RefField)); + + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution_kind(18, "save", "src/user.ts", 17, "ffi/call")], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + ); + } + + // ── IsImplementation lane (RFC-027 live edge-kind scope, TypeScript) ──────── + + fn inherit(base_name: &str, line: u32) -> InheritanceRef { + InheritanceRef { + base_name: base_name.to_string(), + line, + } + } + + /// Lexical floor: `class Order extends Base` where `Base` has exactly one + /// definition repo-wide resolves to an `is-implementation` edge with no + /// language server. The clause line is the class declaration line, which maps + /// to the implementing class as the edge source. + #[test] + fn an_unambiguous_base_resolves_to_a_live_is_implementation_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/base.ts", "class:Base", "class", 1, 10), + ]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[], + &[inherit("Base", 3)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("src/order.ts", "class:Order")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("src/base.ts", "class:Base")) + .expect("a live is-implementation edge to the base must exist"); + assert_eq!(edge.kind, EdgeKind::IsImplementation); + assert_eq!(edge.provenance.as_deref(), Some("live")); + } + + /// An interface `implements` target resolves the same way, keyed on the + /// `interface:` signature the target-kind set admits. + #[test] + fn an_unambiguous_interface_resolves_to_a_live_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/shape.ts", "interface:Shape", "interface", 1, 5), + ]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[], + &[inherit("Shape", 3)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "class:Order"), + node_id("src/shape.ts", "interface:Shape"), + ) + .as_deref(), + Some("live"), + ); + } + + /// A base with two definitions repo-wide is the cross-file ambiguity the + /// editor's provider exists for: the lexical floor abstains and it becomes a + /// `definition`-provider target, never a guessed edge. + #[test] + fn an_ambiguous_base_abstains_and_becomes_an_editor_target() { + let store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/a.ts", "class:Base", "class", 1, 10), + ("src/b.ts", "class:Base", "class", 1, 10), + ]); + let targets = inheritance_targets_needing_editor( + &store, + CORPUS, + "src/order.ts", + &[inherit("Base", 3)], + ); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].edge_kind, "is-implementation"); + assert_eq!(targets[0].name, "Base"); + assert_eq!(targets[0].ref_line, 3); + assert_eq!( + targets[0].provider, "definition", + "the base type is resolved to its definition, not its implementors", + ); + } + + /// A base the lexical floor could settle is not also sent to the editor: the + /// two lanes partition inheritance clauses with no overlap, exactly as they + /// do calls. + #[test] + fn a_resolvable_base_is_not_sent_to_the_editor() { + let store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/base.ts", "class:Base", "class", 1, 10), + ]); + let targets = inheritance_targets_needing_editor( + &store, + CORPUS, + "src/order.ts", + &[inherit("Base", 3)], + ); + assert!( + targets.is_empty(), + "a unique base is the lexical floor's job" + ); + } + + /// Section 7.3b for an implements target: an editor resolves the base name to + /// its declaration and the daemon emits an `is-implementation` edge from the + /// implementing class to the interface node the position mapped to. + #[test] + fn an_editor_resolved_base_becomes_an_is_implementation_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/shape.ts", "interface:Shape", "interface", 1, 5), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution_kind( + 3, + "Shape", + "src/shape.ts", + 1, + "is-implementation", + )], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("src/order.ts", "class:Order")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("src/shape.ts", "interface:Shape")) + .expect("an is-implementation edge must exist"); + assert_eq!(edge.kind, EdgeKind::IsImplementation); + } +} diff --git a/crates/travsr-indexer/tests/typescript.rs b/crates/travsr-indexer/tests/typescript.rs index 377c7179..88495885 100644 --- a/crates/travsr-indexer/tests/typescript.rs +++ b/crates/travsr-indexer/tests/typescript.rs @@ -224,14 +224,15 @@ fn link_imports_skips_package_imports() { "package imports must not produce resolves-to edges" ); - // 15 relative imports (./mcp, ./clientProxy, ./status, ./codelens, ./hover, ./tree, - // ./repoFileTree, ./welcome, ./graph, ./installer, ./telemetry, ./commands, - // ./contextExplorer, ./mcpRegister, ./contextCodeAction) × 3 candidates each - // (#610: .ts + .tsx + .js probe) = 45 edges. + // 16 relative imports (./mcp, ./clientProxy, ./status, ./codelens, ./hover, + // ./liveResolution, ./tree, ./repoFileTree, ./welcome, ./graph, ./installer, + // ./telemetry, ./commands, ./contextExplorer, ./mcpRegister, + // ./contextCodeAction) × 3 candidates each + // (#610: .ts + .tsx + .js probe) = 48 edges. assert_eq!( edges.len(), - 45, - "15 relative imports × 3 extension candidates = 45 resolves-to edges" + 48, + "16 relative imports × 3 extension candidates = 48 resolves-to edges" ); } diff --git a/crates/travsr-ipc/src/message.rs b/crates/travsr-ipc/src/message.rs index a43f3c35..c6529ee4 100644 --- a/crates/travsr-ipc/src/message.rs +++ b/crates/travsr-ipc/src/message.rs @@ -108,6 +108,74 @@ pub enum ControlMessage { /// so are unknown rather than clean. undiagnosed: usize, }, + /// RFC-027 daemon-driven positions: an editor asks the daemon which + /// references in a dirty file it should resolve. + /// + /// The daemon runs the native extractor over the file, keeps the references + /// its own lexical lane cannot settle, and answers with a + /// [`LiveResolutionTarget`] per reference — line, name, edge kind, and which + /// provider to run. The editor then resolves each and reports back with + /// [`ControlMessage::ReportLiveResolution`]. This replaces the editor's blind + /// `identifier(` scan, so reference detection lives with the parser and the + /// graph rather than in an English-shaped regex. + /// + /// The answer rides `ControlResponse::result` as a JSON array. Daemons older + /// than this variant answer with a parse error, which the extension treats as + /// "no targets" — the live lane simply stays at its lexical floor. + RequestLiveResolutionTargets { + /// Absolute workspace root, checked against the daemon's own repo exactly + /// as `ReportLiveResolution` does (#698 review, P1): discovery enumerates + /// a namespace, not a repo. + repo_root: String, + /// Stable for the lifetime of one editor window. + session: String, + /// The dirty file, repo-relative with forward slashes. + file: String, + /// Editor buffer version, echoed so the editor can drop a stale batch. + buffer_version: i64, + }, + /// RFC-027: an editor reports where a reference in a dirty file actually + /// resolves to, so the daemon can close the between-commits semantic gap. + /// + /// This looks like the editor plane above but is deliberately a different + /// thing, and the difference is the whole safety argument. #688 keeps + /// editor data *out* of the graph because diagnostics are the editor's + /// claim about the code. Here the editor never makes a claim about the + /// graph: it reports a **position**, and the daemon resolves that position + /// against its own SCIP-owned identity. The editor cannot name a node, + /// cannot mint a VName, and cannot say which symbols are related. It + /// answers exactly one question a language server is authoritative for + /// ("what does the cursor at this position point at?") and the graph owns + /// everything downstream of that answer. + /// + /// The resulting edges are written with `provenance='live'` and swept when + /// commit-time SCIP ratifies the region (RFC-027 sections 8.3 and 8.4), so + /// the durable graph stays SCIP-pinned and deterministic. Non-determinism + /// is confined to the ephemeral overlay, which is why persisting these does + /// not reopen what #688 closed. + /// + /// Unlike [`ControlMessage::ReportLspDiagnostics`] there is no `ttl_secs`: + /// a live edge's lifetime is bounded by ratification, not by a lease, so a + /// TTL here would be a second expiry mechanism with no consumer. + /// + /// Daemons older than this variant answer with a parse error, which the + /// extension ignores. Losing the report costs freshness, never truth. + ReportLiveResolution { + /// Absolute workspace root this report describes. Checked against the + /// daemon's own repo for the same reason as `ReportLspDiagnostics` + /// (#698 review, P1): discovery enumerates a namespace, not a repo. + repo_root: String, + /// Stable for the lifetime of one editor window. + session: String, + /// The dirty file these references live in. Repo-relative, forward + /// slashes, matching the graph's own path keys. + file: String, + /// One entry per reference the editor was able to resolve. References + /// it could not resolve are simply absent: the live lane is fail-closed + /// (RFC-027 section 8.1), so an omission becomes a `pending` marker + /// rather than a guessed edge. + resolutions: Vec, + }, /// #688: read the editor plane (`travsr daemon lsp`). /// /// Answers from memory across all live sessions, dropping expired ones as @@ -117,6 +185,111 @@ pub enum ControlMessage { LspStatus, } +/// One resolved reference: where the call site is, and where the editor's +/// language provider says it points. +/// +/// Both ends are positions, never identities. The daemon maps them to nodes +/// itself (RFC-027 section 7.5: current Tree-sitter spans for dirty files, SCIP +/// ranges for clean ones), which is what keeps VName minting SCIP's exclusive +/// job. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LiveResolution { + /// 1-based line of the reference in the dirty file. + pub ref_line: u32, + /// 0-based UTF-16 column, as the editor counts them. + pub ref_col: u32, + /// The referenced name, used to narrow candidates before position mapping + /// and to record an honest `pending` row when mapping fails. + pub name: String, + /// Repo-relative path the definition lives in, forward slashes. A target + /// outside the workspace is dropped by the extension rather than sent, so + /// the live lane stays intra-corpus (RFC-027 section 8.2). + pub target_path: String, + /// 1-based line of the definition the editor resolved to. + pub target_line: u32, + /// Editor buffer version this answer was computed against. The daemon drops + /// a resolution whose buffer has moved on rather than trusting a stale + /// position (RFC-027 section 11). + pub buffer_version: i64, + /// The graph edge kind this reference resolves to, as the stable + /// `EdgeKind::as_str` string (`ref/call`, `ref/field`, `ref/imports`, + /// `is-implementation`, `overrides`). The editor knows which reference shape + /// it queried and which provider answered, so it names the kind; the daemon + /// emits an edge of exactly that kind rather than assuming a call + /// (RFC-027 live edge-kind scope). `#[serde(default)]` yields `ref/call`, + /// preserving the pre-expansion payload where every live edge was a call. + #[serde(default = "default_live_edge_kind")] + pub edge_kind: String, +} + +/// Back-compat default for [`LiveResolution::edge_kind`]: an older extension +/// only ever resolved calls, so a payload without the field means `ref/call`. +fn default_live_edge_kind() -> String { + "ref/call".to_string() +} + +/// One reference the editor should resolve, computed by the daemon from the +/// native extractor's reference set (RFC-027 daemon-driven positions). +/// +/// The daemon says *which* reference and *which* edge kind; the editor finds the +/// exact column of `name` on `ref_line`, runs `provider`, and reports the answer +/// back as a [`LiveResolution`]. This is what replaces the editor's blind +/// `identifier(` scan: reference detection moves to the daemon (which has the +/// parser and the graph), leaving the editor only the provider round-trip. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LiveResolutionTarget { + /// 1-based line of the reference in the dirty file. The editor searches this + /// line for `name` to recover the column the native extractor does not carry. + pub ref_line: u32, + /// The referenced name, so the editor can pin the column and so the daemon + /// can record an honest `pending` row if the editor's answer maps to nothing. + pub name: String, + /// The graph edge kind this reference resolves to, as `EdgeKind::as_str` + /// (`ref/call`, `ref/field`, `ref/imports`, `is-implementation`, + /// `overrides`). Echoed back on the [`LiveResolution`] so the daemon emits an + /// edge of exactly this kind. + pub edge_kind: String, + /// Which editor provider answers this reference shape: `definition` (calls, + /// fields, imports) or `implementation` (implements clauses, overrides). + pub provider: String, +} + +/// The targets in one dependent file the editor should also resolve +/// (RFC-027 section 8.7.5, the interface-edit closure). +/// +/// When the saved file adds or renames a symbol other files reference by name, +/// their edges into it were stranded and the editor, which only ever publishes +/// the saved document, never re-resolves them. The daemon names those files +/// here so the same target request restores them, keeping the editor the +/// initiator (§10.1). The editor opens each file, resolves its `targets`, and +/// reports back under that file's own path — so a dependent's edges attach to a +/// definition in the dependent (self-healing on its next save), never to one in +/// the saved file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DependentTargets { + /// Repo-relative path of the dependent, forward slashes. + pub file: String, + /// The references in `file` to resolve, same shape as the saved file's own. + pub targets: Vec, +} + +/// The full answer to a target request: the saved file's own references plus the +/// dependents the interface-edit closure wants re-resolved (RFC-027 §8.7.5). +/// +/// Rides `ControlResponse::result` as a JSON object. An extension too old to +/// know this shape reads `result` as an array, finds an object, and treats it as +/// no targets — the live lane simply stays at its lexical floor for that save, +/// which is fail-closed (§8.1), never a wrong edge. New extensions read `own` +/// for the saved document and open each `dependents` file to resolve it. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LiveResolutionTargets { + /// References in the saved file, resolved against its live buffer. + pub own: Vec, + /// Dependent files whose stranded edges this save can restore (§8.7.5). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dependents: Vec, +} + /// One file's current diagnostic state, as an editor sees it. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FileDiagnostics { @@ -227,6 +400,157 @@ mod tests { } } + // RFC-027: same contract as the diagnostics line above. The extension + // hand-builds this in TypeScript (packages/travsr-vscode/src/daemonIpc.ts), + // so the wire tag and field names here are the contract. If this test is + // edited, that file has to change with it. + #[test] + fn report_live_resolution_wire_shape_matches_the_extension() { + let line = r#"{"op":"report-live-resolution","repo_root":"/home/alice/proj", + "session":"vscode-1-abc","file":"src/order.ts", + "resolutions":[{"ref_line":42,"ref_col":8,"name":"save", + "target_path":"src/user.ts","target_line":17,"buffer_version":9}]}"#; + match serde_json::from_str::(line).expect("extension line must parse") { + ControlMessage::ReportLiveResolution { + repo_root, + session, + file, + resolutions, + } => { + assert_eq!(repo_root, "/home/alice/proj"); + assert_eq!(session, "vscode-1-abc"); + assert_eq!(file, "src/order.ts"); + assert_eq!(resolutions.len(), 1); + let r = &resolutions[0]; + assert_eq!((r.ref_line, r.ref_col), (42, 8)); + assert_eq!(r.name, "save"); + assert_eq!(r.target_path, "src/user.ts"); + assert_eq!(r.target_line, 17); + assert_eq!(r.buffer_version, 9); + // An extension too old to send edge_kind means the pre-expansion + // behaviour: every live edge was a call. + assert_eq!( + r.edge_kind, "ref/call", + "a payload without edge_kind defaults to ref/call" + ); + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + + // The expanded wire shape: an editor that resolved a field carries the edge + // kind, so the daemon emits ref/field rather than assuming a call. + #[test] + fn a_live_resolution_carries_its_edge_kind() { + let line = r#"{"op":"report-live-resolution","repo_root":"/r","session":"s", + "file":"src/zoo.rs","resolutions":[{"ref_line":5,"ref_col":8,"name":"count", + "target_path":"src/zoo.rs","target_line":2,"buffer_version":3, + "edge_kind":"ref/field"}]}"#; + match serde_json::from_str::(line).expect("must parse") { + ControlMessage::ReportLiveResolution { resolutions, .. } => { + assert_eq!(resolutions[0].edge_kind, "ref/field"); + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + + // RFC-027 daemon-driven positions: the request the extension sends to ask + // which references it should resolve. + #[test] + fn request_live_resolution_targets_wire_shape_matches_the_extension() { + let line = r#"{"op":"request-live-resolution-targets","repo_root":"/home/alice/proj", + "session":"vscode-1-abc","file":"src/order.ts","buffer_version":9}"#; + match serde_json::from_str::(line).expect("request line must parse") { + ControlMessage::RequestLiveResolutionTargets { + repo_root, + session, + file, + buffer_version, + } => { + assert_eq!(repo_root, "/home/alice/proj"); + assert_eq!(session, "vscode-1-abc"); + assert_eq!(file, "src/order.ts"); + assert_eq!(buffer_version, 9); + } + other => panic!("expected RequestLiveResolutionTargets, got {other:?}"), + } + } + + // The daemon serialises targets into `ControlResponse::result`; the field + // names here are the contract the extension parses. + #[test] + fn a_resolution_target_serialises_to_the_shape_the_extension_reads() { + let target = LiveResolutionTarget { + ref_line: 19, + name: "save".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }; + let v = serde_json::to_value(&target).expect("serialise"); + assert_eq!(v["ref_line"], 19); + assert_eq!(v["name"], "save"); + assert_eq!(v["edge_kind"], "ref/call"); + assert_eq!(v["provider"], "definition"); + } + + // RFC-027 section 8.7.5: the target response carries the saved file's own + // references under `own` and the interface-edit closure under `dependents`, + // each dependent naming its own file so the editor reports it back keyed + // there. `dependents` is omitted when empty so a body edit's answer stays the + // shape it always was, only nested one level under `own`. + #[test] + fn a_target_response_carries_own_and_dependents() { + let resp = LiveResolutionTargets { + own: vec![LiveResolutionTarget { + ref_line: 12, + name: "run".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }], + dependents: vec![DependentTargets { + file: "src/main.go".to_string(), + targets: vec![LiveResolutionTarget { + ref_line: 4, + name: "Start".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }], + }], + }; + let v = serde_json::to_value(&resp).expect("serialise"); + assert_eq!(v["own"][0]["name"], "run"); + assert_eq!(v["dependents"][0]["file"], "src/main.go"); + assert_eq!(v["dependents"][0]["targets"][0]["name"], "Start"); + + // A body edit yields no dependents, and the field is then absent. + let bare = LiveResolutionTargets { + own: vec![], + dependents: vec![], + }; + let v = serde_json::to_value(&bare).expect("serialise"); + assert!(v.get("dependents").is_none(), "empty dependents omitted"); + // An old-shape answer (bare array) is not this struct, which is exactly + // why an old extension reading `result` as an array degrades to no + // targets rather than mis-parsing. + let round: LiveResolutionTargets = + serde_json::from_value(serde_json::json!({"own": []})).expect("default dependents"); + assert!(round.dependents.is_empty()); + } + + // An empty resolution list is meaningful: the editor looked and resolved + // nothing, which the daemon records as pending rather than as "no report". + #[test] + fn an_empty_live_resolution_report_parses() { + let line = r#"{"op":"report-live-resolution","repo_root":"/r","session":"s", + "file":"src/a.ts","resolutions":[]}"#; + match serde_json::from_str::(line).expect("empty report must parse") { + ControlMessage::ReportLiveResolution { resolutions, .. } => { + assert!(resolutions.is_empty()) + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + // ttl 0 is the detach signal, so it has to survive the wire as itself // rather than being treated as "unset" and defaulted to something alive. #[test] diff --git a/crates/travsr-mcp/src/query.rs b/crates/travsr-mcp/src/query.rs index 698fc4fe..27b3d5fb 100644 --- a/crates/travsr-mcp/src/query.rs +++ b/crates/travsr-mcp/src/query.rs @@ -717,17 +717,35 @@ fn is_containment_edge(kind: &travsr_core::EdgeKind) -> bool { /// containment edge reached in `Callers`/`Both` direction (#517 DD-1): the /// node is still recorded and displayed, but the traversal does not walk /// further from it, so a file's other definitions never enter the BFS queue. +/// The read-side provenance of an edge that came out of a store reader, with a +/// `tree-sitter` fallback for a constructed edge that never carried one. +fn prov_of(e: &travsr_core::Edge) -> String { + e.provenance + .clone() + .unwrap_or_else(|| "tree-sitter".to_string()) +} + +/// One expansion step out of [`next_edges`]: +/// `(edge_kind, next_id, expand, incoming, provenance)`. The 5th element is the +/// edge's true `edges.provenance` (DEBT-75). +pub type NextEdge = (travsr_core::EdgeKind, NodeId, bool, bool, String); + pub fn next_edges( store: &SqliteStore, node_id: NodeId, direction: QueryDirection, edge_mode: QueryEdgeMode, is_seed: bool, -) -> anyhow::Result> { +) -> anyhow::Result> { + // DEBT-75: the 5th element is the edge's true `edges.provenance`, carried + // through from the store readers so callers no longer have to assume + // "tree-sitter". `unwrap_or` only fires on a constructed (never-read) edge, + // which cannot reach here. let mut out = Vec::new(); if matches!(direction, QueryDirection::Deps | QueryDirection::Both) { for e in store.iter_edges_from(node_id)? { - out.push((e.kind, e.dst, true, false)); + let prov = e.provenance.unwrap_or_else(|| "tree-sitter".to_string()); + out.push((e.kind, e.dst, true, false, prov)); } } if matches!(direction, QueryDirection::Callers | QueryDirection::Both) { @@ -769,7 +787,7 @@ pub fn next_edges( for e in &incoming { let s = &e.kind; if is_semantic_edge(s) || matches!(s, travsr_core::EdgeKind::DefinesBinding) { - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } else { @@ -779,24 +797,24 @@ pub fn next_edges( // judged from coverage in graph_query, not from this one node. for e in &incoming { let s = &e.kind; - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } else { for e in &incoming { let s = &e.kind; - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } // Multiple call sites (and the file-node definition splice) can yield the // same (kind, src, orientation) triple — collapse them for display. let mut seen = HashSet::new(); - out.retain(|(kind, id, _, incoming)| seen.insert((*kind, *id, *incoming))); + out.retain(|(kind, id, _, incoming, _)| seen.insert((*kind, *id, *incoming))); // #517 DD-1: non-containment edges (the answer) precede containment edges // (orientation) from the same parent. Stable sort preserves DB order // within each group, so output stays deterministic. - out.sort_by_key(|(kind, _, _, _)| is_containment_edge(kind)); + out.sort_by_key(|(kind, _, _, _, _)| is_containment_edge(kind)); Ok(out) } @@ -909,7 +927,7 @@ pub fn graph_query(store: &SqliteStore, args: &GraphQueryArgs) -> anyhow::Result continue; } - for (edge_kind, next_id, child_expand, edge_incoming) in next_edges( + for (edge_kind, next_id, child_expand, edge_incoming, edge_provenance) in next_edges( store, current_id, args.direction, @@ -923,15 +941,7 @@ pub fn graph_query(store: &SqliteStore, args: &GraphQueryArgs) -> anyhow::Result } else { (current_id, next_id) }; - // DEBT(travsr-75): iter_edges_from/to do not return provenance, so - // BFS-traversed edges always show "tree-sitter" in JSON output even - // when the DB row is "lsif". Only --all mode (all_edges) is correct. - edges_raw.push(( - src, - dst, - edge_kind.as_str().to_string(), - "tree-sitter".to_string(), - )); + edges_raw.push((src, dst, edge_kind.as_str().to_string(), edge_provenance)); if !visited.contains(&next_id) { if let Some(next_node) = store.get_node(next_id)? { diff --git a/crates/travsr-mcp/src/server.rs b/crates/travsr-mcp/src/server.rs index dc2518df..17a075b4 100644 --- a/crates/travsr-mcp/src/server.rs +++ b/crates/travsr-mcp/src/server.rs @@ -253,6 +253,9 @@ fn handle_tool_call( // #319 P3: LOD repo-map overview mode + package drill path_prefix. let mode = args["mode"].as_str().unwrap_or(""); let path_prefix = args["path_prefix"].as_str().unwrap_or(""); + // RFC-027 section 10: optional, and absent means "everything", so an + // existing caller keeps seeing the fresher graph unchanged. + let provenance = args["provenance"].as_str().unwrap_or(""); tools::get_graph_json( store, &tools::GraphJsonParams { @@ -263,6 +266,7 @@ fn handle_tool_call( token_budget, mode, path_prefix, + provenance, }, ) } @@ -497,6 +501,7 @@ pub fn tools_list() -> serde_json::Value { "direction": { "type": "string", "enum": ["deps", "callers", "both"], "description": "Edge direction. Default: both" }, "depth": { "type": "integer", "minimum": 1, "maximum": 4, "description": "BFS depth. Default: 2" }, "kind_filter": { "type": "string", "enum": ["file", ""], "description": "Restrict nodes to a specific kind. 'file' returns only file nodes and imports edges (project module map). Default: empty (all kinds)." }, + "provenance": { "type": "string", "enum": ["", "ratified", "tree-sitter", "lsif", "scip", "live"], "description": "Restrict edges by how they were derived. Default empty returns everything, including 'live' edges resolved from uncommitted edits and not yet ratified. Use 'ratified' to exclude those and see only what the commit-gated pipeline has confirmed. Every edge in the response carries its own 'provenance' field." }, "token_budget": { "type": "integer", "description": "Cap the payload to roughly this many tokens (0 or omitted = unlimited). Truncation is reported via truncated_by_budget." }, "mode": { "type": "string", "enum": ["", "overview"], "description": "'overview' returns directory-level component tiles (each with file_count and dependents) plus cross-component dependency edges from the resolved graph, ranked by how depended-upon each component is. Combine with path_prefix to drill into a component." }, "path_prefix": { "type": "string", "description": "When mode='overview', scope to files under this path prefix (e.g. 'src/components/'). Returns file nodes inside the prefix plus external package nodes for cross-boundary dependencies." } @@ -886,6 +891,9 @@ fn handle_tool_call_global( let kind_filter = args["kind_filter"].as_str().unwrap_or(""); let mode = args["mode"].as_str().unwrap_or(""); let path_prefix = args["path_prefix"].as_str().unwrap_or(""); + // RFC-027 section 10: optional, and absent means "everything", so an + // existing caller keeps seeing the fresher graph unchanged. + let provenance = args["provenance"].as_str().unwrap_or(""); tools::get_graph_json_global( repos, repo_arg, @@ -897,6 +905,7 @@ fn handle_tool_call_global( token_budget: 0, mode, path_prefix, + provenance, }, ) } @@ -1142,6 +1151,7 @@ pub fn tools_list_global() -> serde_json::Value { "direction": { "type": "string", "enum": ["deps", "callers", "both"], "description": "Edge direction. Default: both" }, "depth": { "type": "integer", "minimum": 1, "maximum": 4, "description": "BFS depth. Default: 2" }, "kind_filter": { "type": "string", "enum": ["file", ""], "description": "Restrict nodes to a specific kind. 'file' returns only file nodes and imports edges (project module map). Default: empty (all kinds)." }, + "provenance": { "type": "string", "enum": ["", "ratified", "tree-sitter", "lsif", "scip", "live"], "description": "Restrict edges by how they were derived. Default empty returns everything, including 'live' edges resolved from uncommitted edits and not yet ratified. Use 'ratified' to exclude those and see only what the commit-gated pipeline has confirmed. Every edge in the response carries its own 'provenance' field." }, "repo": { "type": "string", "description": "Repo name (run repos_list to discover). Always supply to avoid cross-repo noise; omit only when explicitly querying across all repos." }, "mode": { "type": "string", "enum": ["", "overview"], "description": "'overview' returns directory-level component tiles (each with file_count and dependents) plus cross-component dependency edges from the resolved graph." }, "path_prefix": { "type": "string", "description": "When mode='overview', scope to files under this path prefix. Returns file nodes inside the prefix plus external package nodes for cross-boundary dependencies." } diff --git a/crates/travsr-mcp/src/tools.rs b/crates/travsr-mcp/src/tools.rs index 15d987c7..1c004984 100644 --- a/crates/travsr-mcp/src/tools.rs +++ b/crates/travsr-mcp/src/tools.rs @@ -249,6 +249,44 @@ pub fn phase_b_degraded_note(store: &SqliteStore) -> Option { } } +/// RFC-027 section 10: tell a reader that this answer includes un-ratified +/// edges, and where the remaining gaps are. +/// +/// Two numbers, because they mean opposite things and an agent needs both: +/// `live` edges are references the overlay *did* resolve ahead of the commit +/// (extra freshness, not yet confirmed), while `pending` references are ones +/// nothing could resolve (a known gap, deliberately not guessed at). Reporting +/// only a count of live edges would read as pure upside and hide the abstentions +/// that are the other half of a fail-closed lane. +/// +/// `None` when the overlay is empty and nothing is pending, which is the state +/// of any repo with no uncommitted edits — so the note never fires on a clean +/// tree and costs a reader nothing. +fn live_overlay_note(store: &SqliteStore) -> Option { + let live = store.count_edges_with_provenance("live").ok().unwrap_or(0); + let pending = store.pending_ref_count().ok().unwrap_or(0); + if live == 0 && pending == 0 { + return None; + } + let mut parts = Vec::new(); + if live > 0 { + parts.push(format!( + "{live} edge{} resolved from uncommitted edits and not yet ratified", + if live == 1 { "" } else { "s" } + )); + } + if pending > 0 { + parts.push(format!( + "{pending} reference{} detected but not resolved", + if pending == 1 { "" } else { "s" } + )); + } + Some(format!( + "[note: live overlay active: {}. These resolve deterministically at the next commit; filter to provenance != live for ratified truth only.]", + parts.join("; ") + )) +} + /// Languages whose Phase B never produced call/ref edges on the last run, per /// the `phase_b_warnings` meta (#755). /// @@ -421,6 +459,7 @@ fn append_read_notes(store: &SqliteStore, body: String, head: Option<&str>) -> S let mut out = body; for note in [ phase_b_degraded_note(store), + live_overlay_note(store), head.and_then(|h| head_index_mismatch_note(h, &stored)), ] .into_iter() @@ -488,6 +527,10 @@ fn read_note_signals(store: &SqliteStore, head: Option<&str>) -> Vec String { let Some(src_node) = node_map.get(&edge.src) else { continue; }; + // RFC-027 section 10: mark an un-ratified edge so a reader never takes + // the live overlay for committed truth. Only `live` is called out — + // every other provenance is ratified, and tagging all of them would be + // noise on the common case. + let live = live_marker(edge); // True call edge: try to expand into exact call-site lines. if tag == "[call]" { if let Some(root) = &repo_root { @@ -630,7 +678,7 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { if !sites.is_empty() { for line in sites { lines.push(format!( - "{tag} {} ({}) \u{2014} {}:{}", + "{tag} {} ({}) \u{2014} {}:{}{live}", display_label(src_node), src_node.kind, src_node.vname.path, @@ -645,7 +693,7 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { // report the node's definition line as before. let loc = src_node.line.map(|l| format!(":{l}")).unwrap_or_default(); lines.push(format!( - "{tag} {} ({}) \u{2014} {}{}", + "{tag} {} ({}) \u{2014} {}{}{live}", display_label(src_node), src_node.kind, src_node.vname.path, @@ -661,6 +709,22 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { lines.join("\n") } +/// RFC-027 section 10: the suffix marking an edge as part of the live overlay. +/// +/// Empty for every ratified provenance, so the common case reads exactly as it +/// did before. A `live` edge is resolved but not yet ratified — precise enough +/// to act on, and honest that the commit-gated pipeline has not confirmed it +/// yet. Consumers that need ground truth filter it out; consumers that ignore +/// provenance simply see a fresher graph, which is the additive default +/// section 10 asks for. +fn live_marker(edge: &travsr_core::Edge) -> &'static str { + if edge.provenance.as_deref() == Some("live") { + " [live: resolved from your uncommitted edit, not yet ratified]" + } else { + "" + } +} + /// Extract the bare symbol name from a stored signature for textual call-site /// matching: strips the `kind:` prefix and any scope qualifier. Examples: /// `fn:SyncPod` → `SyncPod`, `method:Foo#charge` → `charge`, @@ -6246,6 +6310,46 @@ pub struct GraphJsonParams<'a> { pub token_budget: usize, pub mode: &'a str, pub path_prefix: &'a str, + /// RFC-027 section 10: restrict edges by how they were derived. + /// + /// `""` (the default) returns everything, so an existing caller sees the + /// fresher graph and nothing changes for it. `"ratified"` excludes the live + /// overlay, for a consumer that needs only what the deterministic pipeline + /// has confirmed. Any other value names a single provenance exactly + /// (`tree-sitter`, `lsif`, `scip`, `live`). + /// + /// A word rather than a `!=` expression: the meaningful question a consumer + /// has is "confirmed, or everything", and spelling that as a filter grammar + /// would invite queries the store cannot answer cheaply. + pub provenance: &'a str, +} + +impl Default for GraphJsonParams<'_> { + fn default() -> Self { + Self { + query: "", + direction: "both", + depth: 2, + kind_filter: "", + token_budget: 0, + mode: "", + path_prefix: "", + provenance: "", + } + } +} + +/// Whether an edge with `provenance` passes the caller's filter. +/// +/// Unknown filter values match nothing rather than everything: a typo should +/// return an obviously empty graph, not silently ignore the constraint a +/// consumer added precisely because it needed ground truth. +fn provenance_allowed(filter: &str, provenance: &str) -> bool { + match filter { + "" => true, + "ratified" => provenance != "live", + exact => provenance == exact, + } } /// BFS from seed node(s) matching `query`, respecting `direction` and `depth`. @@ -6261,6 +6365,7 @@ pub fn get_graph_json(store: &SqliteStore, params: &GraphJsonParams<'_>) -> Stri token_budget, mode, path_prefix, + provenance, } = params; if !matches!(*mode, "" | "overview") { tracing::warn!("get_graph_json rejected unknown mode: {mode}"); @@ -6298,6 +6403,7 @@ pub fn get_graph_json(store: &SqliteStore, params: &GraphJsonParams<'_>) -> Stri depth, kind_filter, *token_budget, + provenance, head.as_deref(), ) } @@ -6626,6 +6732,7 @@ fn strip_native_kind_prefix(label: &str) -> &str { .unwrap_or(label) } +#[allow(clippy::too_many_arguments)] fn get_graph_json_raw( store: &SqliteStore, query: &str, @@ -6633,6 +6740,8 @@ fn get_graph_json_raw( depth: u8, kind_filter: &str, token_budget: usize, + // RFC-027 section 10: edge provenance filter. `""` returns everything. + provenance: &str, // #645/#661: the caller's live short HEAD for the index/HEAD drift signal. // Injected (not read from LAUNCH_CWD here) so the global aggregator can pass // *each* repo's own HEAD rather than the one workspace's — see @@ -6906,30 +7015,36 @@ fn get_graph_json_raw( ) { // First pass: dedup via edge_seen, enqueue new visits. // Collect (dst_id, kind_s) only for edges that produce JSON output. - let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming) in &nexts { + let mut new_edges: Vec<(NodeId, &str, &str)> = Vec::new(); + for (kind, next_id, child_expand, _incoming, edge_prov) in &nexts { let kind_s = edge_kind_str(kind); + if !provenance_allowed(provenance, edge_prov) { + continue; + } if edge_seen.insert((current_id, *next_id, kind_s)) { - new_edges.push((*next_id, kind_s)); + new_edges.push((*next_id, kind_s, edge_prov.as_str())); } if visited.insert(*next_id) { queue.push_back((*next_id, hop + 1, *child_expand)); } } // Batch-fetch dst nodes, then emit JSON edges in original order. - let dst_ids: Vec = new_edges.iter().map(|(id, _)| *id).collect(); + let dst_ids: Vec = new_edges.iter().map(|(id, _, _)| *id).collect(); let node_map: HashMap = store .get_nodes(&dst_ids) .unwrap_or_default() .into_iter() .map(|n| (n.id, n)) .collect(); - for (dst_id, kind_s) in &new_edges { + for (dst_id, kind_s, provenance) in &new_edges { if let Some(dst) = node_map.get(dst_id) { + // RFC-027 section 10: renderers and agents need to tell + // the un-ratified overlay from committed truth. edges_out.push(serde_json::json!({ - "source": node_json_id(&node), - "target": node_json_id(dst), - "kind": kind_s, + "source": node_json_id(&node), + "target": node_json_id(dst), + "kind": kind_s, + "provenance": provenance, })); } } @@ -6945,30 +7060,34 @@ fn get_graph_json_raw( hop == 0, ) { // First pass: dedup via edge_seen, enqueue new visits. - let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming) in &nexts { + let mut new_edges: Vec<(NodeId, &str, &str)> = Vec::new(); + for (kind, next_id, child_expand, _incoming, edge_prov) in &nexts { let kind_s = edge_kind_str(kind); + if !provenance_allowed(provenance, edge_prov) { + continue; + } if edge_seen.insert((*next_id, current_id, kind_s)) { - new_edges.push((*next_id, kind_s)); + new_edges.push((*next_id, kind_s, edge_prov.as_str())); } if visited.insert(*next_id) { queue.push_back((*next_id, hop + 1, *child_expand)); } } // Batch-fetch src nodes, then emit JSON edges in original order. - let src_ids: Vec = new_edges.iter().map(|(id, _)| *id).collect(); + let src_ids: Vec = new_edges.iter().map(|(id, _, _)| *id).collect(); let node_map: HashMap = store .get_nodes(&src_ids) .unwrap_or_default() .into_iter() .map(|n| (n.id, n)) .collect(); - for (src_id, kind_s) in &new_edges { + for (src_id, kind_s, provenance) in &new_edges { if let Some(src) = node_map.get(src_id) { edges_out.push(serde_json::json!({ - "source": node_json_id(src), - "target": node_json_id(&node), - "kind": kind_s, + "source": node_json_id(src), + "target": node_json_id(&node), + "kind": kind_s, + "provenance": provenance, })); } } @@ -7057,6 +7176,7 @@ pub fn get_graph_json_global( token_budget: _, mode, path_prefix, + provenance, } = params; if *mode == "overview" { if !path_prefix.is_empty() { @@ -7118,6 +7238,7 @@ pub fn get_graph_json_global( depth, kind_filter, 0, + provenance, head.as_deref(), ); let parsed: serde_json::Value = match serde_json::from_str(&raw) { @@ -8900,6 +9021,7 @@ mod tests { direction: "both", depth: 1, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8922,6 +9044,7 @@ mod tests { direction: "both", depth: 1, kind_filter: "file", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8968,6 +9091,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8982,6 +9106,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 30, mode: "", path_prefix: "", @@ -9046,6 +9171,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "", @@ -9106,6 +9232,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "", @@ -9149,6 +9276,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "pkg/a/", @@ -9220,6 +9348,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "pkg/a/", @@ -9246,6 +9375,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "badmode", path_prefix: "", @@ -9264,6 +9394,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "../etc/passwd", @@ -9521,6 +9652,7 @@ mod tests { direction: "callers", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -9577,6 +9709,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11742,6 +11875,162 @@ mod snippet_tests { assert!(out.contains("chk1111"), "head note: {out}"); } + // ── RFC-027 section 10: the live overlay is legible, never silent ──────── + + /// The note never fires on a clean tree, so it costs an ordinary reader + /// nothing, and fires as soon as either half of the overlay is non-empty. + #[test] + fn the_live_overlay_note_fires_only_when_there_is_an_overlay() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + store.set_meta("last_commit", "idx0000").unwrap(); + store.set_meta("phase_b_commit", "idx0000").unwrap(); + assert!( + live_overlay_note(&store).is_none(), + "a repo with no uncommitted edits must get no note" + ); + + let a = node_with("fn:a", "function", "a.ts"); + let b = node_with("fn:b", "function", "b.ts"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + store + .put_edge_live(&travsr_core::Edge::new( + a.id, + b.id, + travsr_core::EdgeKind::RefCall, + )) + .unwrap(); + + let note = live_overlay_note(&store).expect("an overlay must be announced"); + assert!(note.contains("1 edge resolved"), "singular form: {note}"); + assert!( + note.contains("provenance != live"), + "a reader must be told how to get ratified-only truth: {note}" + ); + } + + /// Both halves are reported, because they mean opposite things: live edges + /// are extra freshness, pending references are known gaps. Reporting only + /// the first would read as pure upside and hide the abstentions. + #[test] + fn the_live_overlay_note_reports_abstentions_as_well_as_resolutions() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + let a = node_with("fn:a", "function", "a.ts"); + store.put_node(&a).unwrap(); + store + .replace_ref_resolution_states( + &a.vname.corpus, + "a.ts", + &[travsr_store::RefResolution { + src: a.id, + ref_line: 3, + ref_col: 0, + name: "save".to_string(), + state: "pending", + resolved_dst: None, + }], + ) + .unwrap(); + + let note = live_overlay_note(&store).expect("a pending reference must be announced"); + assert!( + note.contains("1 reference detected but not resolved"), + "the abstention must be visible: {note}" + ); + } + + /// The overlay marker is attached per edge, and only to un-ratified ones. + #[test] + fn only_a_live_edge_is_marked_in_caller_output() { + let ratified = travsr_core::Edge::new( + travsr_core::NodeId(1), + travsr_core::NodeId(2), + travsr_core::EdgeKind::RefCall, + ); + assert_eq!( + live_marker(&ratified), + "", + "an unlabelled edge is not marked" + ); + + let mut ts = ratified.clone(); + ts.provenance = Some("tree-sitter".to_string()); + assert_eq!(live_marker(&ts), "", "ratified provenance is not marked"); + + let mut live = ratified.clone(); + live.provenance = Some("live".to_string()); + assert!( + live_marker(&live).contains("not yet ratified"), + "a live edge must say so" + ); + } + + /// The filter defaults to returning everything, so an existing caller keeps + /// the fresher graph; `ratified` is the opt-in for ground truth only. + #[test] + fn the_provenance_filter_defaults_to_everything() { + assert!(provenance_allowed("", "live")); + assert!(provenance_allowed("", "scip")); + + assert!(!provenance_allowed("ratified", "live")); + assert!(provenance_allowed("ratified", "tree-sitter")); + assert!(provenance_allowed("ratified", "scip")); + + assert!(provenance_allowed("live", "live")); + assert!(!provenance_allowed("live", "scip")); + + // An unknown filter matches nothing rather than everything: a typo must + // produce an obviously empty graph, not silently drop the constraint a + // consumer added because it needed ground truth. + assert!(!provenance_allowed("ratifed", "scip")); + } + + /// The prose and JSON surfaces must announce the overlay identically. They + /// are separate seams (`append_read_notes` vs `read_note_signals`) because a + /// prose note appended to a JSON body would break `JSON.parse`, and it would + /// be easy for one to gain a note the other never learns about. + #[test] + fn both_surfaces_announce_the_live_overlay() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + store.set_meta("last_commit", "idx0000").unwrap(); + store.set_meta("phase_b_commit", "idx0000").unwrap(); + let a = node_with("fn:a", "function", "a.ts"); + let b = node_with("fn:b", "function", "b.ts"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + store + .put_edge_live(&travsr_core::Edge::new( + a.id, + b.id, + travsr_core::EdgeKind::RefCall, + )) + .unwrap(); + + let prose = append_read_notes(&store, "body".to_string(), None); + assert!(prose.contains("live overlay active"), "prose: {prose}"); + + let signals = read_note_signals(&store, None); + assert!( + signals + .iter() + .any(|s| s.as_str().unwrap_or("").contains("live overlay active")), + "json signals: {signals:?}" + ); + + // And the JSON body still parses with the signal embedded. + let out = serde_json::json!({ "nodes": [], "edges": [], "signals": signals }); + let text = serde_json::to_string(&out).unwrap(); + serde_json::from_str::(&text).expect("body must stay valid JSON"); + } + + /// Build a node for the overlay tests. + fn node_with(sig: &str, kind: &str, path: &str) -> travsr_core::Node { + travsr_core::Node::new( + travsr_core::VName::new("test", "", path, "typescript", sig), + kind, + ) + } + // ── #661 WS-D: head-only note on the deterministic path:line tools ──────── #[test] @@ -11889,6 +12178,7 @@ mod snippet_tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11917,6 +12207,7 @@ mod snippet_tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11937,6 +12228,7 @@ mod snippet_tests { direction: "both", depth: 1, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -12152,6 +12444,7 @@ mod snippet_tests { direction: "both", depth: 1, kind_filter: "file", + provenance: "", token_budget: 0, mode: "", path_prefix: "", diff --git a/crates/travsr-plugin-host/src/sandbox/toolchain.rs b/crates/travsr-plugin-host/src/sandbox/toolchain.rs index 117ca44f..cbd6bcdf 100644 --- a/crates/travsr-plugin-host/src/sandbox/toolchain.rs +++ b/crates/travsr-plugin-host/src/sandbox/toolchain.rs @@ -551,43 +551,103 @@ fn php_access() -> ToolchainAccess { /// Resolve the dotnet install root that holds `host/`, `sdk/`, `shared/` — the /// value `DOTNET_ROOT` must point at, and the dir the sandbox must grant read + /// execute so scip-dotnet's apphost can load the runtime and shell out to -/// `dotnet`. Uses `tool_path` (PATHEXT-aware, so `dotnet.exe` resolves on -/// Windows — the old `dir.join("dotnet")` PATH scan never matched there, leaving -/// Windows with no DOTNET_ROOT and no exec grant). Only accepts a root that -/// actually carries an SDK/host, and falls back to the per-user -/// `~/.dotnet` when `dotnet` on PATH is a runtime-only host. +/// `dotnet`. +/// +/// Tries a `dotnet` launcher — `tool_path` first (PATHEXT-aware, so `dotnet.exe` +/// resolves on Windows), then the well-known installs a minimal-PATH daemon +/// omits — and maps the first one carrying a real SDK to its root. **The +/// non-PATH launchers are the load-bearing part**: a daemon launched from a GUI +/// or a login shell without `/opt/homebrew/bin` on PATH left `tool_path` finding +/// nothing, so no `DOTNET_ROOT` was injected and no exec grant issued, and +/// scip-dotnet failed to launch — the C# lane then produced no reference edges. +/// When no launcher is reachable at all, the well-known SDK roots are probed +/// directly (this also covers the per-user `~/.dotnet` when `dotnet` on PATH is a +/// runtime-only host). fn dotnet_sdk_root() -> Option { - let exe = travsr_core::exec::tool_path("dotnet")?; + if let Some(root) = dotnet_launcher_candidates() + .into_iter() + .filter(|p| p.is_file()) + .find_map(|exe| dotnet_sdk_root_from_binary(&exe)) + { + return Some(root); + } + well_known_dotnet_roots() + .into_iter() + .find(|d| d.join("sdk").is_dir()) +} + +/// `dotnet` launcher locations to try, PATH-resolved first, then the well-known +/// installs a sandboxed or GUI-launched daemon's PATH omits: Homebrew's +/// version-independent `opt/` symlink (Apple silicon and Intel), the official +/// installer directory (where `dotnet` sits directly in the SDK root), and a +/// user-local `~/.dotnet`. +fn dotnet_launcher_candidates() -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = travsr_core::exec::tool_path("dotnet") { + out.push(p); + } + out.extend( + [ + "/opt/homebrew/opt/dotnet/bin/dotnet", + "/usr/local/opt/dotnet/bin/dotnet", + "/usr/local/share/dotnet/dotnet", + "/usr/share/dotnet/dotnet", + ] + .into_iter() + .map(PathBuf::from), + ); + if let Some(h) = home() { + out.push(h.join(".dotnet").join("dotnet")); + } + out +} + +/// The SDK-bearing root a `dotnet` launcher belongs to, or `None` if it carries +/// no `sdk/`. +/// +/// Requires an actual `sdk/` (not just `host/`): scip-dotnet runs `dotnet +/// restore`/build, so a runtime-only host root is useless (`C:\Program +/// Files\dotnet` carries `host/` even when SDK-less, so a `host/` check would +/// wrongly pick it over a real SDK elsewhere). Two layouts: +/// - **Standard** (Windows / dotnet-install / Linux tarball / Program Files): +/// `dotnet(.exe)` sits directly in the root holding `host/ sdk/ shared/`. +/// - **Homebrew macOS:** `…/Cellar/dotnet//bin/dotnet` → `…/libexec`. +fn dotnet_sdk_root_from_binary(exe: &std::path::Path) -> Option { // canonicalize resolves symlinks (Homebrew's `bin/dotnet` shim) but adds the // `\\?\` verbatim prefix on Windows — strip it, or dotnet chokes on a // `\\?\`-prefixed DOTNET_ROOT / exec-grant path. - let real = strip_windows_verbatim(std::fs::canonicalize(&exe).unwrap_or(exe)); + let real = + strip_windows_verbatim(std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf())); let dir = real.parent()?; - // Require an actual `sdk/` (not just `host/`): scip-dotnet runs `dotnet - // restore`/build, so a runtime-only host root is useless. `C:\Program - // Files\dotnet` carries `host/` even when SDK-less, so a `host/` check would - // wrongly pick it over a real SDK elsewhere. - // Standard layout (Windows / dotnet-install / Linux tarball / Program Files): - // `dotnet(.exe)` sits directly in the root holding host/ sdk/ shared/. if dir.join("sdk").is_dir() { return Some(dir.to_path_buf()); } - // Homebrew macOS: …/Cellar/dotnet//bin/dotnet → …/libexec holds the SDK. if let Some(libexec) = dir.parent().map(|p| p.join("libexec")) { if libexec.join("sdk").is_dir() { return Some(libexec); } } - // `dotnet` on PATH is a runtime-only host (no SDK): fall back to the per-user - // dotnet-install default that actually carries an SDK. - if let Some(d) = home().map(|h| h.join(".dotnet")) { - if d.join("sdk").is_dir() { - return Some(d); - } - } None } +/// SDK roots to probe directly when no `dotnet` launcher is reachable, plus the +/// per-user dotnet-install default. Filtered by an actual `sdk/` at the call site. +fn well_known_dotnet_roots() -> Vec { + let mut out: Vec = [ + "/opt/homebrew/opt/dotnet/libexec", + "/usr/local/opt/dotnet/libexec", + "/usr/local/share/dotnet", + "/usr/share/dotnet", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + if let Some(h) = home() { + out.push(h.join(".dotnet")); + } + out +} + /// `scip-dotnet` resolves NuGet packages. Needs: /// - NuGet global-packages dir (read+write) — `dotnet restore` downloads new packages here /// - `~/.dotnet` (read) — dotnet global tools dir; scip-dotnet binary lives here @@ -1017,9 +1077,78 @@ fn go_access() -> ToolchainAccess { #[cfg(test)] mod tests { - use super::strip_windows_verbatim; + use super::{ + dotnet_launcher_candidates, dotnet_sdk_root_from_binary, strip_windows_verbatim, + well_known_dotnet_roots, + }; use std::path::PathBuf; + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "travsr-toolchain-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + /// Homebrew layout: `/bin/dotnet` with the SDK under `/libexec`. + /// This is the install a minimal-PATH daemon could not resolve before the fix. + #[test] + fn dotnet_root_resolves_homebrew_layout() { + let root = scratch("dn-brew"); + std::fs::create_dir_all(root.join("bin")).unwrap(); + std::fs::create_dir_all(root.join("libexec/sdk")).unwrap(); + std::fs::write(root.join("bin/dotnet"), b"#!/bin/sh\n").unwrap(); + + let got = dotnet_sdk_root_from_binary(&root.join("bin/dotnet")).unwrap(); + // `dotnet_sdk_root_from_binary` strips the Windows `\\?\` verbatim prefix + // `canonicalize` adds; strip `want` the same way (a no-op off Windows). + assert_eq!( + got, + strip_windows_verbatim(std::fs::canonicalize(root.join("libexec")).unwrap()) + ); + } + + /// Standard layout: `dotnet` sits directly in the SDK root beside `sdk/`. + #[test] + fn dotnet_root_resolves_standard_layout() { + let root = scratch("dn-std"); + std::fs::create_dir_all(root.join("sdk")).unwrap(); + std::fs::write(root.join("dotnet"), b"#!/bin/sh\n").unwrap(); + + let got = dotnet_sdk_root_from_binary(&root.join("dotnet")).unwrap(); + // Strip the `\\?\` prefix from `want` to match the function (see above). + assert_eq!( + got, + strip_windows_verbatim(std::fs::canonicalize(&root).unwrap()) + ); + } + + /// A runtime-only host (no `sdk/`) is rejected: scip-dotnet needs the SDK to + /// restore/build, so a runtime root is worse than falling through. + #[test] + fn dotnet_root_rejects_runtime_only_host() { + let root = scratch("dn-runtime"); + std::fs::create_dir_all(root.join("host")).unwrap(); + std::fs::write(root.join("dotnet"), b"#!/bin/sh\n").unwrap(); + assert!(dotnet_sdk_root_from_binary(&root.join("dotnet")).is_none()); + } + + /// The Homebrew fallbacks a sandboxed / GUI-launched PATH omits are in the + /// search, both as launcher candidates and as direct-root candidates. + #[test] + fn dotnet_candidates_cover_the_sandbox_path_gap() { + assert!(dotnet_launcher_candidates() + .iter() + .any(|p| p.ends_with("opt/homebrew/opt/dotnet/bin/dotnet"))); + assert!(well_known_dotnet_roots() + .iter() + .any(|p| p.ends_with("opt/homebrew/opt/dotnet/libexec"))); + } + #[test] fn strips_drive_verbatim_prefix() { assert_eq!( diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 2d8876f9..c6032d28 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -30,6 +30,29 @@ pub const NODE_EXACT_LOOKUP_LIMIT: usize = 21; /// Row cap on [`SqliteStore::search_nodes_by_name`] (fuzzy simple-name Tier 2). pub const NODE_NAME_SEARCH_LIMIT: usize = 100; +/// Definition kinds [`SqliteStore::enclosing_definition_at`] recognises as an +/// enclosing scope. Mirrors `definition_node_ids_in_file`; deliberately excludes +/// `field` so a field read never resolves to an enclosing "definition" (#757). +const ENCLOSING_DEFINITION_KINDS: &[&str] = &[ + "function", + "method", + "fn", + "class", + "interface", + "struct", + "trait", + "enum", + "type", + "typedef", + "union", + "object", + "protocol", + "mixin", + "extension", + "namespace", + "init", +]; + /// Type alias for the RFC-018 Step 4 semantic-ANN callback injected by the daemon. /// Returns `(NodeId, cosine_similarity_score)` pairs in descending score order. pub type EmbedKnnHook = @@ -575,6 +598,35 @@ impl Migration for V22TestRole { } } +/// RFC-027 section 9.2: `ref_resolution_state`, so an unresolved reference can +/// be reported as pending instead of silently vanishing or being guessed at. +struct V23RefResolutionState; +impl Migration for V23RefResolutionState { + fn version(&self) -> u32 { + 23 + } + fn up(&self, store: &mut dyn StoreMigratable) -> anyhow::Result<()> { + // CREATE TABLE / INDEX IF NOT EXISTS — idempotent on re-run. + store.exec_ddl(include_str!("migrations/v23_ref_resolution_state.sql")) + } +} + +/// RFC-027 section 12: `ref_resolution_state.resolved_dst`, so the precision +/// meter can compare what the live lane claimed against what Phase B derived. +struct V24RefResolutionTarget; +impl Migration for V24RefResolutionTarget { + fn version(&self) -> u32 { + 24 + } + fn up(&self, store: &mut dyn StoreMigratable) -> anyhow::Result<()> { + // ALTER TABLE … ADD COLUMN has no IF NOT EXISTS in SQLite — guard. + if !store.column_exists("ref_resolution_state", "resolved_dst")? { + store.exec_ddl(include_str!("migrations/v24_ref_resolution_target.sql"))?; + } + Ok(()) + } +} + /// Build the ordered migration runner for the SQLite backend. /// Register new SQLite migrations here; version order is enforced by the runner. fn sqlite_migration_runner() -> MigrationRunner { @@ -600,9 +652,76 @@ fn sqlite_migration_runner() -> MigrationRunner { r.register(V20PurgeOrphanEdgeSites); r.register(V21LexicalSplit); r.register(V22TestRole); + r.register(V23RefResolutionState); + r.register(V24RefResolutionTarget); r } +/// RFC-027 section 9.2: one reference the live lane examined, and what became +/// of it. +/// +/// `resolved_dst` is `None` for a `pending` row — an abstention resolved to +/// nothing, which is the whole point of it. +#[derive(Debug, Clone)] +pub struct RefResolution { + pub src: NodeId, + pub ref_line: u32, + pub ref_col: u32, + pub name: String, + /// `"resolved"` or `"pending"`. + pub state: &'static str, + pub resolved_dst: Option, +} + +/// RFC-027 section 12: how the live lane scored against Phase B. +/// +/// Deliberately three buckets, not two. `unverifiable` is the honest home for a +/// live claim Phase B left no call-site evidence for — Phase B has its own +/// recall gaps, and the code can change between the edit and the commit, so +/// "Phase B did not produce this" is not the same statement as "the live lane +/// was wrong". Folding those into `disagree` would make the meter pessimistic +/// and unactionable, and a meter nobody believes does not gate anything. +/// +/// Precision is therefore reported over the verified subset only, with coverage +/// beside it. Precision without coverage would let 1.0 over two of five hundred +/// claims read as a passing grade. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LivePrecision { + /// Live claims Phase B resolved to the same target at the same call site. + pub agree: u64, + /// Live claims Phase B resolved to a *different* target at that site. These + /// are true false positives, and the number the shipping gate exists for. + pub disagree: u64, + /// Live claims with no ratified call-site evidence either way. + pub unverifiable: u64, +} + +impl LivePrecision { + /// Agreement over the verified subset, or `None` when nothing was verifiable. + /// + /// `None` rather than `1.0`: a lane that resolved nothing verifiable has not + /// earned a perfect score, and returning one would let an empty sample clear + /// the shipping gate. + pub fn precision(&self) -> Option { + let verified = self.agree + self.disagree; + (verified > 0).then(|| self.agree as f64 / verified as f64) + } + + /// Fraction of live claims that could be checked at all. + pub fn coverage(&self) -> f64 { + let total = self.agree + self.disagree + self.unverifiable; + if total == 0 { + return 0.0; + } + (self.agree + self.disagree) as f64 / total as f64 + } + + /// Total live claims this sample covers. + pub fn claims(&self) -> u64 { + self.agree + self.disagree + self.unverifiable + } +} + /// The storage interface every Travsr backend must satisfy. /// /// All methods return `Result` (from `travsr-error`). @@ -2476,6 +2595,165 @@ impl SqliteStore { .map_err(|e| StoreError::Database(e.to_string())) } + /// Stable content fingerprint of every node, for equality assertions. + /// + /// Ordered by id so two graphs built by different routes compare directly. + pub fn node_fingerprint(&self) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT corpus || '|' || path || '|' || signature || '|' || kind \ + FROM nodes ORDER BY id", + ) + .context("node_fingerprint: prepare")?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .context("node_fingerprint: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("node_fingerprint: decode")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// Stable content fingerprint of every edge **including its provenance**. + /// + /// Provenance is in the fingerprint on purpose. The RFC-027 convergence + /// property is not "the same number of edges" but "the same graph", and an + /// un-ratified `live` row sitting where a `scip` row belongs has the same + /// count and the wrong meaning. Counting alone would let exactly the + /// failure this property exists to rule out slip through. + pub fn edge_fingerprint(&self) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT src || '|' || dst || '|' || kind || '|' || provenance \ + FROM edges ORDER BY src, dst, kind", + ) + .context("edge_fingerprint: prepare")?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .context("edge_fingerprint: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("edge_fingerprint: decode")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 8.3: retire the live overlay for the languages that just + /// completed a Phase B run. + /// + /// Most live edges never reach this. When Phase B re-derives an edge the + /// live lane had already found, the ratification write upserts the same + /// `(src, dst, kind)` row and relabels its provenance, so the edge is + /// ratified *in place*. What is still marked `live` afterwards is exactly + /// the set Phase B did **not** re-derive, which is why deleting it cannot + /// lose a real edge. That is also why the ratification writes must run + /// first, and must not be prevented from overwriting a `live` row. + /// + /// **Scoped, not blanket.** The Phase B completion marker advances whenever + /// *any* language produced results, even when another language's sidecar + /// crashed (#712). A blanket delete would therefore discard the overlay for + /// a language whose truth was never re-derived, taking away precision the + /// developer had a moment earlier and not giving it back until that sidecar + /// is fixed and a later commit runs. Live edges for a crashed language + /// survive, still labeled `live`, which is honest. + /// + /// Keyed on the **src node's** language rather than `edges.language`, which + /// is a derived label reconciled after the fact and defaulted at insert + /// time, so it cannot be trusted as the scoping key. + /// + /// Invariant #4 is unaffected: a clean run has nothing crashed, so every + /// language that has nodes is in `languages` and the sweep is total. + pub fn sweep_live_edges_for_languages( + &mut self, + languages: &[String], + ) -> Result { + if languages.is_empty() { + return Ok(0); + } + let placeholders = std::iter::repeat("?") + .take(languages.len()) + .collect::>() + .join(","); + let sql = format!( + "DELETE FROM edges WHERE provenance = 'live' \ + AND src IN (SELECT id FROM nodes WHERE language IN ({placeholders}))" + ); + self.conn + .execute(&sql, params_from_iter(languages.iter())) + .map(|n| n as u64) + .context("sweeping live edges for ratified languages") + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// Scoped orphan sweep for the incremental path: drop edges *owned by* + /// `paths` whose destination is absent from `nodes`. + /// + /// The init path already guarantees this. `flush_staging_to_production` + /// promotes every node in the batch and then drops any staged edge with a + /// missing endpoint, which it calls making "no orphan edges" a store + /// invariant at the staging boundary. `reindex_replace` had no equivalent, + /// so the two paths disagreed and Invariant #4 (incremental == full) failed + /// in the orphan dimension. + /// + /// The edges this matters for are speculative by construction. TypeScript + /// import resolution emits a `resolves-to` candidate per plausible + /// extension (`./user` becomes `user.ts`, `user.tsx`, `user.js`) because it + /// cannot know which one exists without touching the store. On a full index + /// the losers are dropped at the staging boundary; incrementally they + /// survived, so every ordinary TypeScript edit left two dead edges behind + /// and `fsck` reported orphans on a healthy repo. + /// + /// **Call this after the whole batch is written, never per file.** That is + /// the same position the staging flush occupies: an edge from an + /// already-processed file to one later in the same batch is legitimately + /// dangling until that file's nodes land, and sweeping mid-batch would + /// delete it. + /// + /// Scoped to `paths` rather than the whole table because the unscoped + /// [`sweep_orphans`] is a full edge scan, which is a fine cost for `fsck` + /// and not one to pay on every save. + pub fn sweep_orphan_edges_for_paths( + &mut self, + corpus: &str, + paths: &[String], + ) -> Result { + if paths.is_empty() { + return Ok(0); + } + (|| -> AnyResult { + let tx = self + .conn + .transaction() + .context("sweep_orphan_edges_for_paths: begin")?; + let mut swept = 0u64; + for path in paths { + // `src IN (…)` hits the edges primary key prefix, so this is a + // bounded probe per path rather than a scan of the edge table. + swept += tx + .execute( + "DELETE FROM edges \ + WHERE src IN (SELECT id FROM nodes WHERE corpus = ?1 AND path = ?2) \ + AND dst NOT IN (SELECT id FROM nodes)", + params![corpus, path], + ) + .context("sweeping orphan edges for path")? as u64; + } + tx.commit() + .context("sweep_orphan_edges_for_paths: commit")?; + Ok(swept) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// Orphan-edge sweep: deletes every edge whose src or dst is absent from /// `nodes`. Should return 0 in correct operation after Tiers 0–2; a non-zero /// count indicates a write-path invariant violation. @@ -3649,6 +3927,54 @@ LIMIT ?4", Ok(()) } + /// Persist an edge resolved by the RFC-027 live lane (`provenance='live'`). + /// + /// Live edges are an ephemeral overlay over the commit-gated semantic graph: + /// Tree-sitter detected the reference, the live engine resolved it precisely + /// (unambiguous-lexical or LSP-disambiguated), and the next Phase B run + /// ratifies it. + /// + /// **The overlay is purely additive: this never relabels an edge that + /// already exists.** That is what makes retiring it safe. The sweep in + /// [`sweep_live_edges_for_languages`] deletes rows, so anything it can + /// reach must be something the live lane itself created — otherwise + /// retiring the overlay would destroy pre-existing truth rather than + /// returning the graph to what it was. + /// + /// The hazard is concrete, not theoretical. An interface edit re-resolves + /// the files that reference the edited one (section 6.3), and those files + /// were *not* re-parsed, so their `tree-sitter` edges are still in place. An + /// upsert that relabelled them `live` would hand them to the sweep, and any + /// one Phase B did not happen to re-derive would be deleted outright. The + /// convergence property test is what surfaced this. + /// + /// So the `ON CONFLICT` only refreshes a row that is *already* `live`, which + /// keeps re-emission idempotent — `reindex_replace` deletes every outbound + /// edge of a file on each save, so the engine re-emits whole-file rather + /// than only for references it believes are new. Every other provenance is + /// left exactly as it was. + /// + /// This never mints identity (RFC-027 section 8.2): both endpoints must + /// already exist as nodes, so the fencing rule and VName uniqueness hold. + pub fn put_edge_live(&mut self, edge: &Edge) -> Result<(), StoreError> { + self.conn + .execute( + "INSERT INTO edges(src, dst, kind, provenance, confidence) VALUES(?1, ?2, ?3, 'live', ?4) \ + ON CONFLICT(src, dst, kind) DO UPDATE SET \ + confidence = excluded.confidence \ + WHERE edges.provenance = 'live'", + params![ + node_id_to_i64(edge.src), + node_id_to_i64(edge.dst), + edge.kind.as_str(), + edge.confidence.map(|c| c as i64), + ], + ) + .context("inserting live edge") + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(()) + } + /// PR #715: batch-check which of `ids` already exist in `nodes`, one query per /// chunk rather than a `SELECT 1` per id, for the Phase B half-edge guard. /// @@ -3790,6 +4116,15 @@ LIMIT ?4", // ('lsif'/'scip') already on the row is never demoted by a later // write (ADR-002), so a heuristic 'tree-sitter' write cannot // overwrite it. + // + // RFC-027: the ELSE arm demoting a 'live' row to 'tree-sitter' + // is deliberate, not a leak. Both callers that pass a + // 'tree-sitter' provenance (`init_repo_with_progress` and + // `run_background_phase_b_inner`) are Phase B runs, so reaching + // here means Phase B just re-derived this edge and it is no + // longer a live guess. Demotion IS ratification for a + // co-located edge, and it is what leaves the section 8.3 sweep + // holding only the live edges Phase B did not re-derive. tx.execute( "INSERT INTO edges(src, dst, kind, provenance, confidence) \ VALUES(?1, ?2, ?3, ?4, ?5) \ @@ -4604,6 +4939,453 @@ LIMIT ?4", Ok(ids.into_iter().map(i64_to_node_id).collect()) } + /// Count edges carrying `provenance`. + /// + /// RFC-027 leans on this twice: the Invariant #4 convergence check asserts + /// zero `live` rows in a committed graph, and the precision meter needs the + /// overlay's size. Cheap enough to call per assertion at fixture scale. + pub fn count_edges_with_provenance(&self, provenance: &str) -> Result { + self.conn + .query_row( + "SELECT count(*) FROM edges WHERE provenance = ?1", + params![provenance], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64) + .context("counting edges by provenance") + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 12: upsert reference-resolution rows without clearing the + /// file's others. + /// + /// The editor lane arrives *after* the save-path pass has already recorded + /// every reference in the file, and it answers a subset of them. It must + /// therefore upgrade the rows it resolved rather than replace the set: + /// [`Self::replace_ref_resolution_states`] would delete the references the + /// editor did not answer, and those pending rows are the honest record of + /// what is still unresolved. + /// + /// Rows collide with the save-path pass on the `(src, ref_line, ref_col, + /// name)` primary key, so an editor answer flips that reference's existing + /// `pending` row to `resolved` in place instead of forking one reference + /// into two rows. That is what lets the precision meter score the editor + /// lane at all: without a claim row a resolution is invisible to + /// [`Self::live_precision_sample_by_language`], and a language that runs the + /// editor lane alone could never earn the per-language gate a reading. + pub fn upsert_ref_resolution_states( + &mut self, + rows: &[RefResolution], + ) -> Result<(), StoreError> { + (|| -> AnyResult<()> { + let tx = self + .conn + .transaction() + .context("upsert_ref_resolution_states: begin")?; + for r in rows { + tx.execute( + "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state, resolved_dst) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET \ + state = excluded.state, resolved_dst = excluded.resolved_dst", + params![ + node_id_to_i64(r.src), + r.ref_line as i64, + r.ref_col as i64, + r.name, + r.state, + r.resolved_dst.map(node_id_to_i64), + ], + ) + .context("upserting ref_resolution_state row")?; + } + tx.commit().context("upsert_ref_resolution_states: commit")?; + Ok(()) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 9.2: replace a file's reference-resolution rows. + /// + /// Called once per live pass over a file, with every reference that pass + /// saw and what became of it. Whole-file replacement rather than + /// incremental patching, for the same reason the live engine re-emits + /// whole-file: `reindex_replace` has just rewritten the file's nodes, so + /// any row keyed on a node that no longer exists is stale by construction. + /// + /// `rows` are `(src, ref_line, ref_col, name, state)` where `state` is + /// `"pending"` or `"resolved"`. Owned by `src`'s file, mirroring how + /// `edge_sites` scopes ownership, so the delete below can be keyed on the + /// same `(corpus, path)` predicate. + pub fn replace_ref_resolution_states( + &mut self, + corpus: &str, + path: &str, + rows: &[RefResolution], + ) -> Result<(), StoreError> { + (|| -> AnyResult<()> { + let tx = self + .conn + .transaction() + .context("replace_ref_resolution_states: begin")?; + tx.execute( + "DELETE FROM ref_resolution_state \ + WHERE src IN (SELECT id FROM nodes WHERE corpus = ?1 AND path = ?2)", + params![corpus, path], + ) + .context("clearing this file's ref_resolution_state rows")?; + for r in rows { + tx.execute( + "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state, resolved_dst) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET \ + state = excluded.state, resolved_dst = excluded.resolved_dst", + params![ + node_id_to_i64(r.src), + r.ref_line as i64, + r.ref_col as i64, + r.name, + r.state, + r.resolved_dst.map(node_id_to_i64), + ], + ) + .context("inserting ref_resolution_state row")?; + } + tx.commit() + .context("replace_ref_resolution_states: commit")?; + Ok(()) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 12: score the live lane's claims against Phase B's truth. + /// + /// Run at ratification, **after** the Phase B writes and **before** the + /// sweep. After, because it compares against what Phase B derived; before, + /// because the sweep is about to discard the evidence. + /// + /// Verification is at **call-site line** granularity, joining each live + /// claim in `ref_resolution_state` to the `edge_sites` rows Phase B recorded + /// for the same `(src, line)`. Anything coarser is not safe to gate on: a + /// function that calls several things would let a mis-targeted claim match + /// some *other* call's correct answer and score as agreement. An optimistic + /// meter is worse than none, because it clears a bar the lane has not met. + /// + /// A claim whose site Phase B recorded nothing for is `unverifiable`, not + /// wrong — Phase B has recall gaps of its own, and the code can change + /// between the edit and the commit. + /// + /// `SCIP wins all ties` (section 12) falls out of the ordering rather than + /// needing a rule here: by the time this runs, Phase B has already written + /// its answer over any co-located live row. + pub fn live_precision_sample(&self) -> Result { + (|| -> AnyResult { + let mut stmt = self + .conn + .prepare( + // Per claim: does Phase B have any site at this line, and does + // one of them name the same target? + "SELECT \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line) AS has_site, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line \ + AND s.dst = r.resolved_dst) AS matches \ + FROM ref_resolution_state r \ + WHERE r.state = 'resolved' AND r.resolved_dst IS NOT NULL", + ) + .context("live_precision_sample: prepare")?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)? == 1, row.get::<_, i64>(1)? == 1)) + }) + .context("live_precision_sample: query")?; + + let mut out = LivePrecision::default(); + for row in rows { + match row.context("live_precision_sample: decode")? { + (false, _) => out.unverifiable += 1, + (true, true) => out.agree += 1, + (true, false) => out.disagree += 1, + } + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 12: [`live_precision_sample`], split by language. + /// + /// The shipping gate is **per-language** ("if it cannot hold that bar for a + /// language, the lane is disabled for that language"), so the meter has to + /// attribute each claim to one. Attribution is by the **source** node's + /// language — the file that was edited, which is the file the live lane ran + /// on — the same key the ratification sweep scopes its delete on, so the + /// meter and the sweep never disagree about which language a row belongs to. + /// + /// Bucketing is in Rust rather than a SQL `GROUP BY` so the two per-claim + /// `EXISTS` sub-selects stay identical to [`live_precision_sample`]; the + /// only change is carrying `n.language` alongside them. + pub fn live_precision_sample_by_language( + &self, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT n.language, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line) AS has_site, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line \ + AND s.dst = r.resolved_dst) AS matches \ + FROM ref_resolution_state r \ + JOIN nodes n ON n.id = r.src \ + WHERE r.state = 'resolved' AND r.resolved_dst IS NOT NULL", + ) + .context("live_precision_sample_by_language: prepare")?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? == 1, + row.get::<_, i64>(2)? == 1, + )) + }) + .context("live_precision_sample_by_language: query")?; + + let mut out: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for row in rows { + let (lang, has_site, matches) = + row.context("live_precision_sample_by_language: decode")?; + let bucket = out.entry(lang).or_default(); + match (has_site, matches) { + (false, _) => bucket.unverifiable += 1, + (true, true) => bucket.agree += 1, + (true, false) => bucket.disagree += 1, + } + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 10: the pending references in `path`, for the + /// `live_overlay` freshness note. + /// + /// Returns `(name, ref_line)` per unresolved reference, so a consumer can + /// say *which* call sites are un-targeted rather than only how many. + pub fn pending_refs_in_file( + &self, + corpus: &str, + path: &str, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT r.name, r.ref_line FROM ref_resolution_state r \ + JOIN nodes n ON n.id = r.src \ + WHERE r.state = 'pending' AND n.corpus = ?1 AND n.path = ?2 \ + ORDER BY r.ref_line ASC", + ) + .context("pending_refs_in_file: prepare")?; + let rows = stmt + .query_map(params![corpus, path], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)) + }) + .context("pending_refs_in_file: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("decoding pending ref row")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 sections 6.3 and 8.7.5: the files whose stranded live edges the + /// save of `path` can now restore — a dependent holding a `pending` + /// reference that names a symbol `path` defines. + /// + /// This is the reverse-closure that the interface-edit fix (§8.7.5) hands to + /// the editor. The editor only ever publishes the document that was saved, so + /// a dependent whose edge into `path` was invalidated is never re-resolved on + /// its own; naming it here lets the target request pull it in beside the + /// saved file's own references, keeping the editor the initiator. + /// + /// Matched on the pending reference's leaf name against this file's + /// definition signatures — `kind:Leaf` (unqualified) or `kind:Qual.Leaf` + /// (qualified), the two shapes `signature` takes. Scoped to `language`: a + /// live edge stays within one language, both because §8.2 keeps it + /// intra-corpus and because a cross-language definition would not resolve + /// through the dependent's own provider anyway. Leaf names are identifiers, + /// so they carry no `LIKE` wildcard to escape. + /// + /// Capped at `limit` files (`LIVE_CLOSURE_FILE_CAP` at the call site): a + /// symbol thousands of files are pending on is a hot utility, and the + /// freshness is not worth re-resolving every one on each save. Truncation + /// costs recall, which the commit-gated path repairs. Over-inclusion (a leaf + /// name shared by an unrelated symbol) is recall-neutral: the editor resolves + /// the real position and the daemon maps it fail-closed, so a spurious file + /// costs a round trip, never a wrong edge. + pub fn dependents_pending_on_file( + &self, + corpus: &str, + path: &str, + language: &str, + limit: usize, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT DISTINCT dep.path \ + FROM ref_resolution_state r \ + JOIN nodes dep ON dep.id = r.src \ + WHERE r.state = 'pending' \ + AND dep.corpus = ?1 AND dep.path <> ?2 AND dep.language = ?3 \ + AND EXISTS ( \ + SELECT 1 FROM nodes def \ + WHERE def.corpus = ?1 AND def.path = ?2 AND def.language = ?3 \ + AND (def.signature LIKE '%:' || r.name \ + OR def.signature LIKE '%.' || r.name) \ + ) \ + ORDER BY dep.path ASC LIMIT ?4", + ) + .context("dependents_pending_on_file: prepare")?; + let rows = stmt + .query_map(params![corpus, path, language, limit as i64], |row| { + row.get::<_, String>(0) + }) + .context("dependents_pending_on_file: query")?; + let mut out = Vec::new(); + for row in rows { + out.push(row.context("decoding dependents_pending_on_file row")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 10: how many references are currently unresolved. + /// + /// The count half of [`pending_refs_in_file`], for the envelope note where + /// only the magnitude is wanted. + pub fn pending_ref_count(&self) -> Result { + self.conn + .query_row( + "SELECT count(*) FROM ref_resolution_state WHERE state = 'pending'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64) + .context("counting pending references") + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 8.3: clear pending rows that Phase B has since resolved. + /// + /// Run at ratification. A reference whose enclosing node now has an + /// outgoing edge is no longer pending, whatever resolved it, so this is + /// keyed on edge existence rather than on which lane won. + /// + /// Returns the number of rows cleared. + pub fn clear_resolved_pending_refs(&mut self) -> Result { + self.conn + .execute( + "DELETE FROM ref_resolution_state \ + WHERE state = 'pending' \ + AND EXISTS (SELECT 1 FROM edges e WHERE e.src = ref_resolution_state.src)", + [], + ) + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 7.5: map a `(path, line)` position to the graph node + /// that owns it, returning the **narrowest** enclosing definition. + /// + /// This is the `location_to_node` primitive the live lane needs at both + /// ends: the call site's enclosing function becomes the edge's `src`, and + /// the definition the editor pointed at becomes its `dst`. + /// + /// The RFC describes this as range-source-aware (current Tree-sitter spans + /// for dirty files, SCIP ranges for clean ones). In this store one lookup + /// covers both, because a node's `line`/`end_line` always come from the most + /// recent parse of its file and Phase A re-parses a file on save before the + /// live engine runs. A position that lands in no definition span returns + /// `None`, which the caller must treat as an abstention rather than + /// guessing: fail-closed is the whole precision argument (section 8.1). + /// + /// Ordering matches [`find_narrowest_enclosing`]: widest-last, so the first + /// containing span is the tightest one. + pub fn enclosing_definition_at( + &self, + corpus: &str, + path: &str, + line: u32, + ) -> Result, StoreError> { + self.enclosing_node_at(corpus, path, line, ENCLOSING_DEFINITION_KINDS) + } + + /// The tightest node whose span contains `line`, restricted to `kinds`. + /// + /// Generalizes [`Self::enclosing_definition_at`] so a caller can supply its + /// own valid endpoint kinds. RFC-027's live lane needs this: mapping the + /// target of a `ref/field` edge must find the `field` node the editor's + /// definition provider pointed at, which the definition-only kind set of + /// `enclosing_definition_at` deliberately excludes (`get_callers` must never + /// see a field read as a caller, #757). Each live edge kind therefore passes + /// the kinds that are valid *for it*, and the gate is the kind set itself. + /// + /// `kinds` must contain only internal constant strings; they are bound as + /// parameters, never interpolated, so no user input can reach the SQL text. + /// An empty `kinds` matches nothing (`Ok(None)`), never everything. + pub fn enclosing_node_at( + &self, + corpus: &str, + path: &str, + line: u32, + kinds: &[&str], + ) -> Result, StoreError> { + if kinds.is_empty() { + return Ok(None); + } + (|| -> AnyResult> { + // Placeholders start at ?4: ?1 corpus, ?2 path, ?3 line. + let placeholders = (0..kinds.len()) + .map(|i| format!("?{}", i + 4)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id FROM nodes \ + WHERE corpus = ?1 AND path = ?2 \ + AND line IS NOT NULL AND end_line IS NOT NULL \ + AND line <= ?3 AND end_line >= ?3 \ + AND kind IN ({placeholders}) \ + ORDER BY (end_line - line) ASC, id ASC LIMIT 1" + ); + let mut stmt = self + .conn + .prepare_cached(&sql) + .context("enclosing_node_at: prepare")?; + use rusqlite::types::Value; + let mut vals: Vec = vec![ + Value::Text(corpus.to_string()), + Value::Text(path.to_string()), + Value::Integer(line as i64), + ]; + vals.extend(kinds.iter().map(|k| Value::Text((*k).to_string()))); + let id: Option = stmt + .query_row(params_from_iter(vals), |row| row.get(0)) + .optional() + .context("enclosing_node_at: query")?; + Ok(id.map(i64_to_node_id)) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// G1: Look up the unified `NodeId` for a raw SCIP symbol string. /// /// Returns `None` if the symbol has not been aliased (i.e. no tree-sitter @@ -7250,20 +8032,24 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare_cached("SELECT dst, kind, confidence FROM edges WHERE src = ?1") + .prepare_cached( + "SELECT dst, kind, confidence, provenance FROM edges WHERE src = ?1", + ) .context("preparing iter_edges_from query")?; let rows = stmt .query_map(params![node_id_to_i64(src)], |row| { let dst_i64: i64 = row.get(0)?; let kind_str: String = row.get(1)?; let confidence: Option = row.get(2)?; - Ok((dst_i64, kind_str, confidence)) + let provenance: String = row.get(3)?; + Ok((dst_i64, kind_str, confidence, provenance)) }) .context("executing iter_edges_from query")?; let mut out = Vec::new(); for row in rows { - let (dst_i64, kind_str, confidence) = row.context("decoding edge row")?; + let (dst_i64, kind_str, confidence, provenance) = + row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; out.push(Edge { @@ -7271,6 +8057,7 @@ impl Store for SqliteStore { dst: i64_to_node_id(dst_i64), kind, confidence: confidence.map(|c| c as u8), + provenance: Some(provenance), }); } tracing::debug!(edges_returned = out.len()); @@ -7288,18 +8075,18 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare("SELECT dst FROM edges WHERE src = ?1 AND kind = ?2") + .prepare("SELECT dst, provenance FROM edges WHERE src = ?1 AND kind = ?2") .context("preparing iter_edges_from_kind query")?; let rows = stmt .query_map(params![node_id_to_i64(src), kind.as_str()], |row| { - row.get::<_, i64>(0) + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) }) .context("executing iter_edges_from_kind query")?; let mut out = Vec::new(); for row in rows { - let dst_i64 = row.context("decoding iter_edges_from_kind row")?; - out.push(Edge::new(src, i64_to_node_id(dst_i64), kind)); + let (dst_i64, provenance) = row.context("decoding iter_edges_from_kind row")?; + out.push(Edge::new(src, i64_to_node_id(dst_i64), kind).with_provenance(provenance)); } tracing::debug!(edges_returned = out.len()); Ok(out) @@ -7316,7 +8103,7 @@ impl Store for SqliteStore { (|| -> AnyResult> { let placeholders = srcs.iter().map(|_| "?").collect::>().join(","); let sql = format!( - "SELECT src, dst, kind, confidence FROM edges WHERE src IN ({placeholders})" + "SELECT src, dst, kind, confidence, provenance FROM edges WHERE src IN ({placeholders})" ); // prepare() not prepare_cached(): the SQL string varies per chunk length // (different number of '?' placeholders), so prepare_cached would create a @@ -7334,12 +8121,14 @@ impl Store for SqliteStore { let dst_i64: i64 = row.get(1)?; let kind_str: String = row.get(2)?; let confidence: Option = row.get(3)?; - Ok((src_i64, dst_i64, kind_str, confidence)) + let provenance: String = row.get(4)?; + Ok((src_i64, dst_i64, kind_str, confidence, provenance)) }) .context("executing iter_edges_from_batch")?; let mut out = Vec::new(); for row in rows { - let (src_i64, dst_i64, kind_str, confidence) = row.context("decoding edge row")?; + let (src_i64, dst_i64, kind_str, confidence, provenance) = + row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; out.push(Edge { @@ -7347,6 +8136,7 @@ impl Store for SqliteStore { dst: i64_to_node_id(dst_i64), kind, confidence: confidence.map(|c| c as u8), + provenance: Some(provenance), }); } tracing::debug!(edges_returned = out.len()); @@ -7360,22 +8150,23 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare("SELECT src, kind FROM edges WHERE dst = ?1") + .prepare("SELECT src, kind, provenance FROM edges WHERE dst = ?1") .context("preparing iter_edges_to query")?; let rows = stmt .query_map(params![node_id_to_i64(dst)], |row| { let src_i64: i64 = row.get(0)?; let kind_str: String = row.get(1)?; - Ok((src_i64, kind_str)) + let provenance: String = row.get(2)?; + Ok((src_i64, kind_str, provenance)) }) .context("executing iter_edges_to query")?; let mut out = Vec::new(); for row in rows { - let (src_i64, kind_str) = row.context("decoding edge row")?; + let (src_i64, kind_str, provenance) = row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; - out.push(Edge::new(i64_to_node_id(src_i64), dst, kind)); + out.push(Edge::new(i64_to_node_id(src_i64), dst, kind).with_provenance(provenance)); } tracing::debug!(edges_returned = out.len()); Ok(out) @@ -9382,6 +10173,178 @@ mod tests { ); } + /// DEBT-75: every `iter_edges_*` reader must carry the row's true + /// `edges.provenance`, so a consumer that traverses the graph (the MCP + /// surface, `travsr graph --format json`) reports how an edge was actually + /// derived instead of assuming `tree-sitter`. Before this, an `lsif` edge + /// read back through BFS was indistinguishable from a heuristic one. + #[test] + fn edge_readers_carry_true_provenance() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + + // One tree-sitter edge and one lsif edge out of the same source. + let ts_edge = Edge::new(a.id, b.id, EdgeKind::DefinesBinding); + let lsif_edge = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge(&ts_edge).unwrap(); + store.put_edge_lsif(&lsif_edge).unwrap(); + + let prov = |edges: &[Edge], kind: EdgeKind| -> String { + edges + .iter() + .find(|e| e.kind == kind) + .and_then(|e| e.provenance.clone()) + .unwrap_or_else(|| panic!("no {kind:?} edge returned")) + }; + + let from = store.iter_edges_from(a.id).unwrap(); + assert_eq!(prov(&from, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&from, EdgeKind::DefinesBinding), "tree-sitter"); + + let to = store.iter_edges_to(b.id).unwrap(); + assert_eq!(prov(&to, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&to, EdgeKind::DefinesBinding), "tree-sitter"); + + let batch = store.iter_edges_from_batch(&[a.id]).unwrap(); + assert_eq!(prov(&batch, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&batch, EdgeKind::DefinesBinding), "tree-sitter"); + + let by_kind = store.iter_edges_from_kind(a.id, EdgeKind::RefCall).unwrap(); + assert_eq!(prov(&by_kind, EdgeKind::RefCall), "lsif"); + } + + /// RFC-027: the live overlay is purely additive. + /// + /// It creates edges that were absent and refreshes its own, but never + /// relabels a row another lane wrote. That is the property that makes the + /// ratification sweep safe: everything the sweep can delete is something + /// the live lane created, so retiring the overlay returns the graph to what + /// it was instead of destroying pre-existing truth. + #[test] + fn the_live_overlay_is_additive_and_never_relabels_another_lane() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let e = Edge::new(a.id, b.id, EdgeKind::RefCall); + + let prov = |st: &SqliteStore| -> String { + st.iter_edges_from(a.id).unwrap()[0] + .provenance + .clone() + .expect("reader must carry provenance") + }; + + // Absent -> live: the overlay's actual job. + store.put_edge_live(&e).unwrap(); + assert_eq!(prov(&store), "live", "a missing edge is created as live"); + + // live -> live: idempotent. reindex_replace wipes a file's outbound + // edges on every save, so the engine re-emits whole-file (plan R5). + store.put_edge_live(&e).unwrap(); + assert_eq!(prov(&store), "live"); + + // live -> lsif: ratification wins. + store.put_edge_lsif(&e).unwrap(); + assert_eq!(prov(&store), "lsif", "lsif must overwrite live"); + + // lsif -> live: must NOT demote ratified truth. + store.put_edge_live(&e).unwrap(); + assert_eq!( + prov(&store), + "lsif", + "a live write must never demote lsif/scip" + ); + + // tree-sitter -> live: must NOT relabel. This is the one the sweep + // depends on. An interface edit re-resolves files that were never + // re-parsed and still hold their tree-sitter edges; relabelling those + // would hand pre-existing truth to the sweep to delete. + let e2 = Edge::new(a.id, b.id, EdgeKind::DefinesBinding); + store.put_edge(&e2).unwrap(); + store.put_edge_live(&e2).unwrap(); + let ts = store + .iter_edges_from(a.id) + .unwrap() + .into_iter() + .find(|x| x.kind == EdgeKind::DefinesBinding) + .and_then(|x| x.provenance) + .unwrap(); + assert_eq!( + ts, "tree-sitter", + "a live write must leave an existing tree-sitter row untouched" + ); + } + + /// RFC-027: a Phase B write over a `live` row ratifies it. + /// + /// Both callers that pass a 'tree-sitter' provenance are Phase B runs + /// (`init_repo_with_progress`, `run_background_phase_b_inner`), so such a + /// write reaching an existing `live` row means Phase B just re-derived that + /// edge by native leaf-name resolution. Relabelling it is correct: it is no + /// longer a live guess. This is what leaves the section 8.3 sweep holding + /// only the live edges Phase B did *not* re-derive, so deleting those + /// cannot lose a real edge. + #[test] + fn write_phase_b_batch_ratifies_a_live_edge() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let e = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge_live(&e).unwrap(); + + store + .write_phase_b_batch(&[], std::slice::from_ref(&e), "tree-sitter") + .unwrap(); + assert_eq!( + store.iter_edges_from(a.id).unwrap()[0] + .provenance + .as_deref(), + Some("tree-sitter"), + "Phase B re-derived this edge natively, so it is ratified, not live" + ); + + // And a SCIP-provenance batch ratifies it as compiler truth. + store + .write_phase_b_batch(&[], std::slice::from_ref(&e), "scip") + .unwrap(); + assert_eq!( + store.iter_edges_from(a.id).unwrap()[0] + .provenance + .as_deref(), + Some("scip"), + "scip must overwrite live" + ); + } + + /// Provenance is metadata about how an edge was derived, not part of its + /// identity: the store's primary key is `(src, dst, kind)`. A read-back edge + /// must therefore still compare equal to the constructed one it came from. + #[test] + fn provenance_is_not_part_of_edge_identity() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let built = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge_lsif(&built).unwrap(); + + let read_back = store.iter_edges_from(a.id).unwrap(); + assert_eq!(read_back.len(), 1); + assert_eq!(read_back[0].provenance.as_deref(), Some("lsif")); + assert_eq!( + read_back[0], built, + "an edge differing only in provenance is the same edge" + ); + } + // V4 (no package column) → V5 migration must add package with empty default, // add language column to edges, and existing nodes must read back correctly. #[test] @@ -10120,6 +11083,7 @@ mod tests { dst: node_b.id, kind: travsr_core::EdgeKind::RefCall, confidence: None, + provenance: None, }; let batch = vec![ @@ -10185,6 +11149,7 @@ mod tests { dst: node.id, kind: travsr_core::EdgeKind::RefCall, confidence: None, + provenance: None, }; let batch = vec![ diff --git a/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql b/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql new file mode 100644 index 00000000..34dfbe45 --- /dev/null +++ b/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql @@ -0,0 +1,33 @@ +-- RFC-027 section 9.2: honest abstention for the live semantic lane. +-- +-- A reference Tree-sitter detected but nothing could resolve is not an edge and +-- must never be rendered as one. It is also not nothing: "there is a call here, +-- and its target is not yet known" is a true and useful statement, and the +-- whole precision-first argument rests on being able to say it out loud instead +-- of guessing. This table is where that statement lives. +-- +-- Named `ref_resolution_state` rather than `resolution_state` to avoid +-- colliding with the daemon's unrelated `record_dart_resolution_state` +-- bookkeeping, which tracks Dart Phase B availability. +-- +-- Modelled on `edge_sites`: a composite PK so re-resolving a file is an actual +-- dedup rather than unbounded growth, and WITHOUT ROWID so rows cluster on that +-- PK and its prefix (src) serves as the lookup index without a second one. +-- +-- `state` is 'pending' or 'resolved'. Rows are owned by their `src` node's file +-- the same way `edge_sites` rows are, so the live engine deletes a file's rows +-- before re-resolving it and a vanished symbol's rows go with its node. +CREATE TABLE IF NOT EXISTS ref_resolution_state ( + src INTEGER NOT NULL, + ref_line INTEGER NOT NULL, + ref_col INTEGER NOT NULL, + name TEXT NOT NULL, + state TEXT NOT NULL, + PRIMARY KEY (src, ref_line, ref_col, name) +) WITHOUT ROWID; + +-- The MCP surface asks "what is pending in this file", which is a scan by state +-- across many src nodes rather than a probe of one. Without this it is a full +-- table scan on every query that mentions a dirty file. +CREATE INDEX IF NOT EXISTS idx_ref_resolution_state + ON ref_resolution_state(state); diff --git a/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql b/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql new file mode 100644 index 00000000..81cdca1e --- /dev/null +++ b/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql @@ -0,0 +1,12 @@ +-- RFC-027 section 12: record what the live lane actually claimed, so its +-- precision can be measured against Phase B's answer at the next commit. +-- +-- The edge itself cannot answer this. By the time ratification runs, a live edge +-- Phase B re-derived has already been relabelled in place, and one it did not +-- re-derive is about to be swept. Either way the edges table no longer says +-- "the live lane resolved this reference to this node" — only that a row exists, +-- or does not. This column is that claim, kept independently of the edge's fate. +-- +-- NULL for a pending row: an abstention resolved to nothing, which is the whole +-- point of it. +ALTER TABLE ref_resolution_state ADD COLUMN resolved_dst INTEGER; diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md new file mode 100644 index 00000000..e704c8be --- /dev/null +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -0,0 +1,434 @@ +# RFC-027: Live Semantic Resolution — LSP-Assisted Incremental Phase B + +**Date:** 2026-08-24 +**Status:** Draft +**Phase:** Post-v0.11.0 +**Author:** Abhishek +**Related:** ADR-009 (SCIP vs LSIF wire format), ADR-002 (edge provenance policy), ADR-006 (subprocess trust), ADR-017 (unified plugin sandbox trust), RFC-005 (cross-language edge resolution), RFC-008 (multi-language extension architecture), RFC-009 (cross-language bridge plugins), RFC-014 (Phase B symbol unification) +**Supersedes:** N/A + +--- + +## 1. Summary + +Travsr's semantic graph (Phase B) is **commit-gated**: cross-file, type-resolved edges are only rebuilt when the git hook fires. Between commits, only Tree-sitter (Phase A) updates the graph, which sees *that* a reference exists but not *which* symbol it resolves to. The result is **mid-edit degradation**: the semantic graph goes vague on exactly the code the developer is actively changing. + +This RFC proposes a **live semantic resolution lane** that fills the between-commits gap without regressing the commit-gated guarantee. It rests on a strict separation of responsibilities: + +``` +Tree-sitter → DETECTS a reference exists (live, cheap, always on) +SCIP graph → OWNS node identity (Kythe VName) (durable ground truth) +LSP → DISAMBIGUATES a specific position (live, type-aware, surgical, optional) +Commit SCIP → RATIFIES the region, heals drift (deterministic convergence) +``` + +LSP is used **only as a disambiguation oracle over SCIP-identified candidates** — never as a symbol source, never for identity, never for full-repo extraction. When no language server is available, the system degrades gracefully to today's commit-gated behavior with **zero regression**. + +The design is precision-first and fail-closed: the live lane may only emit an edge when resolution is provably correct, and abstains (marks the reference *pending*) otherwise. Honest staleness beats confident wrongness. + +--- + +## 2. Motivation + +### 2.1 The felt problem (dogfooded) + +CLAUDE.md's dogfooding mandate says "the friction you feel is the bug report." This RFC responds to a friction hit repeatedly while developing Travsr itself: after editing a file, `get_callers`, `get_blast_radius`, and `find_references` on symbols in that file return stale or incomplete semantic edges until the next commit. The developer (or agent) navigating their own in-progress change gets the *least* reliable graph at the *moment they most need it*. + +### 2.2 Why this is not solved by the existing stack + +- **Phase A (Tree-sitter)** runs live on save and updates structural nodes/edges, but cannot resolve semantic edges: it knows `user.save()` is a call, not *which* `save`. Name resolution requires scope + type analysis Tree-sitter does not perform. +- **Phase B (SCIP)** is correct but commit-gated by deliberate decision (recorded in code at `travsr-daemon/src/lib.rs` — "Phase B stays commit-gated" / "still commit-gated on purpose"; commit-gating is a cost concession, not a belief that staleness is acceptable). Running full SCIP on every keystroke is not viable. + +The gap is narrow but real: **cross-file, type-resolved edges for uncommitted edits.** This RFC closes that gap for the surface where it hurts (the IDE) while preserving every existing correctness property. + +### 2.3 Scope of the value + +Be precise about what this buys: the durable graph converges at commit **regardless** of this feature. What the live lane buys is **correctness latency** — precise resolution in seconds mid-edit instead of at the next commit. That is a freshness improvement, not new capability. It is nonetheless high-value because the pain it addresses *is* a freshness problem. + +--- + +## 3. Non-Goals + +- **Not** a replacement for SCIP. SCIP remains the durable, deterministic ground truth and the sole source of cross-corpus identity. +- **Not** LSP as a graph builder or identity source. Prior analysis (see §11 Alternatives) rejected both. +- **Not** a cross-language feature. Live edges are strictly intra-corpus; they never enter the `BridgeRegistry` (RFC-009). +- **Not** a change to the commit-gated Phase B pipeline's outputs. The committed graph is byte-for-byte what a full reindex would produce (Invariant #4). +- **Not** a headless-daemon feature in v1. Initial delivery targets the IDE surface where a language server is already running (§7.6). + +--- + +## 4. Background & Prior Art + +- **ADR-009** established SCIP for new languages and LSIF for incumbents, keyed on *stable, package-qualified symbol identity*. That identity is the foundation this RFC builds on and must never be weakened. +- **ADR-009 Rule 4** forbids feeding *synthetic* (non-corpus-verified) symbols into the bridge registry, because they can collide with a real symbol from a different corpus and violate RFC-005's `src.corpus == dst.corpus` invariant. LSP-derived resolutions are exactly such synthetic symbols; this RFC's fencing rule (§8.2) is a direct consequence. +- **ADR-002 Rule 1** requires every edge to carry a provenance tag (`provenance TEXT NOT NULL DEFAULT 'tree-sitter'`). This RFC adds a new value (§9.1). +- **Principal Architect Invariant #4** (incremental correctness): a full reindex and an incremental reindex of the same codebase must produce identical graphs. This is the load-bearing correctness property and §8.3 discharges it. +- **Principal Architect Invariant #3** (LLM prohibition on structural reasoning): note LSP resolution is *deterministic compiler-grade analysis*, not an LLM. It does not violate Invariant #3. It is an algorithm, used as an oracle. + +--- + +## 5. The Core Model — Four Lanes + +| Lane | Produces | Lifetime | Determinism | Provenance | +|---|---|---|---|---| +| Tree-sitter (Phase A) | structural nodes/edges, reference *detection* | live | deterministic | `tree-sitter` | +| SCIP (Phase B) | semantic edges, stable identity | durable | deterministic (pinned indexer) | `scip` / `lsif` | +| **Live resolution (new)** | semantic edges for dirty regions | ephemeral (until commit) | non-deterministic (env-dependent) | `live` | +| Commit ratification | replaces live edges with SCIP truth | durable | deterministic | `scip` / `lsif` | + +The live lane is an **overlay**. It never persists past the commit that ratifies it. The committed graph is always SCIP-derived, which is why Invariant #4 holds (§8.3). + +--- + +## 6. Edit Classification + +The correct invalidation region depends on *what* changed. The live engine classifies each save: + +### 6.1 Body edit +A change that does **not** alter any symbol's referenced surface — no rename, no signature change, no added/removed export, no new/removed reference crossing the file boundary. + +→ **Local re-resolution only.** Re-resolve the edited file's outgoing references. No other file is touched. This is the common case (most edits are body edits) and is cheap. + +### 6.2 Interface edit +A change to the *referenced surface* of a symbol that has incoming edges or is exported: rename, signature change, visibility change, deletion, or introduction of a symbol that other files already reference-by-name. + +→ **Reverse-closure invalidation** (§6.3). This is the expensive case, paid only when the public surface actually changes — mirroring how incremental compilers (salsa, `tsc --incremental`) invalidate downstream only on interface change, not on every body edit. + +### 6.3 Reverse-dependency closure +Given the set `S` of changed symbols in the edited file: + +``` +closure = { f : file f contains an edge whose target ∈ S } + ∪ { f : file f contains an unresolved reference whose name ∈ names(new symbols in S) } +``` + +The store already maintains reverse edges, so the first set is a direct lookup. The second set catches *newly resolvable* references (a symbol that other files referenced-by-name now exists). Files in `closure` have their boundary edges re-resolved against the changed surface. + +**Asymmetry (critical):** *outgoing* edges (edited file → others) are recomputable from the edited file alone; *incoming* edges (others → edited file) are not, because their sources live elsewhere. Incoming edges are stable **only if** the referenced surface is unchanged. This is exactly why interface edits trigger the closure and body edits do not. + +--- + +## 7. Resolution Flow + +For each new or unresolved reference `R` at position `P` in a dirty file: + +``` +1. LOCAL SCOPE CHECK (Tree-sitter, deterministic) + Is R a free/unbound reference (not a local var, param, or shadowed name)? + If R is import-bound, record the import's declared target. + → if R binds to a local/param: resolve locally, no graph edge needed. DONE. + +2. CANDIDATE SET (graph, by name, narrowed by any import target) + candidates = graph.symbols_named(R.name) filtered by import/module hint + +3a. UNAMBIGUOUS LEXICAL (|candidates| == 1 AND local-clean) + → emit edge to the single candidate. + provenance = "live", confidence = high. + This lane requires NO language server. + +3b. AMBIGUOUS + LSP AVAILABLE (|candidates| > 1) + → query textDocument/definition at P, tagged to the current buffer version. + location L ← definition response (must match buffer version, else wait/retry) + node N ← location_to_node(L) // §7.5 + if N resolved → emit edge to N. provenance = "live", confidence = high. + else → ABSTAIN (mark pending). // L pointed outside the graph + +3c. AMBIGUOUS + NO LSP + → ABSTAIN. Mark R as ref_resolution_state = pending. + +4. ABSTENTION IS HONEST + A pending reference is surfaced as "call site present, target not yet + resolved (stale since your edit; resolves at commit)" — never as a + fabricated edge. +``` + +### 7.4 Why 3b restores precision without sacrificing it +`textDocument/definition` is **real type-aware resolution** — the language server knows the static type of the receiver, so it resolves `user.save()` to the correct `save`. This converts the fail-closed lexical lane's *"abstain on ambiguity"* into *"resolve precisely,"* raising recall on precisely the cases (method-on-receiver, overloads) the lexical lane must give up — **without** a precision cost, because it is resolution, not a guess. `definition` is also the most universally implemented LSP method (unlike `callHierarchy`), so capability coverage is broad; where absent, 3c applies and precision is never at risk. + +### 7.5 `location_to_node` — the dirty-file span nuance +A definition location `L = (uri, range)` is in the *current* buffer state. Mapping it to a graph node requires ranges that match the buffer: + +- **Def in a clean file:** SCIP ranges are still valid → direct range lookup. +- **Def in a dirty file:** SCIP ranges are stale (shifted by the edit) → map via Tree-sitter's *current* spans, which were just re-parsed. + +The location→node index is therefore **range-source-aware**: current spans for dirty files, SCIP ranges for clean files. The reconciliation cost is confined to the (small) dirty set. + +### 7.6 Surface-specific server access +| Surface | Server access | Mechanism | +|---|---|---| +| **VS Code / IDE extension** | piggyback the **already-running** language provider | `vscode.executeDefinitionProvider(uri, position)` routes to the active language extension; no separate process spawned | +| **JetBrains** | piggyback platform's resolution API | equivalent PSI-reference resolution | +| **Headless CLI daemon** | spawn-own or degrade | out of scope for v1; falls to §7.3c / commit-gated | + +The value concentrates where the assumption ("a dev has LSP running") holds *and* where mid-edit pain lives: the IDE. In the IDE, LSP usage is genuinely free (no spawn) and surgical (a handful of point queries on dirty files), so LSP's chattiness weakness never bites. + +### 7.7 End-to-end sequence + +Live path (on save) and ratification path (on commit) in one view. Note the three +terminal states of a reference — **emit (lexical)**, **emit (LSP-resolved)**, and +**abstain (pending)** — and that every `live` edge is deleted and replaced by SCIP +at commit (§8.3), which is why the committed graph is always deterministic. + +```mermaid +sequenceDiagram + autonumber + actor Dev + participant ED as Editor / IDE + participant TS as Tree-sitter (Phase A) + participant LE as Live Engine + participant G as SCIP Graph (store) + participant LSP as Language Server + participant CI as Commit Hook (Phase B / SCIP) + + Note over Dev,LSP: LIVE PATH — between commits + Dev->>ED: save file + ED->>TS: re-parse dirty file + TS->>LE: nodes + detected references (structural) + LE->>LE: classify edit (body vs interface §6) + alt interface edit + LE->>G: reverse-dependency closure (§6.3) + G-->>LE: files whose boundary edges need re-resolution + end + + loop each new / unresolved reference R @ P + LE->>TS: local scope check (free? import-bound?) + alt binds to local/param + LE->>LE: resolve locally — no graph edge + else free reference + LE->>G: candidates = symbols_named(R) (import-narrowed) + alt exactly 1 candidate (local-clean) + LE->>G: emit edge — provenance="live" (lexical) + else ambiguous AND LSP available + LE->>LSP: definition(uri, P) [buffer version v] + LSP-->>LE: location L (must match v, else wait/abstain §11) + LE->>G: N = location_to_node(L) (§7.5) + alt N in graph + LE->>G: emit edge — provenance="live" (LSP-resolved) + else L outside graph + LE->>G: mark R pending (abstain) + end + else ambiguous AND no LSP + LE->>G: mark R pending (abstain) + end + end + end + + Note over Dev,CI: RATIFICATION PATH — on commit (Invariant #4) + Dev->>CI: git commit (hook fires) + CI->>CI: incremental SCIP over changed ∪ closure + CI->>G: insert SCIP edges (SCIP wins by ADR-002 precedence) + CI->>G: delete leftover provenance="live"; clear resolved pendings; GC ghosts + Note over G: steps run in one WAL transaction (§8.3 — no torn intermediate) + Note over G: committed graph == full reindex (§8.3) — deterministic, live-free +``` + +--- + +## 8. Correctness Contract + +### 8.1 Precision-first, fail-closed +The live lane's target is **precision ≈ 1.0**, achieved by construction: it emits only (a) unambiguous lexical matches that are local-clean, or (b) LSP-resolved locations that map to a graph node. Everything else abstains. Rationale: for a product whose thesis is "zero structural hallucinations," a false edge is not a quality regression but a **breach of the value proposition**, and the failure modes are asymmetric — a missing edge fails safe and recoverable (fall back to grep/read), a wrong edge fails dangerous and silent (agent traverses to the wrong target). Precision ~1.0 or do not emit. + +### 8.2 Fencing rule (Invariant #1 + ADR-009 Rule 4) +Live edges are **intra-corpus only**. They: +- carry `provenance = "live"`, +- are **never** inserted into the `BridgeRegistry` or any cross-corpus resolution path, +- never create or mutate node *identity* — they only attach edges between **already-existing** SCIP-identified nodes (or Tree-sitter nodes within the dirty file). VName minting remains SCIP's exclusive responsibility. + +This preserves RFC-005's `src.corpus == dst.corpus` invariant and Principal Architect Invariant #1 (VName uniqueness): the live lane cannot synthesize a colliding identity because it never synthesizes identity at all. + +### 8.3 Convergence (Principal Architect Invariant #4) +At commit, ratification rides the **whole-project** Phase B run. (The RFC originally specified incremental SCIP over `changed_set ∪ reverse_closure`; that machinery does not exist — `invoke_phase_b_all` runs over full-project inputs and file-level delta is unbuilt, `DEBT(travsr-25)`. Region-scoped ratification is a future optimization gated on that debt.) The pass then: +1. inserts the SCIP-derived edges (which dominate any co-located `live` edge by ADR-002 precedence — the existing `ON CONFLICT(src,dst,kind) DO UPDATE` upsert in `travsr-store/src/lib.rs` already lets `lsif`/`scip` win), +2. deletes the leftover `provenance = "live"` edges in that region that SCIP did not overwrite, +3. clears `pending` reference markers SCIP resolved; GCs ghost nodes/edges from deletions. + +**Ordering rationale (delete-old / insert-new hazard).** A naive "delete all `live` edges, then insert SCIP" sequence opens a transient-gap window: a concurrent query landing between the two steps would see *neither* the live edge (already deleted) nor the SCIP edge (not yet inserted), momentarily reporting a missing edge on ratified code. Insert-then-delete closes that window: the only observable intermediate is a **superset** of the ratified graph, never a gap. + +**Atomicity, corrected (confirmed in implementation).** These steps do **not** run in one WAL transaction, and cannot without restructuring the Phase B write path. That path is already a sequence of independently-committing statements — `put_edge_lsif` per LSIF edge, `write_phase_b_batch`, `write_scip_attributed_batch`, a second `write_phase_b_batch` for natively-resolved edges, `record_edge_sites`, `reconcile_edge_languages` — all under one *process-local* store mutex, which is not a transaction. The realizable guarantee is **ordering + store-lock exclusion + marker-gated visibility**: the sweep runs while that mutex is still held, after the last ratification write and before the `phase_b_commit` marker advances. In-process readers share the mutex and never observe an intermediate; a separate-process reader can, and sees only the harmless superset above. This answers review-ask #1: the hazard is real, and is dissolved by ordering, not by transaction atomicity. + +**Most live edges never reach the sweep.** A live edge that Phase B re-derives is ratified *in place*: the ratification write upserts the same `(src, dst, kind)` row and relabels its provenance (`lsif`/`scip`, or `tree-sitter` when the edge came from native leaf-name resolution, which is itself only ever written on a Phase B path). By the time the sweep runs, the rows still marked `live` are exactly those Phase B did **not** re-derive, so deleting them cannot lose a real edge. This is why the ratification writes must not be prevented from overwriting a `live` row. + +**The overlay must be purely additive** (confirmed in implementation; this is what makes the sweep safe). Emitting a live edge creates a row that was absent, or refreshes one the live lane already owns. It must **never** relabel a row another lane wrote. Otherwise the sweep — which deletes rows — could reach an edge the overlay did not create, and retiring the overlay would destroy pre-existing truth instead of returning the graph to it. + +The hazard is concrete rather than theoretical: an interface edit re-resolves the files that *reference* the edited one (§6.3), and those files were not re-parsed, so their `tree-sitter` edges are still in place. An upsert that relabelled them would hand them to the sweep, and any one Phase B did not happen to re-derive would vanish. The convergence property below is what surfaced this. + +Stated as an invariant: **every row the sweep can delete is a row the live lane created**, so ratification is a return to the pre-overlay graph rather than a mutation of it. + +**The sweep is language-scoped, not blanket.** `made_progress` advances the `phase_b_commit` marker whenever *any* language produced results, even when another language's sidecar crashed (#712). A blanket `DELETE FROM edges WHERE provenance='live'` would therefore discard live edges for a language whose SCIP truth was never re-derived in that run. The sweep is restricted to the languages that completed, keyed on the src node's language. Live edges for a crashed language survive, still labeled `live`, which is honest. Invariant #4 is unaffected: a clean run has nothing crashed, so the scoped sweep is total, and that is the case the convergence property below asserts. + +**Property (must hold, property-tested):** +``` +graph(G) --overlay--> G' --ratify--> graph(G) +``` +Ratifying the overlay returns the graph to exactly what it was before the overlay existed, provenance included. This is the form the property actually takes in code, and it is stronger than a count comparison: a `live` row sitting where a ratified one belongs has the same count and the wrong meaning. + +It composes with in-place ratification to give the original statement. An overlay edge Phase B re-derives is relabelled and survives; one it does not re-derive is swept; either way the committed graph carries zero live edges and is what the deterministic pipeline produces. Invariant #4 is discharged: the live overlay never survives the run that ratifies it. + +Note the property is asserted against the *pre-overlay* graph rather than against a from-scratch reindex. Those differ for an unrelated, pre-existing reason — Phase B is whole-project and commit-gated (`DEBT(travsr-25)`), so an incremental Phase A pass legitimately lacks semantic edges a full index would have. Asserting equality with a full reindex would be asserting something RFC-027 does not claim and does not fix. + +### 8.4 Determinism fence +Live resolution depends on the installed server version and is therefore non-deterministic across environments. This is **fenced**: non-determinism exists only in the ephemeral overlay, between commits. The durable (committed) graph is SCIP-pinned and deterministic. The overlay was never part of the deterministic ground-truth contract, so the fence is sound. Live edges MUST be visibly distinguishable at query time (§10) so no consumer mistakes the overlay for ratified truth. + +**Reconciliation with the #688 editor plane.** The daemon already has an editor plane (`ControlMessage::ReportLspDiagnostics`) whose written contract is that editor-derived data is volatile and *"never enters the graph … can never be an edge or a node."* Persisting `live` edges appears to cross that line. It does not, and the distinction is what makes this sound: + +- **#688 carries the editor's claim about the code.** A diagnostic is the editor's own judgement, it has no counterpart in the repository, and nothing later replaces it with a derived truth. Admitting it to the graph would make the graph unreproducible with no path back. +- **RFC-027 carries a position, not a claim.** The editor answers only "what does the cursor at this position point at" — the one question a language server is authoritative for. It never names a node, never mints a VName, and never asserts a relationship. The daemon maps both endpoints to nodes itself against SCIP-owned identity (§7.5, §8.2), so the graph owns everything downstream of that answer. +- **The result is bounded by ratification, not by trust.** Every live edge is either relabelled by a Phase B write or swept (§8.3), so the non-determinism has a guaranteed end. A diagnostic has no such terminating event, which is exactly why it stays in its own plane. + +The two rules are therefore the same rule: *nothing whose non-determinism has no terminating event may enter the graph.* #688 has none, so it stays out; a live edge's terminating event is the next Phase B run. + +--- + +## 9. Data Model & Schema + +### 9.1 Provenance (ADR-002 Rule 1) +Add one value to the edge provenance enum: + +| Source | `edges.provenance` | +|---|---| +| Tree-sitter (Phase A) | `tree-sitter` | +| LSIF | `lsif` | +| SCIP | `scip` | +| Cross-language bridge | `bridge:` | +| **Live resolution (new)** | `live` | + +Note: this table reflects the **de-facto** enum in the shipped store (`tree-sitter` / `lsif` / `scip`, plus `bridge:` from ADR-009), which already diverged from ADR-002's originally written value list (`tree-sitter` / `lsif` / `merged`, where `merged` was reserved-for-future and `scip` was added later). `live` extends that de-facto set; it does not reinstate `merged`. + +`live` edges are the only provenance the commit ratifier is permitted to bulk-delete-and-replace. + +**No schema migration is required** (confirmed in implementation). `edges.provenance` is an unconstrained `TEXT NOT NULL DEFAULT 'tree-sitter'` column with no `CHECK` (`travsr-store/src/migrations/v2_edge_provenance.sql`), so `'live'` is a code change at the precedence-bearing insert sites, not a migration. Only the `ref_resolution_state` table (§9.2) needs one. + +**Precedence in code.** `put_edge_live` upserts with `WHERE edges.provenance IN ('tree-sitter','live')`, so a live edge upgrades a heuristic row, is idempotent over itself, and can never overwrite `lsif`/`scip`/`bridge:*`. Re-emission has to be idempotent because `reindex_replace` deletes every outbound edge of a file on each save, so the engine re-emits for the whole file rather than only for references it believes are new. + +### 9.2 Reference resolution state +References detected by Tree-sitter but not yet edge-resolved carry a `ref_resolution_state` (named to avoid collision with the daemon's existing `record_dart_resolution_state` bookkeeping in `travsr-daemon/src/lib.rs`, which tracks Dart Phase B availability and is an unrelated concept): +- `resolved` — an edge exists (any provenance). +- `pending` — detected, not resolved, awaiting live resolution or commit ratification. Surfaced honestly; never rendered as an edge. + +**Schema changes require Principal Architect sign-off (§13).** + +--- + +## 10. MCP Surface Impact (Solution Architect) + +The live overlay must be *legible* to consumers, never silently blended into ratified truth: + +- `get_callers`, `find_references`, `get_blast_radius`, `get_graph_json` gain an optional per-edge `provenance` field, and `live` edges are labeled. Two corrections from implementation: per-edge provenance was **not** "already present" at the MCP surface (`DEBT(travsr-75)` — core `Edge` carried no provenance field, so every BFS-traversed edge reported `tree-sitter` regardless of its stored row), and there is **no** existing `provenance` filter (only `kind_filter`). Threading provenance through `Edge` and the store readers is a prerequisite, landed separately; the filter argument is a small additive schema edit. +- Responses that include a live overlay carry a freshness note, so an agent knows the answer includes un-ratified edges *and* where the gaps are. Both halves are reported, because they mean opposite things: resolved-but-un-ratified edges are extra freshness, while `pending` references are known gaps the lane deliberately refused to guess at. A count of live edges alone would read as pure upside and hide the abstentions that are the other half of a fail-closed lane. + + Delivered as two seams rather than one (confirmed in implementation): prose tools append the note to the body, and JSON tools carry it as a `signals` array item, because appending a `[note: …]` string to a JSON body would break `JSON.parse`. Both are asserted to agree by test. Per-edge, `get_callers` marks each un-ratified row inline and `get_graph_json` emits a `provenance` field on every edge. +- **No new MCP tool.** This is a data-quality/provenance annotation on existing tools, not a new contract. MCP-as-only-interface (Invariant #6) is unaffected. +- Default behavior is additive: consumers that ignore provenance see a *fresher* graph; consumers that require ground truth pass `provenance: "ratified"`. The filter is a word rather than a `!=` expression — the meaningful question a consumer has is "confirmed, or everything", and a filter grammar would invite queries the store cannot answer cheaply. An unknown value matches nothing rather than everything, so a typo returns an obviously empty graph instead of silently dropping the constraint a consumer added precisely because it needed ground truth. + +--- + +## 11. Consistency & LSP Settle Protocol + +LSP is eventually-consistent after `didChange`. The engine MUST: +1. push the current buffer version to the server (`didChange`) before querying, +2. tag each `definition` request with the buffer `version`, +3. accept a response only if it corresponds to the version queried; otherwise wait for the next settle or retry (bounded), +4. treat a settle timeout as **abstention** (§7.3c), never as a resolved edge. + +In the IDE-piggyback path (§7.6), the editor owns `didChange`; Travsr reads through `executeDefinitionProvider`, which already reflects the current buffer, simplifying settle handling. + +--- + +## 12. Measurement & Quality Gates (QA) + +The "is the live lane worse than nothing?" question is answered empirically, not by assertion. Ground truth is on tap every commit: + +- **Continuous precision meter:** at each commit, diff what the live lane claimed against what Phase B derived. Disagreements are logged individually at `warn` — one wrong edge is the failure this design exists to avoid, and it should be visible the moment it happens rather than averaged into a ratio. **SCIP wins all ties**; in implementation that falls out of ordering rather than needing a rule, because the meter runs after Phase B has already written its answer over any co-located live row. + + **Precision is measured, not agreement** (corrected in implementation). The two are not the same here, because the two lanes have deliberately opposite policies: the live lane is precision-first and abstains on ambiguity, while the Phase B resolver is explicitly recall-biased ("overconnection is safe … all matches are emitted; PPR damping absorbs the noise"). The live lane therefore emits a legitimate *subset*, and scoring agreement would penalise it for exactly the abstentions §8.1 requires. + + **Verification is at call-site line granularity**, joining each recorded claim to the `edge_sites` row Phase B wrote for the same `(src, line)`. Anything coarser is not safe to gate on: a function that calls several things would let a mis-targeted claim match some *other* call's correct answer and score as agreement. An optimistic meter is worse than none, because it clears a bar the lane has not met. + + **Three buckets, not two.** A claim Phase B left no evidence for is `unverifiable`, not wrong: Phase B has recall gaps of its own, and the code can change between the edit and the commit. Precision is reported over the verified subset with **coverage beside it**, because precision alone would let "1.0 over two of four hundred claims" read as a passing grade. A sample with nothing verifiable reports *no* precision rather than a perfect one, so an empty measurement cannot clear the gate. + + **The meter must run at ratification**, after the Phase B writes and before the sweep. Not a stylistic choice: `reindex_replace` deletes the edited file's `edge_sites` on save, so between the save and the next Phase B run there is no call-site evidence at all, and measuring earlier yields no number rather than a pessimistic one. + + The reading is cumulative and surfaced by `travsr status`, since a gate needs a value a human can read rather than a log line that scrolled away. +- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. **(Phase 4: implemented per-language.** The meter splits by the source node's language — `live_precision_sample_by_language`, cumulative under `meta` key `live_precision.` — and the save path consults `live_lane_enabled_for(language)`.) **Strict opt-in gate (§8.7.6 decision):** two independent conditions must both hold for a language to run. (1) No *adverse* reading: a cumulative sample of ≥ 20 verified claims at precision < 0.99 disables even a shipped language, and it re-enables itself when the reading recovers. (2) *Vouched to ship*: the language is on `LIVE_LANE_SHIPPED`, the code-level opt-in list, each entry earning its place at a measured ≥ 0.99 (the §11.3 procedure). The earlier policy left an *unmeasured* language enabled so it could earn a reading, which — when eleven non-native languages joined at once — shipped nine enabled with no reading, contradicting "ships enabled only at measured precision ≥ 0.99." Strict gating makes that literal: an unmeasured, un-vouched language is disabled until opted in. The deadlock this would create (a disabled language can never run to be measured) is broken by `TRAVSR_LIVE_LANE_MEASURE`, an env override that lifts the gate for exactly the language a measurement fixture is driving, never touching the adverse-meter safety. +- **Recall telemetry:** the same diff reports the recall the live lane buys over the pure fail-closed lexical lane, quantifying whether the LSP dependency earns its operational cost. +- Fixture corpus reuses the RFC-003 §6 fixtures plus the existing LSIF path as a differential oracle (available for TS/Rust/Python). + +--- + +## 13. Security & Threat Model (defer to Principal Security Engineer) + +| Concern | Assessment | +|---|---| +| Persistent language server = long-lived process running build logic (ADR-006/017) | **IDE-piggyback path spawns nothing** — it reuses the server the developer already runs and trusts; the serverless §7.3a lane needs none. A daemon-spawned headless server is **REJECTED for v1** (§16.1): it would run the indexed repo's build logic continuously, outside ADR-006 Rule 1's per-repo opt-in and ADR-017's bounded-batch sandbox. Any revisit is `NEEDS_THREAT_MODEL`. | +| Live edges poisoning cross-corpus resolution | Blocked by the fencing rule (§8.2): `live` edges never enter the `BridgeRegistry`. | +| Non-deterministic durable graph | Impossible: live edges never survive a commit (§8.3). | +| Prompt-injection via edge content | Unchanged from existing pipeline; `` sanitization applies identically. | + +**Sign-offs required before Accepted:** +- Principal Architect — schema change (§9), new provenance value, convergence proof (§8.3). +- Principal Security Engineer — **signed off (Phase 4).** IDE-piggyback trust assumption accepted: the daemon spawns nothing and only consumes positions from a server the developer already runs. Daemon-spawned headless language servers **REJECTED for v1** (§16.1); any future path is gated on a fresh threat model that extends ADR-006 Rule 1 and defines a persistent-server sandbox profile. +- Solution Architect — MCP provenance surface (§10). + +--- + +## 14. Phased Delivery + +| Phase | Deliverable | Gate | +|---|---|---| +| **0 — Spike** | TS-only, IDE-piggyback, unambiguous-lexical lane (§7.3a) + LSP disambiguation (§7.3b) on a clean repo. Prove the resolution flow end-to-end. | Demo; measured precision on fixtures. | +| **1 — Edit classification + invalidation** | Body vs interface classifier (§6), reverse-closure invalidation, `pending` state (§9.2). | Property test: no ghost edges after rename/delete fixtures. | +| **2 — Commit ratification + convergence** | Incremental SCIP over `changed ∪ closure`, live-edge replacement, Invariant #4 property test (§8.3). | `graph(full) == graph(incremental+ratified)` green on fixtures. | +| **3 — MCP surface + precision gate** | Provenance labeling, `live_overlay` envelope note (§10), continuous precision meter (§12). | Precision ≥ 0.99 on fixture corpus. | +| **4 — Second language + headless decision** | Rust (differential vs rust-analyzer LSIF). Decide headless spawn path scope with Security. | Per-language precision gate. | + +Language choice for the spike is **TypeScript**: mature `tsserver` resolution + an existing LSIF path to use as the differential oracle. + +--- + +## 15. Alternatives Considered + +1. **LSP as symbol/identity source.** Rejected: LSP speaks `(file, position)` with no package-qualified identity; reconstructing stable identity from it is lossy and, per ADR-009 Rule 4, forbidden from bridges. (This RFC uses LSP for *location*, not identity.) +2. **Common protocol layer over LSP for all languages.** Rejected as a foundation: LSP's uniformity is skin-deep; capability coverage, identity semantics, per-server bootstrap, and trust surface are all per-language, so it degenerates into "common format + N per-language producers" — which is the SCIP model on a worse substrate. +3. **Pure lexical name-matching heuristic (guess best candidate).** Rejected outright: fabricates edges on ambiguous cases (method-on-receiver, overloads), violating the zero-hallucination thesis. Only the *unambiguous* subset survives, as §7.3a. +4. **Incremental SCIP only, no live lane.** Viable and is effectively the fail-safe floor (§7.3c degradation). Rejected as *sufficient* because it does not close the mid-edit window — SCIP still runs at commit, not on save. Retained as the graceful-degradation baseline. +5. **Do nothing (status quo commit-gated).** The zero-cost option and the guaranteed floor. This RFC is strictly additive over it: no server → exactly status quo. + +--- + +## 16. Open Questions + +1. **Headless daemon path — RESOLVED (Phase 4, Principal Security Engineer).** *Should Travsr ever spawn its own pinned language servers for non-IDE consumers?* **No — commit-gated Phase B plus the IDE-piggyback live lane is the permanent answer for v1; the daemon spawns no language server of its own.** Two facts decide it. **(a) There is no functional need:** §7.3a (unambiguous-lexical) runs with no server at all, and §7.3b consumes a position from the server the developer already runs and already trusts inside their editor — the daemon spawns nothing on either lane. **(b) Spawning would strictly widen the trust surface ADR-006/017 exist to bound.** A persistent language server (rust-analyzer, tsserver) executes the indexed repo's `build.rs`, proc-macros, and build scripts — the same arbitrary code as `rust-analyzer --lsif`, but **continuously and long-lived** rather than in a bounded batch killed at 60s. A daemon that spawned one unprompted would run that code on any repo it indexes, including a repo the developer is only *reviewing, not running*, without the per-repo opt-in ADR-006 Rule 1 requires and outside the bounded-batch profile ADR-017 `SandboxPolicy::Standard` defines — a long-lived server legitimately spawns the compiler and never terminates, tripping the very kill conditions that sandbox imposes. **Verdict: REJECTED for v1.** If ever revisited it is `NEEDS_THREAT_MODEL`: a new RFC must (i) extend ADR-006 Rule 1's per-repo opt-in to cover persistent servers, (ii) define a persistent-server sandbox profile distinct from the bounded-batch one, and (iii) be gated on a demonstrated consumer need the two shipped lanes cannot serve. Absent all three, commit-gated stays the answer. +2. **Interface-edit detection granularity.** Can the classifier reliably distinguish rename from delete+add without the commit SCIP pass? Rename mis-classification only affects the *live* overlay (healed at commit), so a conservative "treat ambiguous as interface edit" is safe but costs recall. Measure in Phase 1. +3. **Multi-dirty-file settle ordering.** When several files are dirty and cross-reference each other, is single-pass resolution sufficient or is a fixpoint needed? Bound the iterations; measure convergence in Phase 1. +4. **Confidence exposure.** Do we surface `live` as a boolean provenance only, or a graded confidence? Solution Architect leans boolean (provenance) for MCP simplicity; revisit if consumers ask. Note the plumbing for graded already exists independently of provenance: the `edges` table already carries a `confidence` column (written today by the LSIF/SCIP upserts in `travsr-store/src/lib.rs`), so a future graded signal can ride that field **without** touching the provenance enum. Recommended resolution: keep `provenance` boolean, reserve `confidence` as the graded channel if a consumer ever needs it. + +--- + +## 17. Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Live precision < 0.99 for a language | Medium | High | Per-language gate (§12); disable lane, keep floor. No regression. | +| Dirty-file span mapping bug → wrong node | Medium | High | Confine to dirty set (§7.5); property tests; commit ratification heals. | +| LSP settle race → stale resolution | Medium | Medium | Version-tagged requests; timeout → abstain (§11). | +| Reverse-closure too large on hot files | Low | Medium | Interface-edit-only trigger (§6); bound closure depth; fall to pending. | +| Ghost edges from missed deletions | Medium | High | Reverse-edge cleanup on interface edits; commit GC (§8.3). | +| Consumers treat overlay as ground truth | Medium | Medium | Mandatory provenance labeling + envelope note (§10); filterable. | + +--- + +## 18. References + +- ADR-002 — Edge Provenance Policy +- ADR-006 — rust-analyzer Subprocess Trust Model +- ADR-009 — SCIP vs LSIF Wire Format (esp. Rule 4, fencing rationale) +- ADR-017 — Unified Plugin Sandbox Trust +- ADR-018 — Drop Kùzu Backend (SQLite+WAL is the only backend) +- RFC-005 — Cross-Language Edge Resolution (`src.corpus == dst.corpus` invariant) +- RFC-008 — Multi-Language Extension Architecture (Phase A/B) +- RFC-009 — Cross-Language Bridge Plugin System +- RFC-014 — Phase B Symbol Unification +- Principal Architect Invariants #1 (VName uniqueness), #3 (LLM prohibition), #4 (incremental correctness) +- LSP specification — `textDocument/definition` +- VS Code API — `vscode.executeDefinitionProvider` diff --git a/packages/travsr-vscode/src/daemonIpc.ts b/packages/travsr-vscode/src/daemonIpc.ts index bbc0b37f..7a442ce2 100644 --- a/packages/travsr-vscode/src/daemonIpc.ts +++ b/packages/travsr-vscode/src/daemonIpc.ts @@ -186,6 +186,185 @@ export async function reportLspDiagnostics( }); } +/** One reference an editor's language provider resolved (RFC-027). */ +export interface LiveResolutionItem { + /** 1-based line of the reference in the dirty file. */ + ref_line: number; + /** 0-based UTF-16 column, as VS Code counts them. */ + ref_col: number; + /** The referenced name, for the daemon's pending bookkeeping. */ + name: string; + /** Repo-relative path of the definition, forward slashes. */ + target_path: string; + /** 1-based line of the definition. */ + target_line: number; + /** Document version this answer was computed against. */ + buffer_version: number; + /** + * Graph edge kind this reference resolves to, as `EdgeKind::as_str` + * (`ref/call`, `ref/field`, `ref/imports`, `is-implementation`, `overrides`). + * Echoed from the daemon's target so it emits an edge of exactly that kind + * (RFC-027 live edge-kind scope). + */ + edge_kind: string; +} + +/** + * One reference the daemon asked this editor to resolve (RFC-027 daemon-driven + * positions). The daemon detected it and named its edge kind; the editor finds + * the column and runs the provider. + */ +export interface LiveResolutionTargetItem { + /** 1-based line of the reference in the dirty file. */ + ref_line: number; + /** The referenced name — the editor finds its column on `ref_line`. */ + name: string; + /** Edge kind to carry back on the resolved `LiveResolutionItem`. */ + edge_kind: string; + /** Which provider answers this reference: `definition` or `implementation`. */ + provider: string; +} + +/** + * The targets in one dependent file the editor should also resolve + * (RFC-027 section 8.7.5, the interface-edit closure). The editor opens the + * file, resolves its `targets`, and reports back keyed to `file`. + */ +export interface DependentTargetsItem { + /** Repo-relative path of the dependent, forward slashes. */ + file: string; + /** The references in `file` to resolve. */ + targets: LiveResolutionTargetItem[]; +} + +/** + * The full answer to a target request: the saved file's own references plus the + * dependents the interface-edit closure wants re-resolved (RFC-027 §8.7.5). + */ +export interface LiveResolutionTargetsResult { + own: LiveResolutionTargetItem[]; + dependents: DependentTargetsItem[]; +} + +/** + * Ask the daemon which references in a dirty file this editor should resolve + * (RFC-027 daemon-driven positions). + * + * Unlike the fire-and-forget reports, this reads a response: the owning daemon + * answers with `{ own, dependents }`, other daemons reject on the repo-identity + * guard. Returns empty on every miss — no daemon, a daemon too old to know the + * op or one that still answers with a bare array, a malformed answer — so the + * caller simply resolves nothing that round. + */ +export async function requestLiveResolutionTargets( + repoRoot: string, + file: string, + bufferVersion: number +): Promise { + const result = await request(repoRoot, { + op: "request-live-resolution-targets", + repo_root: repoRoot, + session: SESSION_ID, + file, + buffer_version: bufferVersion, + }); + const empty: LiveResolutionTargetsResult = { own: [], dependents: [] }; + if (!result || typeof result !== "object" || Array.isArray(result)) return empty; + const r = result as Partial; + return { + own: Array.isArray(r.own) ? r.own : [], + dependents: Array.isArray(r.dependents) ? r.dependents : [], + }; +} + +/** + * Send one control message and read the daemon's response, best-first. + * + * Broadcasts like {@link send}, but the owning daemon is the one that answers + * with a truthy `result` (others reject on the repo guard), so the first such + * `result` is taken. Returns `null` when no daemon answered usefully. + */ +async function request(repoRoot: string, payload: object): Promise { + const line = JSON.stringify(payload) + "\n"; + const candidates = candidateSocketPaths(repoRoot); + const responses = await Promise.all( + candidates.map((c) => requestLine(c, line)) + ); + for (const r of responses) { + if ( + r && + typeof r === "object" && + (r as { ok?: boolean }).ok === true && + (r as { result?: unknown }).result != null + ) { + return (r as { result?: unknown }).result; + } + } + return null; +} + +/** Write one control line and read back one newline-terminated JSON response. */ +function requestLine(socketPath: string, line: string): Promise { + return new Promise((resolve) => { + let settled = false; + let buf = ""; + const finish = (v: unknown): void => { + if (settled) return; + settled = true; + sock.destroy(); + resolve(v); + }; + const tryParse = (): void => { + const nl = buf.indexOf("\n"); + if (nl < 0) return; + try { + finish(JSON.parse(buf.slice(0, nl))); + } catch { + finish(null); + } + }; + + const sock = net.connect(socketPath); + sock.setTimeout(CONNECT_TIMEOUT_MS); + sock.on("connect", () => sock.write(line)); + sock.on("data", (d: Buffer) => { + buf += d.toString("utf8"); + tryParse(); + }); + // A short line with no trailing newline still parses on end. + sock.on("end", () => { + tryParse(); + finish(null); + }); + sock.on("error", () => finish(null)); + sock.on("timeout", () => finish(null)); + }); +} + +/** + * Publish where references in a dirty file actually resolve (RFC-027). + * + * Unlike `reportLspDiagnostics` this carries no lease: a live edge's lifetime + * is bounded by commit-time ratification in the daemon, not by a TTL here. + * + * Same fire-and-forget contract as everything else in this file. Losing a + * report costs freshness, never truth: the daemon abstains rather than + * guessing, and the commit-gated path resolves the same references anyway. + */ +export async function reportLiveResolution( + repoRoot: string, + file: string, + resolutions: LiveResolutionItem[] +): Promise { + return send(repoRoot, { + op: "report-live-resolution", + repo_root: repoRoot, + session: SESSION_ID, + file, + resolutions, + }); +} + /** * Drop this window's view now, rather than leaving it to expire. * diff --git a/packages/travsr-vscode/src/extension.ts b/packages/travsr-vscode/src/extension.ts index 295de0f7..d7c490da 100644 --- a/packages/travsr-vscode/src/extension.ts +++ b/packages/travsr-vscode/src/extension.ts @@ -15,6 +15,7 @@ import { BLAST_RADIUS_SELECTOR, } from "./codelens"; import { CallersHoverProvider, HOVER_SELECTOR } from "./hover"; +import { publishLiveResolutions } from "./liveResolution"; import { TravsrTreeDataProvider } from "./tree"; import { TravsrRepoFileTreeProvider } from "./repoFileTree"; import { showWelcome, showWelcomeIfFirstRun } from "./welcome"; @@ -534,6 +535,25 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // RFC-027: live semantic resolution. On save, ask the language provider the + // developer is already running where this file's call sites resolve, and + // report the positions to the daemon so it can close the between-commits + // semantic gap. No server is spawned (section 7.6). + // + // Fire-and-forget and fully optional: the daemon resolves unambiguous callees + // on its own without any of this, and abstains rather than guessing on + // anything it cannot map. Losing these reports costs freshness, never truth, + // so nothing here is awaited or surfaced. + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + // No folder open means no repo to attribute the file to. + if (!workspaceRoot) return; + void publishLiveResolutions(workspaceRoot, doc).catch(() => { + // Never surfaced: see the note above. + }); + }) + ); + // Re-index command — also reachable from the status Quick Pick. Lives here // (not commands.ts) because it needs the output channel + workspace root. context.subscriptions.push( diff --git a/packages/travsr-vscode/src/liveResolution.ts b/packages/travsr-vscode/src/liveResolution.ts new file mode 100644 index 00000000..ee0522dc --- /dev/null +++ b/packages/travsr-vscode/src/liveResolution.ts @@ -0,0 +1,330 @@ +/** + * RFC-027 live semantic resolution — the editor half (daemon-driven positions). + * + * Phase B (SCIP) is commit-gated, so between commits the graph knows *that* + * `user.save()` is a call but not *which* `save`. The daemon closes most of + * that gap on its own (an unambiguous callee needs no help), but a reference on + * a typed receiver — a method or field on `user`, an `implements` clause — is + * exactly what lexical matching cannot settle. That is the one question a + * language server is authoritative for, so we ask the one the developer is + * already running. + * + * ## Daemon-driven, not editor-scanned + * + * The editor no longer guesses which references exist. It **asks the daemon** + * (`requestLiveResolutionTargets`), which runs the real parser over the dirty + * file, keeps the references its own lexical lane cannot settle, and answers + * with a position, a name, an edge kind, and which provider to run. Reference + * detection therefore lives with the parser and the graph, not in an + * English-shaped regex here — which is what lets the lane reach fields, + * implements clauses, and every language the native extractor covers, rather + * than only `identifier(` in the handful the old scan understood. + * + * The editor's residual job is small and mechanical: find the column of the + * named reference on its line, run the provider, and report the answer back. + * + * ## What this does and does not do + * + * It calls `vscode.executeDefinitionProvider` / `executeImplementationProvider`, + * which route to whatever extension owns the language. **No server is spawned + * and none is bundled** — this reuses a process the developer already trusts and + * already pays for (RFC-027 section 7.6), which is why it costs nothing and + * needs no new trust decision. + * + * It reports **positions**, never identities. It never names a graph node, + * never mints a VName, and never asserts a relationship. The daemon maps both + * endpoints to nodes itself against SCIP-owned identity. This is the line that + * separates it from the #688 editor plane, where the editor's own *claim* + * (a diagnostic) is what is being reported and so must stay out of the graph. + * + * Everything is bounded and best-effort: a capped number of queries per file, + * a stale-buffer check before reporting, and every failure path is silent. A + * freshness improvement is never worth a word of the user's attention. + */ + +import * as vscode from "vscode"; + +import { + DependentTargetsItem, + LiveResolutionItem, + LiveResolutionTargetItem, + reportLiveResolution, + requestLiveResolutionTargets, +} from "./daemonIpc"; + +/** + * Cap on provider queries for one save, across the saved file **and** every + * dependent the interface-edit closure pulls in (RFC-027 section 8.7.5). + * + * Each is an IPC round trip into another extension host. The daemon keeps the + * set surgical (only references its lexical lane could not settle), so a + * hand-written file is far below this; the cap only bounds a pathological + * generated file or a rename in a hot utility whose closure is large. It bounds + * the *total*, not each file, so one save can never fan out into an unbounded + * storm of queries (§10.1). Truncation degrades recall, which the commit-gated + * path repairs. + */ +const MAX_QUERIES_PER_SAVE = 200; + +/** + * Languages the daemon's live lane detects references for. A cheap pre-filter + * kept in step with the daemon's own gate (`live_language` in travsr-daemon): + * the daemon is authoritative and returns no targets for anything else, so this + * only avoids a wasted round trip on a save the daemon could not act on. + * Widening it beyond the daemon's set costs one empty request, never + * correctness. + * + * The first block runs the daemon's native extractor. The rest run the generic + * tree-sitter detector and reach this lane through the editor **alone** + * (RFC-027 section 8.3), so omitting one here disables it outright: this filter + * gates the request itself, not just the round trip. + * + * These are VS Code `languageId` values, which are not always the name of the + * language: C# is `csharp`, Objective-C is `objective-c` / `objective-cpp`. + * Whether a given language resolves anything depends on the developer having + * its extension installed; with no provider the request simply returns nothing, + * which is the section 7.3c floor. + */ +const SUPPORTED_LANGUAGES = new Set([ + // Native extractor. + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + "rust", + "python", + // Generic detector, editor lane only. + "go", + "java", + "csharp", + "cpp", + "c", + "objective-c", + "objective-cpp", + "ruby", + "php", + "kotlin", + "swift", + "dart", + "scala", +]); + +/** VS Code provider command for each daemon-named provider. */ +const PROVIDER_COMMAND: Record = { + definition: "vscode.executeDefinitionProvider", + implementation: "vscode.executeImplementationProvider", +}; + +/** Repo-relative, forward-slash path, matching the graph's own path keys. */ +function repoRelative(repoRoot: string, uri: vscode.Uri): string | null { + const full = uri.fsPath; + if (!full.startsWith(repoRoot)) return null; + return full.slice(repoRoot.length).replace(/^[/\\]/, "").replace(/\\/g, "/"); +} + +/** Escape a name for use inside a `RegExp`. */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Column of `name` on 1-based `line` in `doc`, or `null` if it is not there. + * + * The native extractor records a reference's line but not its column, so the + * editor recovers the column here. A whole-word match avoids resolving a + * substring of a longer identifier; if the name has moved off the line (a race + * against an edit) we return `null` and skip, never a wrong position. + */ +function columnOf( + doc: vscode.TextDocument, + line: number, + name: string +): number | null { + if (line < 1 || line > doc.lineCount) return null; + const text = doc.lineAt(line - 1).text; + const m = new RegExp(`\\b${escapeRegExp(name)}\\b`).exec(text); + return m ? m.index : null; +} + +/** A shared, mutable query budget spent across the saved file and dependents. */ +interface Budget { + remaining: number; +} + +/** + * Resolve the references the daemon asked about and publish the answers. + * + * Resolves the saved document's own references, then the dependents whose live + * edges this save can restore (RFC-027 section 8.7.5): a rename in one file + * heals the files that reference it without each being saved in turn. All of it + * shares one query budget so a large closure cannot balloon a save. + * + * Returns the total number of resolutions reported, for tests and logging. + * Resolves to 0 on every uninteresting path (unsupported language, file outside + * the repo, no targets, no provider, nothing resolved) and never rejects. + */ +export async function publishLiveResolutions( + repoRoot: string, + doc: vscode.TextDocument +): Promise { + if (!SUPPORTED_LANGUAGES.has(doc.languageId)) return 0; + const file = repoRelative(repoRoot, doc.uri); + if (!file) return 0; + + const version = doc.version; + const { own, dependents } = await requestLiveResolutionTargets( + repoRoot, + file, + version + ); + if (own.length === 0 && dependents.length === 0) return 0; + // The buffer may have moved while the daemon was parsing; the target lines + // would no longer describe this file. + if (doc.version !== version) return 0; + + const budget: Budget = { remaining: MAX_QUERIES_PER_SAVE }; + let total = 0; + + // The saved file, resolved against its live buffer. + total += await resolveAndReport(repoRoot, doc, file, own, version, budget); + + // Each dependent, resolved against its own file (RFC-027 section 8.7.5). + for (const dep of dependents) { + if (budget.remaining <= 0) break; + total += await resolveDependent(repoRoot, dep, budget); + } + + return total; +} + +/** + * Resolve one dependent file's targets against its own document. + * + * A dependent dirty in another tab is **skipped**: its buffer differs from the + * text the daemon parsed from disk, so the target lines would not describe it, + * and resolving against stale text is exactly the wrong-edge risk the lane must + * avoid (RFC-027 section 10.1). Only the saved document's version is trustworthy + * here; for a clean or freshly opened dependent, disk equals what the daemon saw. + */ +async function resolveDependent( + repoRoot: string, + dep: DependentTargetsItem, + budget: Budget +): Promise { + const uri = vscode.Uri.joinPath(vscode.Uri.file(repoRoot), dep.file); + const open = vscode.workspace.textDocuments.find( + (d) => d.uri.fsPath === uri.fsPath + ); + let depDoc: vscode.TextDocument; + if (open) { + if (open.isDirty) return 0; // unsaved edits — its buffer is not what the daemon parsed. + depDoc = open; + } else { + try { + depDoc = await vscode.workspace.openTextDocument(uri); + } catch { + return 0; // gone from disk or unreadable — skip, never guess. + } + } + return resolveAndReport(repoRoot, depDoc, dep.file, dep.targets, depDoc.version, budget); +} + +/** + * Resolve `targets` against `doc` under the shared `budget` and report the + * answers for `file`. Returns the number reported. Drops the whole batch if the + * buffer moves mid-flight (RFC-027 section 11) — answers against old text no + * longer describe the file. + */ +async function resolveAndReport( + repoRoot: string, + doc: vscode.TextDocument, + file: string, + targets: LiveResolutionTargetItem[], + version: number, + budget: Budget +): Promise { + if (targets.length === 0) return 0; + const resolutions: LiveResolutionItem[] = []; + for (const target of targets) { + if (budget.remaining <= 0) break; + if (doc.version !== version) return 0; + const item = await resolveTarget(repoRoot, doc, target, version); + budget.remaining -= 1; + if (item) resolutions.push(item); + } + if (resolutions.length === 0) return 0; + if (doc.version !== version) return 0; + await reportLiveResolution(repoRoot, file, resolutions); + return resolutions.length; +} + +/** + * Resolve one daemon-named target through its provider, or `null` to skip. + * + * Every skip is safe: the daemon is fail-closed, so a reference we cannot pin, a + * provider that throws, or a target outside the workspace simply does not become + * an edge. A sloppy skip costs recall, never correctness. + */ +async function resolveTarget( + repoRoot: string, + doc: vscode.TextDocument, + target: LiveResolutionTargetItem, + version: number +): Promise { + const command = PROVIDER_COMMAND[target.provider]; + if (!command) return null; + + const col = columnOf(doc, target.ref_line, target.name); + if (col === null) return null; + const pos = new vscode.Position(target.ref_line - 1, col); + + let locations: unknown; + try { + locations = await vscode.commands.executeCommand(command, doc.uri, pos); + } catch { + return null; // no provider, or it threw. Neither is worth reporting. + } + + const found = firstLocation(locations); + if (!found) return null; + const targetPath = repoRelative(repoRoot, found.uri); + // Outside the workspace (node_modules, a .d.ts in the SDK) — dropped here so + // the live lane stays intra-corpus (RFC-027 section 8.2). The daemon would + // abstain anyway; not sending it saves the round trip. + if (!targetPath) return null; + + return { + ref_line: target.ref_line, + ref_col: col, + name: target.name, + target_path: targetPath, + target_line: found.range.start.line + 1, + buffer_version: version, + edge_kind: target.edge_kind, + }; +} + +/** + * Normalize what a definition/implementation provider returned. + * + * Providers may answer with `Location[]`, `LocationLink[]`, or a bare + * `Location`, and a single reference can resolve to several targets (an overload + * set, a merged declaration). We take the first: reporting several targets for + * one position would ask the daemon to pick, which is precisely the guess the + * fail-closed contract forbids. + */ +function firstLocation( + raw: unknown +): { uri: vscode.Uri; range: vscode.Range } | null { + const list = Array.isArray(raw) ? raw : raw ? [raw] : []; + if (list.length === 0) return null; + const first = list[0] as Partial & + Partial; + if (first.uri && first.range) { + return { uri: first.uri, range: first.range as vscode.Range }; + } + if (first.targetUri && first.targetRange) { + return { uri: first.targetUri, range: first.targetRange }; + } + return null; +} diff --git a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts index 6978dd1e..99047df9 100644 --- a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts +++ b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts @@ -15,6 +15,7 @@ import * as path from "path"; import { candidateSocketPaths, detachSession, + reportLiveResolution, reportLspDiagnostics, SESSION_ID, } from "../../daemonIpc"; @@ -194,14 +195,17 @@ suite("daemonIpc: #698 review P1", () => { path.join(__dirname, "..", "..", "daemonIpc.js"), "utf8" ); - const reports = src.split('op: "report-lsp-diagnostics"').length - 1; + // Counts every `op:` this client can send, not one hardcoded op, so a new + // message type is covered the day it is added rather than silently + // exempted. RFC-027's report-live-resolution is why this generalized. + const ops = src.split(/\bop:\s*"/).length - 1; const roots = src.split("repo_root:").length - 1; - assert.ok(reports >= 2, "both report and detach send this op"); + assert.ok(ops >= 3, `expected report, detach and live-resolution ops, saw ${ops}`); assert.strictEqual( roots, - reports, - "every send of that op must name its repo, detach included" + ops, + "every send must name its repo, detach and live resolution included" ); }); @@ -223,3 +227,66 @@ suite("daemonIpc: #698 review P1", () => { ); }); }); + +suite("daemonIpc: live resolution (RFC-027)", function () { + // Real sockets, so a hang shows up as a failure rather than as a pass. + this.timeout(10_000); + + const RESOLUTIONS = [ + { + ref_line: 42, + ref_col: 8, + name: "save", + target_path: "src/user.ts", + target_line: 17, + buffer_version: 9, + edge_kind: "ref/call", + }, + ]; + + // The daemon parses this line with a hand-written serde contract test + // (travsr-ipc/src/message.rs). Neither side shares a serializer, so the wire + // tag and field names are the contract and both tests must agree. + test("sends the wire shape the daemon parses", async () => { + if (process.platform === "win32") return; + const root = tempRepo(); + const sockPath = path.join(root, ".travsr", "daemon-live-res.sock"); + + const received: string[] = []; + const server = net.createServer((c) => { + c.on("data", (b) => received.push(b.toString("utf8"))); + }); + await new Promise((r) => server.listen(sockPath, r)); + + try { + const ok = await reportLiveResolution(root, "src/order.ts", RESOLUTIONS); + assert.strictEqual(ok, true, "a listening daemon must accept the report"); + await new Promise((r) => setTimeout(r, 100)); + + const line = received.join(""); + assert.ok(line.endsWith("\n"), "control protocol is line-delimited"); + const parsed = JSON.parse(line); + assert.strictEqual(parsed.op, "report-live-resolution"); + assert.strictEqual(parsed.repo_root, root, "the daemon drops a report for another repo"); + assert.strictEqual(parsed.session, SESSION_ID); + assert.strictEqual(parsed.file, "src/order.ts"); + assert.deepStrictEqual(parsed.resolutions, RESOLUTIONS); + // A live edge's lifetime is bounded by commit ratification, not a lease, + // so a ttl here would be a second expiry mechanism with no consumer. + assert.strictEqual(parsed.ttl_secs, undefined, "live reports carry no lease"); + } finally { + server.close(); + } + }); + + // The property that matters is "never rejects". The boolean is deliberately + // not asserted: discovery enumerates a per-user namespace, so an unrelated + // daemon on the same machine can accept the bytes (and then drop the report + // by repo), which would make an assertion on it pass or fail with the + // developer's environment rather than with the code. + test("a missing daemon is silent, never a throw", async () => { + const root = tempRepo(); + const ok = await reportLiveResolution(root, "src/order.ts", RESOLUTIONS); + assert.strictEqual(typeof ok, "boolean", "must resolve, never reject"); + }); +}); diff --git a/plugin-hashes.lock b/plugin-hashes.lock index 24971455..a087294a 100644 --- a/plugin-hashes.lock +++ b/plugin-hashes.lock @@ -1,6 +1,6 @@ # Auto-generated by update-plugin-hashes.sh — do not edit manually # Format: crate_name = sha256_of_src_tree -travsr-plugin-host = 1e19ef90398f1e8f73d0cd3431e0c0fae2b56c5bf6debc5fd22f75ef275b6597 +travsr-plugin-host = 4c9edb7ecee336b35ccaaf241aae6b24db968683d64deae2adf1d8a327493569 travsr-plugin-protocol = d6f8d6949b3c684aa21d4742a8c9ba04ccec793bea9551d8264f4c7be901d427 travsr-plugin-sdk = 6f20b7313d11d8fa0dfafd5203ef5870be443ee30b2141687c4624b7cc5f9a2b