diff --git a/.gitignore b/.gitignore index 90328719..9439ce08 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,13 @@ __pycache__/ design/ +# Ad-hoc scratch probe / calibration example binaries (never committed). +# The one real tracked example, crates/travsr-rerank/examples/rerank_bench.rs, +# does not match these patterns and stays tracked. +crates/*/examples/probe_*.rs +crates/*/examples/*_probe.rs +crates/*/examples/calib_*.rs + # travsr:begin (generated AI-tool config, `travsr connect --remove` to undo) /.mcp.json # travsr:end diff --git a/crates/travsr-analysis/src/c.rs b/crates/travsr-analysis/src/c.rs index c110bfb4..0adfcee2 100644 --- a/crates/travsr-analysis/src/c.rs +++ b/crates/travsr-analysis/src/c.rs @@ -50,6 +50,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &["function_definition"], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_c::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/cpp.rs b/crates/travsr-analysis/src/cpp.rs index 6aac8d31..e09f93b5 100644 --- a/crates/travsr-analysis/src/cpp.rs +++ b/crates/travsr-analysis/src/cpp.rs @@ -50,6 +50,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &["function_definition"], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_cpp::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/csharp.rs b/crates/travsr-analysis/src/csharp.rs index d62ea84e..1b15eb5d 100644 --- a/crates/travsr-analysis/src/csharp.rs +++ b/crates/travsr-analysis/src/csharp.rs @@ -59,6 +59,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &[], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_c_sharp::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/dart.rs b/crates/travsr-analysis/src/dart.rs index b435e4ca..9cdb4317 100644 --- a/crates/travsr-analysis/src/dart.rs +++ b/crates/travsr-analysis/src/dart.rs @@ -49,6 +49,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { // file node instead of the enclosing function (E5). decl_kinds: &["function_declaration", "method_declaration"], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_dart::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/generic.rs b/crates/travsr-analysis/src/generic.rs index 340d3cee..38df59fd 100644 --- a/crates/travsr-analysis/src/generic.rs +++ b/crates/travsr-analysis/src/generic.rs @@ -58,6 +58,14 @@ pub struct LanguageConfig { /// ancestor whose kind is listed here. Empty ⇒ the 1-hop span is already /// correct for this grammar (name is a direct child of its declaration). pub decl_kinds: &'static [&'static str], + /// Optional post-parse expansion hook, run once after the capture pass with + /// the parsed tree and source so a language can synthesize nodes the shared + /// capture pipeline cannot express. Used by Ruby (#780) to expand + /// `attr_accessor`/`attr_reader`/`attr_writer` macros into their reader/writer + /// accessor method nodes (one macro → N synthetic names with a derived `=` + /// writer, which no single capture can produce). `None` for every language + /// whose definitions are all direct captures. + pub post_parse: Option, /// N4d refiners for grammars that fold several type kinds into one AST node /// with no distinguishing field (unlike Swift's `declaration_kind`). /// tree-sitter-kotlin-ng folds `class`/`interface`/`enum class` into @@ -73,6 +81,31 @@ pub struct LanguageConfig { pub get_grammar: fn() -> tree_sitter::Language, } +/// A language-specific post-parse expansion pass (see [`LanguageConfig::post_parse`]). +/// Receives the parsed tree/source via [`PostParseCtx`] and appends synthetic +/// nodes and their containment edges to the accumulating output. +pub type PostParseHook = fn(&PostParseCtx<'_>, &mut Vec, &mut Vec); + +/// Context handed to a [`PostParseHook`]: everything it needs to build VNames and +/// containment edges consistent with the capture pass that ran before it. +pub struct PostParseCtx<'a> { + /// Root of the parsed tree. + pub root: tree_sitter::Node<'a>, + /// File bytes (for `utf8_text` on captured nodes). + pub source: &'a [u8], + /// The corpus this file belongs to. + pub corpus: &'a str, + /// VName path of the file being parsed. + pub vname_path: &'a str, + /// Language string (`config.language.as_str()`), for VName construction. + pub lang: &'a str, + /// The file node's id, for edges parented to the file. + pub file_id: travsr_core::NodeId, + /// The parser config, so the hook can reuse `method_containers` / + /// `type_refinements` (e.g. via [`enclosing_container`]). + pub config: &'a LanguageConfig, +} + /// N4d: refine a folded type declaration's kind by the presence of a signal /// node (`has_child_kind`) among the decl's direct children OR inside its /// `modifiers` subtree. The modifiers subtree is scanned because grammars put @@ -265,6 +298,22 @@ pub fn parse_with_config( } } + // #780: language-specific expansion the shared capture pipeline cannot + // express (Ruby `attr_*` macros → accessor method nodes). No-op for every + // config that leaves `post_parse` unset. + if let Some(hook) = config.post_parse { + let ctx = PostParseCtx { + root: tree.root_node(), + source: &source, + corpus, + vname_path, + lang: lang_str, + file_id, + config, + }; + hook(&ctx, &mut nodes, &mut edges); + } + // #479: single language-agnostic post-pass sets test_role from the collected // signals (no-op when the file has no test captures). crate::test_role::apply_test_roles(&test_signals, &mut nodes); @@ -313,7 +362,7 @@ fn decl_end_line(node: tree_sitter::Node<'_>, decl_kinds: &[&str]) -> Option( +pub(crate) fn enclosing_container<'a>( node: tree_sitter::Node<'_>, method_containers: &[(&'static str, &'a str)], type_refinements: &[TypeRefinement], diff --git a/crates/travsr-analysis/src/kotlin.rs b/crates/travsr-analysis/src/kotlin.rs index 05d11027..0224784f 100644 --- a/crates/travsr-analysis/src/kotlin.rs +++ b/crates/travsr-analysis/src/kotlin.rs @@ -64,6 +64,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { prefix: "enum", }, ], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_kotlin_ng::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/objc.rs b/crates/travsr-analysis/src/objc.rs index 63d49748..c4351e1f 100644 --- a/crates/travsr-analysis/src/objc.rs +++ b/crates/travsr-analysis/src/objc.rs @@ -55,6 +55,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &["function_definition"], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_objc::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/php.rs b/crates/travsr-analysis/src/php.rs index d99026d7..0c64ad61 100644 --- a/crates/travsr-analysis/src/php.rs +++ b/crates/travsr-analysis/src/php.rs @@ -51,6 +51,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &[], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_php::LANGUAGE_PHP), }; diff --git a/crates/travsr-analysis/src/ruby.rs b/crates/travsr-analysis/src/ruby.rs index 3996f053..3b7de3ce 100644 --- a/crates/travsr-analysis/src/ruby.rs +++ b/crates/travsr-analysis/src/ruby.rs @@ -2,9 +2,9 @@ use std::path::Path; -use travsr_core::Language; +use travsr_core::{Edge, EdgeKind, Language, Node, VName}; -use crate::generic::{parse_with_config, LanguageConfig}; +use crate::generic::{enclosing_container, parse_with_config, LanguageConfig, PostParseCtx}; use crate::ParseOutput; pub const CONFIG: LanguageConfig = LanguageConfig { @@ -15,6 +15,20 @@ pub const CONFIG: LanguageConfig = LanguageConfig { (module name: (constant) @module.name) (method name: (identifier) @fn.name) (singleton_method name: (identifier) @fn.name) +; #780: setter (`def x=`) and operator (`def ==`, `def <=>`, `def []`, +; `def []=`, `def <<`, …) methods name their def with a `setter`/`operator` +; node, not an `identifier`, so the identifier pattern above never captured +; them and scip-ruby's twins (`Class#`x=`().`, `Class#`==`().`) orphaned. +(method name: (setter) @fn.name) +(method name: (operator) @fn.name) +(singleton_method name: (setter) @fn.name) +(singleton_method name: (operator) @fn.name) +; #780 (RC-3): constants (`X = …`) and instance variables (`@x = …`) as def +; nodes so scip-ruby const/ivar references unify onto them instead of resolving +; to duplicate orphan scip nodes. The ivar is field-qualified by its enclosing +; type (`field:C.@x`) so `@name` in different classes never collide. +(assignment left: (constant) @const.name) +(assignment left: (instance_variable) @field.name) (call method: (identifier) @require_relative.kw arguments: (argument_list (string (string_content) @import)) @@ -49,13 +63,299 @@ pub const CONFIG: LanguageConfig = LanguageConfig { // pattern. ("import", "import", "import"), ("import.gem", "import", "import:gem"), + // #780 (RC-3): `X = …` → `const:X` (file-level; the VName's path keeps + // same-named constants in different files distinct). Matches scip-ruby's + // `const:X` unification candidate. + ("const.name", "constant", "const"), + // #780 (RC-3): `@x = …` → `field:C.@x`, qualified by the enclosing type + // via the shared `field` member-qualification path, matching scip-ruby's + // `field:C.@x` candidate. `@name` in different classes stay distinct. + ("field.name", "field", "field"), ], method_containers: &[("class", "class"), ("module", "class")], decl_kinds: &[], type_refinements: &[], + post_parse: Some(ruby_post_parse), get_grammar: || tree_sitter::Language::new(tree_sitter_ruby::LANGUAGE), }; +/// #780 Prong B: Ruby-specific node synthesis the shared capture pipeline cannot +/// express — `attr_*` macro accessors and `Struct.new` classes with their member +/// accessors and block methods. +fn ruby_post_parse(ctx: &PostParseCtx<'_>, nodes: &mut Vec, edges: &mut Vec) { + expand_attr_accessors(ctx, nodes, edges); + expand_struct_defs(ctx, nodes, edges); +} + +/// The declared name of a `method` / `singleton_method` def as tree-sitter names +/// it: an `identifier` (`foo`), a `setter` (`foo=`), or an `operator` (`==`, +/// `[]`, `<=>`, …). Returns the node's own text, which is exactly the method name +/// scip-ruby uses in its descriptor. +fn method_def_name<'a>(method: tree_sitter::Node<'_>, source: &'a [u8]) -> Option<&'a str> { + let name = method.child_by_field_name("name")?; + match name.kind() { + "identifier" | "setter" | "operator" => name.utf8_text(source).ok().map(str::trim), + _ => None, + } +} + +/// #780 Prong B: expand `attr_accessor` / `attr_reader` / `attr_writer` macros +/// into the accessor method nodes tree-sitter never emits, so scip-ruby's +/// synthesized reader/writer defs (`Class#name().`, `` Class#`name=`(). ``) +/// unify onto a real Phase A twin instead of surviving as orphan duplicates +/// that steal the accessor's reference edges. +/// +/// One macro call yields up to `2 × N` names (a reader `x` and a writer `x=` +/// per symbol), with the writer's `=`-suffixed name derived — which no single +/// tree-sitter capture can express, hence this post-parse pass. Each accessor +/// is anchored on the macro's line (scip-ruby reports the accessor there too, +/// so unification matches with line delta 0) and qualified by the enclosing +/// class/module (matching scip-ruby's container). An accessor whose signature +/// was already emitted by the capture pass — a class with both `attr_accessor +/// :x` and an explicit `def x` / `def x=` — is not re-emitted. +fn expand_attr_accessors(ctx: &PostParseCtx<'_>, nodes: &mut Vec, edges: &mut Vec) { + // Signatures already present (explicit `def x` / `def x=`, and accessors + // emitted earlier in this pass) — the double-emission guard. + let mut seen: std::collections::HashSet = + nodes.iter().map(|n| n.vname.signature.clone()).collect(); + + let mut stack = vec![ctx.root]; + while let Some(node) = stack.pop() { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + if node.kind() != "call" { + continue; + } + let Some(method) = node.child_by_field_name("method") else { + continue; + }; + if method.kind() != "identifier" { + continue; + } + let (reader, writer) = match method.utf8_text(ctx.source).unwrap_or("") { + "attr_accessor" => (true, true), + "attr_reader" => (true, false), + "attr_writer" => (false, true), + _ => continue, + }; + // An accessor macro only defines methods inside a class/module; the + // enclosing type both qualifies the name (matching scip-ruby's + // container) and prevents `method:name` collisions across classes. + let Some((prefix, container)) = enclosing_container( + node, + ctx.config.method_containers, + ctx.config.type_refinements, + ctx.source, + ) else { + continue; + }; + let Some(args) = node.child_by_field_name("arguments") else { + continue; + }; + let line = node.start_position().row as u32 + 1; + let container_id = VName::new( + ctx.corpus, + "", + ctx.vname_path, + ctx.lang, + format!("{prefix}:{container}"), + ) + .id(); + + let mut arg_cursor = args.walk(); + for arg in args.children(&mut arg_cursor) { + if arg.kind() != "simple_symbol" { + continue; + } + // `:package_name` → `package_name`. + let Some(name) = arg + .utf8_text(ctx.source) + .ok() + .and_then(|t| t.strip_prefix(':')) + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let mut method_names: Vec = Vec::with_capacity(2); + if reader { + method_names.push(name.to_string()); + } + if writer { + method_names.push(format!("{name}=")); + } + for mname in method_names { + let sig = format!("method:{container}.{mname}"); + if !seen.insert(sig.clone()) { + continue; + } + let vname = VName::new(ctx.corpus, "", ctx.vname_path, ctx.lang, &sig); + let accessor = Node::new(vname, "method") + .with_line(line) + .with_end_line(line); + edges.push(Edge::new( + container_id, + accessor.id, + EdgeKind::DefinesBinding, + )); + nodes.push(accessor); + } + } + } +} + +/// #780 Prong B (Struct.new): a `Name = Struct.new(:a, :b) do … end` constant +/// is a class in scip-ruby (`…#Name#`) with a read/write accessor per member and +/// the block's own methods — none of which tree-sitter emits, because it sees a +/// constant assigned a method call, not a class. Synthesize the pieces scip +/// unifies against: the `class:Name` node, `method:Name.member` / +/// `method:Name.member=` accessors, and `method:Name.` for each +/// method defined in the `do … end` block. Anchored on the assignment line +/// (where scip reports the class and its synthesized accessors). +fn expand_struct_defs(ctx: &PostParseCtx<'_>, nodes: &mut Vec, edges: &mut Vec) { + let mut seen: std::collections::HashSet = + nodes.iter().map(|n| n.vname.signature.clone()).collect(); + + let mut stack = vec![ctx.root]; + while let Some(node) = stack.pop() { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + if node.kind() != "assignment" { + continue; + } + // `Name = ` where the constant `Name` becomes the class name. + let Some(left) = node.child_by_field_name("left") else { + continue; + }; + if left.kind() != "constant" { + continue; + } + let Some(name) = left.utf8_text(ctx.source).ok().map(str::trim) else { + continue; + }; + // `` must be `Struct.new(...)` — receiver constant `Struct`, + // method `new`. + let Some(call) = node + .child_by_field_name("right") + .filter(|r| r.kind() == "call") + else { + continue; + }; + let is_struct_new = call + .child_by_field_name("receiver") + .and_then(|r| r.utf8_text(ctx.source).ok()) + .is_some_and(|t| t.trim() == "Struct") + && call + .child_by_field_name("method") + .and_then(|m| m.utf8_text(ctx.source).ok()) + .is_some_and(|t| t.trim() == "new"); + if !is_struct_new { + continue; + } + + let line = node.start_position().row as u32 + 1; + let end_line = node.end_position().row as u32 + 1; + let class_sig = format!("class:{name}"); + let class_id = VName::new(ctx.corpus, "", ctx.vname_path, ctx.lang, &class_sig).id(); + + // The `class:Name` node (parented to the file, as tree-sitter's own class + // nodes are). Skip if a real `class Name` already exists. + if seen.insert(class_sig.clone()) { + let vname = VName::new(ctx.corpus, "", ctx.vname_path, ctx.lang, &class_sig); + let class_node = Node::new(vname, "class") + .with_line(line) + .with_end_line(end_line); + edges.push(Edge::new( + ctx.file_id, + class_node.id, + EdgeKind::DefinesBinding, + )); + nodes.push(class_node); + } + + let mut emit_method = + |mname: &str, at: u32, seen: &mut std::collections::HashSet| { + let sig = format!("method:{name}.{mname}"); + if !seen.insert(sig.clone()) { + return; + } + let vname = VName::new(ctx.corpus, "", ctx.vname_path, ctx.lang, &sig); + let m = Node::new(vname, "method").with_line(at).with_end_line(at); + edges.push(Edge::new(class_id, m.id, EdgeKind::DefinesBinding)); + nodes.push(m); + }; + + // Member accessors: one reader + writer per positional `:symbol` arg + // (`keyword_init:` and other pairs are not simple_symbols and are + // skipped). scip-ruby synthesizes these at the class line, so anchor them + // on the assignment line. + if let Some(args) = call.child_by_field_name("arguments") { + let mut ac = args.walk(); + for arg in args.children(&mut ac) { + if arg.kind() != "simple_symbol" { + continue; + } + if let Some(member) = arg + .utf8_text(ctx.source) + .ok() + .and_then(|t| t.strip_prefix(':')) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + emit_method(member, line, &mut seen); + emit_method(&format!("{member}="), line, &mut seen); + } + } + } + + // Methods defined in the `do … end` block belong to the Struct class and + // are reported by scip-ruby at their own def line (which can be far from + // the assignment line in a large block), so anchor each on its own line. + // Emitted before the implicit `initialize` below so an explicit block + // `def initialize` keeps its own accurate line. + if let Some(block) = call.child_by_field_name("block") { + for (mname, at) in block_method_names(block, ctx.source) { + emit_method(&mname, at, &mut seen); + } + } + + // Every Struct has an implicit `initialize` constructor, which scip-ruby + // emits as a def even when there is no block. Anchor it on the class line + // (unless an explicit block `def initialize` already claimed it above). + emit_method("initialize", line, &mut seen); + } +} + +/// `(name, 1-based line)` of `method`/`singleton_method` defs directly inside a +/// Struct.new block, not descending into a nested class/module (whose methods +/// belong to it, not the Struct). Order is irrelevant — the caller dedups by +/// signature. +fn block_method_names(block: tree_sitter::Node<'_>, source: &[u8]) -> Vec<(String, u32)> { + let mut out = Vec::new(); + let mut stack = vec![block]; + while let Some(node) = stack.pop() { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + // A nested class/module owns its own methods — do not attribute them + // to the enclosing Struct. + if child != block && matches!(child.kind(), "class" | "module") { + continue; + } + if matches!(child.kind(), "method" | "singleton_method") { + if let Some(n) = method_def_name(child, source) { + out.push((n.to_string(), child.start_position().row as u32 + 1)); + } + } + stack.push(child); + } + } + out +} + /// Parse a Ruby source file into graph nodes and edges. pub fn parse(corpus: &str, abs_path: &Path, vname_path: &str) -> anyhow::Result { let grammar = (CONFIG.get_grammar)(); @@ -145,6 +445,199 @@ mod tests { assert_eq!(out.nodes.len(), 1); } + fn sigs_of(out: &ParseOutput) -> Vec<&str> { + out.nodes + .iter() + .map(|n| n.vname.signature.as_str()) + .collect() + } + + #[test] + fn attr_accessor_emits_reader_and_writer() { + // #780 Prong B: `attr_accessor :x` synthesizes both `method:C.x` + // (reader) and `method:C.x=` (writer), qualified by the class, anchored + // on the macro line so scip-ruby's `x()`/`x=()` twins unify (delta 0). + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "class C\n attr_accessor :package_name, :version_code\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + let sigs = sigs_of(&out); + for want in [ + "method:C.package_name", + "method:C.package_name=", + "method:C.version_code", + "method:C.version_code=", + ] { + assert!(sigs.contains(&want), "missing {want}: {sigs:?}"); + } + // Both accessors anchor on the macro line (2), so line-proximity holds. + let reader = out + .nodes + .iter() + .find(|n| n.vname.signature == "method:C.package_name") + .unwrap(); + assert_eq!(reader.line, Some(2)); + assert_eq!(reader.kind, "method"); + // Containment edge is parented to the class, not the file. + let class_id = out + .nodes + .iter() + .find(|n| n.vname.signature == "class:C") + .unwrap() + .id; + assert!( + out.edges + .iter() + .any(|e| e.src == class_id && e.dst == reader.id), + "reader must be contained by class:C" + ); + } + + #[test] + fn attr_reader_and_writer_are_one_sided() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "class C\n attr_reader :ro\n attr_writer :wo\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + let sigs = sigs_of(&out); + assert!(sigs.contains(&"method:C.ro"), "reader: {sigs:?}"); + assert!(!sigs.contains(&"method:C.ro="), "no writer for attr_reader"); + assert!(sigs.contains(&"method:C.wo="), "writer: {sigs:?}"); + assert!(!sigs.contains(&"method:C.wo"), "no reader for attr_writer"); + } + + #[test] + fn attr_accessor_does_not_double_emit_explicit_def() { + // #780 guard: a class with both `attr_accessor :x` and an explicit + // `def x` / `def x=` yields exactly one node per signature. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "class C\n attr_accessor :x\n def x\n @x\n end\n def x=(v)\n @x = v\n end\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + assert_eq!( + out.nodes + .iter() + .filter(|n| n.vname.signature == "method:C.x") + .count(), + 1, + "method:C.x must not be double-emitted" + ); + assert_eq!( + out.nodes + .iter() + .filter(|n| n.vname.signature == "method:C.x=") + .count(), + 1, + "method:C.x= must not be double-emitted" + ); + } + + #[test] + fn setter_and_operator_methods_captured() { + // #780 Prong B: setter (`def x=`) and operator (`def ==`, `def <=>`, + // `def []`, `def []=`) methods are captured as qualified method nodes. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "class C\n def name=(v); end\n def ==(o); end\n def <=>(o); end\n def [](i); end\n def []=(i, v); end\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + let sigs = sigs_of(&out); + for want in [ + "method:C.name=", + "method:C.==", + "method:C.<=>", + "method:C.[]", + "method:C.[]=", + ] { + assert!(sigs.contains(&want), "missing {want}: {sigs:?}"); + } + } + + #[test] + fn struct_new_emits_class_members_and_block_methods() { + // #780 Prong B (Struct.new): `X = Struct.new(:a, :b) do def m; end end` is + // a class in scip-ruby with member accessors and block methods. Emit + // class:X, method:X.a/.a=/.b/.b=, and method:X.m. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "module M\n ServiceOption = Struct.new(:auth_type, :name) do\n def describe\n end\n end\n URLLog = Struct.new(:url)\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + let sigs = sigs_of(&out); + for want in [ + "class:ServiceOption", + "method:ServiceOption.auth_type", + "method:ServiceOption.auth_type=", + "method:ServiceOption.name", + "method:ServiceOption.name=", + "method:ServiceOption.describe", + // Every Struct has an implicit `initialize` scip-ruby emits, even the + // block-less `URLLog`. + "method:ServiceOption.initialize", + "class:URLLog", + "method:URLLog.url", + "method:URLLog.url=", + "method:URLLog.initialize", + ] { + assert!(sigs.contains(&want), "missing {want}: {sigs:?}"); + } + // class:ServiceOption is parented to the file (as tree-sitter class nodes + // are), so scip's `…#ServiceOption#` class ref unifies onto it. + let file_id = out.nodes.iter().find(|n| n.kind == "file").unwrap().id; + let class_id = out + .nodes + .iter() + .find(|n| n.vname.signature == "class:ServiceOption") + .unwrap() + .id; + assert!(out + .edges + .iter() + .any(|e| e.src == file_id && e.dst == class_id)); + } + + #[test] + fn constants_and_ivars_emitted_as_def_nodes() { + // #780 Prong C: `X = …` → `const:X`; `@x = …` → `field:C.@x` (qualified + // by the enclosing class), so scip-ruby const/ivar refs unify instead of + // orphaning duplicates. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.rb"); + std::fs::write( + &path, + "class C\n VERSION = \"1.0\"\n def initialize\n @count = 0\n end\nend\n", + ) + .unwrap(); + let out = parse("corp", &path, "a.rb").unwrap(); + let sigs = sigs_of(&out); + assert!(sigs.contains(&"const:VERSION"), "const: {sigs:?}"); + assert!(sigs.contains(&"field:C.@count"), "ivar: {sigs:?}"); + let ivar = out + .nodes + .iter() + .find(|n| n.vname.signature == "field:C.@count") + .unwrap(); + assert_eq!(ivar.kind, "field"); + } + #[test] fn parse_class_and_methods() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/travsr-analysis/src/scala.rs b/crates/travsr-analysis/src/scala.rs index 817f6cb0..b36431b8 100644 --- a/crates/travsr-analysis/src/scala.rs +++ b/crates/travsr-analysis/src/scala.rs @@ -43,6 +43,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &[], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_scala::LANGUAGE), }; diff --git a/crates/travsr-analysis/src/swift.rs b/crates/travsr-analysis/src/swift.rs index ad6f1831..1e0fb7dc 100644 --- a/crates/travsr-analysis/src/swift.rs +++ b/crates/travsr-analysis/src/swift.rs @@ -64,6 +64,7 @@ pub const CONFIG: LanguageConfig = LanguageConfig { ], decl_kinds: &[], type_refinements: &[], + post_parse: None, get_grammar: || tree_sitter::Language::new(tree_sitter_swift::LANGUAGE), }; diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index e23ab6fd..2e528913 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -2900,24 +2900,41 @@ fn write_phase_b_results( scip_unify_attempted = unify.attempted; scip_unify_missed = unify.attempted.saturating_sub(unify.unified); alias_map = unify.alias_map; - let pb_refs = pb_refs_mut; + // #780: synthetic DSL meta-scope def nodes (RSpec blocks) with no twin. + // Dropped outright — node, inbound refs, and edges — so they stop + // surviving as orphan duplicates that steal spec-file reference edges. + let dropped = unify.dropped; + + // Refs whose callee is a dropped synthetic node would write an edge onto + // a node that no longer exists (a dangling/ghost edge). Drop them: the + // "target" was never a real definition, so the reference correctly + // resolves to nothing rather than to a bogus duplicate. + let pb_refs: Vec = pb_refs_mut + .into_iter() + .filter(|r| !dropped.contains(&r.callee_id)) + .collect(); // Drop unified SCIP definition nodes: the tree-sitter node already // represents them (symbol_aliases preserves scip_symbol → TS node // resolution), and writing them would re-create the duplicate node - // + FTS rows that unification exists to eliminate. + // + FTS rows that unification exists to eliminate. Also drop the + // synthetic DSL nodes (#780), which have no TS twin to alias onto. let pb_nodes: Vec = pb_nodes .into_iter() - .filter(|n| !alias_map.contains_key(&n.id)) + .filter(|n| !alias_map.contains_key(&n.id) && !dropped.contains(&n.id)) .collect(); // Rewrite SCIP structural edges through the alias map so they land // on the unified TS nodes instead of the dropped duplicates; an // edge that collapses to a self-loop after rewriting carried no - // information beyond the node itself — drop it. + // information beyond the node itself — drop it. An edge touching a + // dropped synthetic node (#780) has no valid endpoint — drop it too. let pb_edges: Vec = pb_edges .into_iter() .filter_map(|mut e| { + if dropped.contains(&e.src) || dropped.contains(&e.dst) { + return None; + } if let Some(&ts_id) = alias_map.get(&e.src) { e.src = ts_id; } diff --git a/crates/travsr-daemon/src/scip_unifier.rs b/crates/travsr-daemon/src/scip_unifier.rs index 64237131..c219aea2 100644 --- a/crates/travsr-daemon/src/scip_unifier.rs +++ b/crates/travsr-daemon/src/scip_unifier.rs @@ -11,7 +11,7 @@ //! Phase A parser's signature convention. Non-SCIP node signatures (builtin //! native Phase B plugins) yield no descriptor parse and fall through. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use travsr_core::{NodeId, ScipRef}; use travsr_store::SqliteStore; @@ -33,6 +33,14 @@ pub struct UnifyOutcome { pub attempted: usize, /// Callable/type candidates that matched an existing Phase A node. pub unified: usize, + /// SCIP def nodes with no reconcilable Phase A counterpart that must not be + /// counted as misses (#780): Sorbet synthetic DSL meta-scopes (RSpec + /// `describe`/`context`/`it` blocks), and defs in files the tree-sitter parser + /// never indexed (gitignored vendored code scip-ruby indexes anyway). Neither + /// is a reconciliation failure — no twin exists or can. Excluded from + /// `attempted`/`unified`, and dropped by the caller (node plus its inbound + /// refs/edges) so they stop surviving as orphan duplicates that steal edges. + pub dropped: HashSet, } /// G1 unification pass for all SCIP-indexed languages. @@ -51,6 +59,10 @@ pub fn unify_all( ) -> UnifyOutcome { // Maps SCIP NodeId → unified TS NodeId for ref-patching. let mut alias_map: HashMap = HashMap::new(); + // #780: SCIP def nodes that are synthetic DSL meta-scopes — no twin exists + // and none can, so they are neither an attempt nor a miss. Collected here so + // the caller drops them (and their inbound refs/edges) outright. + let mut dropped: HashSet = HashSet::new(); // (scip_symbol, ts_id) pairs, registered in one batch transaction below. let mut aliases: Vec<(String, NodeId)> = Vec::new(); // E6: unification attempts/matches for the miss-rate signal. Scoped to @@ -84,6 +96,13 @@ pub fn unify_all( // re-deriving it would mean re-parsing the SCIP descriptor. let mut unmatched: Vec<(NodeId, &str, Vec, &str)> = Vec::new(); + // #780: paths the tree-sitter parser actually indexed. A SCIP def in a file + // absent from this set is one only the SCIP tool saw — gitignored vendored + // code (scip-ruby indexes `vendor/bundle`, tree-sitter skips it) — and can + // never reconcile, so it is excluded from the miss counters and dropped + // rather than counted as a failure or kept as an edge-stealing orphan. + let indexed_paths = store.phase_a_indexed_paths(corpus).unwrap_or_default(); + for node in nodes { let scip_sym = travsr_indexer::scip_unifier::scip_symbol_from_sig(&node.vname.signature); // Primary: SCIP descriptor grammar (go/java/ruby/c#/c/c++/… + rust/ts/py @@ -92,19 +111,38 @@ pub fn unify_all( // parse as SCIP. The fallback is gated to those languages so native // Phase A/rust nodes — whose signatures look identical — are never // re-unified against themselves. - let parsed = match travsr_indexer::scip_unifier::scip_name_kind(scip_sym) { - Some(p) => p, + let (parsed, is_scip) = match travsr_indexer::scip_unifier::scip_name_kind(scip_sym) { + Some(p) => (p, true), None if matches!(node.vname.language.as_str(), "kotlin" | "swift" | "dart") => { match travsr_indexer::scip_unifier::native_name_kind( &node.vname.signature, &node.kind, ) { - Some(p) => p, + Some(p) => (p, false), None => continue, } } None => continue, }; + // #780: a SCIP def in a file the tree-sitter parser never indexed + // (gitignored vendored code that scip-ruby indexes anyway) has no twin + // and none can exist — it is not a reconciliation failure. Drop it and + // exclude it from the counters. Native sidecar nodes (kotlin/swift/dart) + // are never path-excluded: their twins live in the same indexed sources. + if is_scip && !indexed_paths.contains(&node.vname.path) { + dropped.insert(node.id); + continue; + } + // #780: Sorbet models RSpec `describe`/`context`/`it` blocks as singleton + // scopes and emits SCIP defs for them, but tree-sitter (correctly) sees a + // method call with a block, so no Phase A twin exists or can. These are + // not definitions: exclude them from the miss counters entirely and drop + // the node so it stops orphaning as a duplicate that steals spec-file + // reference edges (~70% of issue #780's headline rate). + if travsr_indexer::scip_unifier::is_synthetic_dsl_scope(&parsed) { + dropped.insert(node.id); + continue; + } // No definition line means line-proximity matching is meaningless — // unwrapping to 0 would let any same-named node on lines 1..=5 of the // file match wrongly. Skip instead. @@ -232,5 +270,112 @@ pub fn unify_all( unified, attempted, alias_map, + dropped, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use travsr_core::{Node, VName}; + + fn scip_node(path: &str, symbol: &str, line: u32) -> Node { + // scip-reader packs def signatures as `scip:{rel_path}:{symbol}`. + let sig = format!("scip:{path}:{symbol}"); + Node::new(VName::new("c", "main", path, "ruby", &sig), "definition").with_line(line) + } + + #[test] + fn dsl_scopes_excluded_and_dropped_real_method_counted() { + // #780: a real `Class#method().` def unifies onto its Phase A twin and + // counts toward the miss rate; a Sorbet RSpec DSL block def is neither + // counted nor written — it is dropped so it cannot steal ref edges. + let mut store = SqliteStore::open_in_memory().unwrap(); + // Phase A twin for the real accessor (as emitted by the Ruby attr_* / + // method captures), same path + line as the SCIP def. + let ts = Node::new( + VName::new( + "c", + "main", + "supply/lib/supply/generated_universal_apk.rb", + "ruby", + "method:GeneratedUniversalApk.package_name", + ), + "method", + ) + .with_line(4) + .with_end_line(4); + store + .write_phase_b_batch(std::slice::from_ref(&ts), &[], "scip") + .unwrap(); + + let real = scip_node( + "supply/lib/supply/generated_universal_apk.rb", + "scip-ruby gem fastlane 0.0.0 Supply#GeneratedUniversalApk#package_name().", + 4, + ); + let dsl_method = scip_node( + "fastlane/spec/actions_specs/carthage_spec.rb", + "scip-ruby gem fastlane 0.0.0 ``#``().", + 7, + ); + let dsl_type = scip_node( + "match/spec/setup_spec.rb", + "scip-ruby gem fastlane 0.0.0 ``#``#", + 1, + ); + + let nodes = vec![real.clone(), dsl_method.clone(), dsl_type.clone()]; + let mut refs: Vec = Vec::new(); + let out = unify_all(&mut store, "c", &nodes, &mut refs); + + assert_eq!(out.attempted, 1, "only the real Class#method is an attempt"); + assert_eq!(out.unified, 1, "the real method unifies onto its twin"); + assert_eq!(out.alias_map.get(&real.id), Some(&ts.id)); + assert!(out.dropped.contains(&dsl_method.id), "DSL block dropped"); + assert!(out.dropped.contains(&dsl_type.id), "DSL type block dropped"); + assert!( + !out.dropped.contains(&real.id), + "the real method must not be dropped" + ); + } + + #[test] + fn scip_def_in_unindexed_file_is_dropped_not_counted() { + // #780: scip-ruby indexes gitignored vendored code the tree-sitter parser + // skips, so those files hold only SCIP defs and no Phase A node. Such a + // def can never reconcile — it must be dropped and excluded from the miss + // rate, not counted as a failure. + let mut store = SqliteStore::open_in_memory().unwrap(); + // An indexed app file with a real Phase A twin (so its path is "indexed"). + let ts = Node::new( + VName::new("c", "main", "lib/app.rb", "ruby", "method:App.run"), + "method", + ) + .with_line(2) + .with_end_line(2); + store + .write_phase_b_batch(std::slice::from_ref(&ts), &[], "scip") + .unwrap(); + + let app = scip_node("lib/app.rb", "scip-ruby gem g 0.0.0 App#run().", 2); + // A vendored def whose file has NO Phase A node at all. + let vendored = scip_node( + "vendor/bundle/ruby/3.4.0/gems/rake/lib/rake/task.rb", + "scip-ruby gem g 0.0.0 Rake#Task#invoke().", + 10, + ); + + let nodes = vec![app.clone(), vendored.clone()]; + let mut refs: Vec = Vec::new(); + let out = unify_all(&mut store, "c", &nodes, &mut refs); + + assert_eq!(out.attempted, 1, "only the indexed-file def is an attempt"); + assert_eq!(out.unified, 1); + assert!( + out.dropped.contains(&vendored.id), + "def in an unindexed (vendored) file must be dropped" + ); + assert!(!out.dropped.contains(&app.id)); } } diff --git a/crates/travsr-indexer/src/scip_unifier.rs b/crates/travsr-indexer/src/scip_unifier.rs index 827739ee..d8f91950 100644 --- a/crates/travsr-indexer/src/scip_unifier.rs +++ b/crates/travsr-indexer/src/scip_unifier.rs @@ -33,7 +33,15 @@ pub struct ScipName<'a> { /// descriptors, parameter descriptors, and macro/meta descriptors — callers /// can feed every Phase B node through without pre-filtering by language. pub fn scip_name_kind(symbol: &str) -> Option> { - let descriptor_chain = symbol.split_whitespace().last()?; + // SCIP symbol = ` `. + // The four metadata fields are single-space separated and space-free for + // every indexer we ingest, but the descriptor chain that follows MAY contain + // spaces when a segment is backtick-escaped — scip-ruby wraps Sorbet's RSpec + // DSL scopes that way (`` ``#``(). ``). + // `split_whitespace().last()` tears those apart into garbage that still gets + // counted as a def miss; take everything after the 4th space instead so the + // whole chain (spaces and all) is parsed as one unit. + let descriptor_chain = symbol.splitn(5, ' ').nth(4)?; // Namespace descriptor `Name/`: Obj-C emits protocols this way // (`Speakable/`), and a protocol is a unifiable type (Phase A `protocol:`). @@ -51,14 +59,18 @@ pub fn scip_name_kind(symbol: &str) -> Option> { }); } - let leaf = descriptor_chain - .rsplit('/') - .next() - .unwrap_or(descriptor_chain); + // Strip the package-path prefix at the last `/` that is not inside a + // backtick-escaped segment — a test-description scope can contain `/` + // (scip-ruby: `` ``(). ``), which is part of the name, + // not a path separator. + let leaf = match rfind_outside_backticks(descriptor_chain, '/') { + Some(i) => &descriptor_chain[i + 1..], + None => descriptor_chain, + }; if leaf.ends_with(").") { // Method/function: `Name().`, `Type#Name().`, `Name(+1).` - let open = leaf.rfind('(')?; + let open = rfind_outside_backticks(leaf, '(')?; let (container, name) = split_container(&leaf[..open]); if name.is_empty() { return None; @@ -104,17 +116,54 @@ pub fn scip_name_kind(symbol: &str) -> Option> { /// match Phase A signatures. Backticks containing `/` or `(` (package /// descriptors) are not tokenized here — those never parse as name/kind. fn split_container(s: &str) -> (Option<&str>, &str) { - match s.rsplit_once('#') { - Some((pre, name)) => { - let container = - unwrap_meta_container(strip_backticks(pre.rsplit('#').next().unwrap_or(pre))); + // Split on the last `#` that is not inside a backtick-escaped segment. A + // scip-ruby RSpec scope embeds `#` in its description (`` `` ``); those are part of the name, not the + // `Container#name` separator, so a naive `rsplit('#')` would tear the scope + // apart into garbage that then escapes DSL detection and mis-parses real + // names. + match rfind_outside_backticks(s, '#') { + Some(i) => { + let name = &s[i + 1..]; + let pre = &s[..i]; + // Innermost container = the segment after `pre`'s own last + // outside-backtick `#` (nested types keep only the innermost). + let container_raw = match rfind_outside_backticks(pre, '#') { + Some(j) => &pre[j + 1..], + None => pre, + }; + let container = unwrap_meta_container(strip_backticks(container_raw)); ( (!container.is_empty()).then_some(container), - strip_backticks(name), + // Unwrap the leaf too: a ``/`` leaf is the + // *singleton class* of a real type (`Tunes#``#`), + // which has no separate Phase A node; reducing it to `X` lets the + // reference unify onto the real `class:X`. A synthetic DSL leaf + // (``) is left unchanged and still recognized. + unwrap_meta_container(strip_backticks(name)), ) } - None => (None, strip_backticks(s)), + None => (None, unwrap_meta_container(strip_backticks(s))), + } +} + +/// Byte index of the last `ch` in `s` that is not inside a backtick-escaped +/// segment. SCIP wraps any identifier containing a structural character +/// (`#`, `/`, `(`, space, `.`) in backticks, so such a character inside a +/// backtick pair belongs to the name and must not be read as a descriptor +/// separator. Backtick regions are delimited by single backticks (the common +/// scip-go / scip-ruby form); the scan simply toggles in/out on each one. +fn rfind_outside_backticks(s: &str, ch: char) -> Option { + let mut in_tick = false; + let mut found = None; + for (i, c) in s.char_indices() { + if c == '`' { + in_tick = !in_tick; + } else if c == ch && !in_tick { + found = Some(i); + } } + found } /// Unwrap scip-ruby's Sorbet meta-class container notation to the bare name. @@ -143,6 +192,33 @@ fn unwrap_meta_container(s: &str) -> &str { s } +/// `true` when a parsed SCIP name is a Sorbet synthetic DSL meta-scope rather +/// than a real definition — scip-ruby (built on Sorbet) models every RSpec block +/// (`describe`, `context`, `it`, `before`, …) as a singleton scope and emits a +/// descriptor for it (`` ``#``(). ``). Tree-sitter +/// correctly sees these as method calls with a block, not definitions, so no +/// Phase A twin exists or can exist. Counting them as def misses is what inflated +/// issue #780's rate to ~52%; the daemon excludes them from the miss counters and +/// drops them so they stop stealing spec-file reference edges. +/// +/// True when the leaf `name` or its `container` is a DSL meta-scope: a bracketed +/// `<…>` segment that [`unwrap_meta_container`] does NOT resolve to a real +/// ``/``. +pub fn is_synthetic_dsl_scope(parsed: &ScipName<'_>) -> bool { + is_dsl_meta_scope(parsed.name) || parsed.container.is_some_and(is_dsl_meta_scope) +} + +/// A single descriptor segment is a Sorbet DSL meta-scope: bracketed `<…>`, its +/// first inner character alphabetic (so a Ruby operator method whose name merely +/// starts with `<` — `<`, `<<`, `<=>` — is NOT misread as a scope), and not a +/// real singleton class/module that [`unwrap_meta_container`] would unwrap. +fn is_dsl_meta_scope(s: &str) -> bool { + let Some(inner) = s.strip_prefix('<').and_then(|i| i.strip_suffix('>')) else { + return false; + }; + inner.chars().next().is_some_and(|c| c.is_alphabetic()) && unwrap_meta_container(s) == s +} + /// Strip ONE pair of surrounding backticks from a SCIP escaped identifier. /// `` `name` `` → `name`; anything else passes through unchanged. fn strip_backticks(s: &str) -> &str { @@ -512,6 +588,124 @@ mod tests { .contains(&"method:EnsureBundleExecAction.is_supported?".to_string())); } + #[test] + fn dsl_descriptor_with_spaces_tokenizes_and_is_synthetic() { + // #780 RC-1b: an RSpec `it` block's descriptor is backtick-escaped and + // contains spaces. `split_whitespace().last()` used to tear it into + // garbage that still counted as a def miss; the 4-space-prefix split now + // keeps the whole chain, and the result is recognized as synthetic. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 ``#``().", + ) + .unwrap(); + assert_eq!(p.name, ""); + assert_eq!(p.container, Some("")); + assert_eq!(p.kind, "function"); + assert!(is_synthetic_dsl_scope(&p), "DSL block must be synthetic"); + } + + #[test] + fn dsl_scope_with_hash_in_description_is_synthetic() { + // #780 RC-1b: an RSpec `describe '#method'` scope embeds a `#` inside its + // backtick-quoted description. A `#`-naive split tore it into garbage that + // escaped DSL detection and stayed counted; the backtick-aware split keeps + // the scope intact so it is recognized and excluded. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 ``#``#", + ) + .unwrap(); + assert_eq!(p.kind, "class"); + assert_eq!(p.name, ""); + assert!( + is_synthetic_dsl_scope(&p), + "scope with '#' must be synthetic" + ); + } + + #[test] + fn dsl_scope_with_slash_in_description_parses_and_is_synthetic() { + // #780 RC-1b: a test description can contain `/` (`uses /var/tmp`), which + // is not a package-path separator. Backtick-aware leaf extraction keeps + // the whole chain so the scope parses and is recognized as synthetic. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 ``#``().", + ) + .unwrap(); + assert_eq!(p.name, ""); + assert!(is_synthetic_dsl_scope(&p)); + } + + #[test] + fn dsl_type_block_trailing_hash_is_synthetic() { + // #780 category C: a `describe` block with a trailing `#` parses as a + // class-kind name that is itself a DSL scope. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 ``#``#", + ) + .unwrap(); + assert_eq!(p.kind, "class"); + assert!( + is_synthetic_dsl_scope(&p), + "DSL type block must be synthetic" + ); + } + + #[test] + fn real_ruby_method_is_not_synthetic() { + // A genuine `Class#method().` must NOT be classified synthetic — it is a + // real def that Phase A should reconcile and the miss rate should count. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 Supply#GeneratedUniversalApk#package_name().", + ) + .unwrap(); + assert_eq!(p.container, Some("GeneratedUniversalApk")); + assert_eq!(p.name, "package_name"); + assert!(!is_synthetic_dsl_scope(&p)); + } + + #[test] + fn operator_method_starting_with_angle_is_not_synthetic() { + // Regression guard: `def <=>` / `def <<` name with `<`, and `<=>` even + // ends with `>`. Neither is a DSL scope (first inner char is not + // alphabetic), so they must reconcile as real operator methods. + for sym in [ + "scip-ruby gem fastlane 0.0.0 Foo#`<=>`().", + "scip-ruby gem fastlane 0.0.0 Foo#`<<`().", + ] { + let p = scip_name_kind(sym).unwrap(); + assert!( + !is_synthetic_dsl_scope(&p), + "operator misread as DSL: {sym}" + ); + } + } + + #[test] + fn singleton_class_leaf_unwraps_to_real_type() { + // #780: a `` leaf (kind class) is the singleton class of `X`; it + // has no Phase A twin of its own, so it must reduce to `X` and unify onto + // the real `class:X` instead of orphaning. + let p = scip_name_kind("scip-ruby gem fastlane 0.0.0 Spaceship#Tunes#``#") + .unwrap(); + assert_eq!(p.kind, "class"); + assert_eq!(p.name, "Members"); + assert_eq!(p.container, Some("Tunes")); + assert!(!is_synthetic_dsl_scope(&p)); + assert!(candidate_signatures(&p).contains(&"class:Members".to_string())); + } + + #[test] + fn class_module_singleton_scope_is_not_synthetic() { + // `` unwraps to a real container, so a class method inside it + // is a real def, not a synthetic scope. + let p = scip_name_kind( + "scip-ruby gem fastlane 0.0.0 Fastlane#Actions#``#`is_supported?`().", + ) + .unwrap(); + assert_eq!(p.container, Some("EnsureBundleExecAction")); + assert!(!is_synthetic_dsl_scope(&p)); + } + #[test] fn rspec_describe_block_container_is_not_unwrapped() { // RSpec DSL descriptors (``) have no Phase A twin; diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 2d8876f9..c2dc49e8 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -4515,6 +4515,33 @@ LIMIT ?4", Ok(id.map(i64_to_node_id)) } + /// The set of paths in `corpus` that carry at least one Phase A definition — + /// a node whose signature is not a `scip:` descriptor and whose kind is not + /// the synthetic `file` node. #780: SCIP tools (scip-ruby) index gitignored + /// vendored code that the tree-sitter parser deliberately skips, so those + /// files hold only SCIP `definition` nodes (plus a `file` stub) and their + /// defs can never reconcile. G1 uses this to tell a file the structural + /// parser actually indexed from one only the SCIP tool saw, so the latter's + /// unreconcilable defs are neither counted as misses nor kept as orphans. + pub fn phase_a_indexed_paths( + &self, + corpus: &str, + ) -> anyhow::Result> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT DISTINCT path FROM nodes \ + WHERE corpus = ?1 AND kind != 'file' AND signature NOT LIKE 'scip:%'", + ) + .context("phase_a_indexed_paths: prepare")?; + let paths = stmt + .query_map([corpus], |row| row.get::<_, String>(0)) + .context("phase_a_indexed_paths: query")? + .collect::, _>>() + .context("phase_a_indexed_paths: collect")?; + Ok(paths) + } + /// The single tree-sitter node anywhere in `corpus` matching one of /// `signatures`, or `None` when there is not exactly one. /// @@ -8149,6 +8176,41 @@ mod tests { assert_eq!(found, Some(inner.id), "narrowest containing span wins"); } + #[test] + fn phase_a_indexed_paths_excludes_scip_only_and_file_stub() { + // #780: a file the tree-sitter parser indexed has a real def node; a file + // only the SCIP tool saw (gitignored vendored code) has just SCIP defs + // (plus a `file` stub). Only the former counts as Phase-A-indexed. + let mut store = SqliteStore::open_in_memory().unwrap(); + // App file: a real tree-sitter method node. + store + .put_node(&Node::new( + VName::new("c", "main", "lib/app.rb", "ruby", "method:App.run"), + "method", + )) + .unwrap(); + // Vendored file: only a SCIP definition node and a `file` stub. + store + .put_node(&Node::new( + VName::new("c", "main", "vendor/gem.rb", "ruby", "scip:vendor/gem.rb:x"), + "definition", + )) + .unwrap(); + store + .put_node(&Node::new( + VName::new("c", "main", "vendor/gem.rb", "ruby", "file"), + "file", + )) + .unwrap(); + + let paths = store.phase_a_indexed_paths("c").unwrap(); + assert!(paths.contains("lib/app.rb"), "app file is indexed"); + assert!( + !paths.contains("vendor/gem.rb"), + "scip-only vendored file must not count as indexed" + ); + } + #[test] fn edge_sites_dedup_on_reinsert() { let store = SqliteStore::open_in_memory().unwrap();