From b9ff11ee442d820e7f2c074332572d62a1d0314c Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 4 Sep 2026 00:50:13 -0400 Subject: [PATCH 1/4] retain nested and bound callable definitions --- CHANGELOG.md | 4 + crates/codestory-indexer/rules/c.scm | 3 +- crates/codestory-indexer/rules/cpp.scm | 29 +-- crates/codestory-indexer/rules/javascript.scm | 11 +- crates/codestory-indexer/rules/tsx.graph.scm | 23 +- .../rules/typescript.graph.scm | 23 +- crates/codestory-indexer/src/cache.rs | 29 +++ crates/codestory-indexer/src/lib.rs | 20 +- .../src/native_declarators.rs | 60 +++++ .../tests/callable_definition_fidelity.rs | 244 ++++++++++++++++++ .../language_snapshots/cpp_tictactoe.txt | 24 +- .../language_snapshots/tsx_tictactoe.txt | 1 - 12 files changed, 395 insertions(+), 76 deletions(-) create mode 100644 crates/codestory-indexer/src/native_declarators.rs create mode 100644 crates/codestory-indexer/tests/callable_definition_fidelity.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 593ef0840..5ef7e0ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Pointer-returning C and C++ functions and variable-bound JavaScript, + TypeScript, and TSX functions and generators retain their full definitions + in the source index. + - Malformed text configuration files no longer block the entire repository index. Their source remains visible with an explicit coverage gap; stale symbols and structural claims from an earlier valid version are removed. diff --git a/crates/codestory-indexer/rules/c.scm b/crates/codestory-indexer/rules/c.scm index e945039d5..705e412a3 100644 --- a/crates/codestory-indexer/rules/c.scm +++ b/crates/codestory-indexer/rules/c.scm @@ -1,6 +1,5 @@ (function_definition - declarator: (function_declarator - declarator: (identifier) @name)) @def + declarator: (_) @name) @def { node @name.node attr (@name.node) kind = "FUNCTION" diff --git a/crates/codestory-indexer/rules/cpp.scm b/crates/codestory-indexer/rules/cpp.scm index 9e29d3ea0..b3b898b85 100644 --- a/crates/codestory-indexer/rules/cpp.scm +++ b/crates/codestory-indexer/rules/cpp.scm @@ -89,22 +89,7 @@ } (function_definition - declarator: (function_declarator - declarator: (_) @name)) @def -{ - node @name.node - attr (@name.node) kind = "FUNCTION" - attr (@name.node) name = (source-text @name) - attr (@name.node) start_row = (start-row @def) - attr (@name.node) start_col = (start-column @def) - attr (@name.node) end_row = (end-row @def) - attr (@name.node) end_col = (end-column @def) -} - -(function_definition - declarator: (pointer_declarator - declarator: (function_declarator - declarator: (_) @name))) @def + declarator: (_) @name) @def { node @name.node attr (@name.node) kind = "FUNCTION" @@ -168,8 +153,7 @@ name: (_) @ns_name body: (declaration_list (function_definition - declarator: (function_declarator - declarator: (_) @member_name)))) + declarator: (_) @member_name))) { edge @ns_name.node -> @member_name.node attr (@ns_name.node -> @member_name.node) kind = "MEMBER" @@ -189,8 +173,7 @@ name: (_) @class_name body: (field_declaration_list (function_definition - declarator: (function_declarator - declarator: (_) @method_name)))) + declarator: (_) @method_name))) { edge @class_name.node -> @method_name.node attr (@class_name.node -> @method_name.node) kind = "MEMBER" @@ -243,8 +226,7 @@ name: (_) @class_name body: (field_declaration_list (function_definition - declarator: (function_declarator - declarator: (_) @method_name)))) + declarator: (_) @method_name))) { edge @class_name.node -> @method_name.node attr (@class_name.node -> @method_name.node) kind = "MEMBER" @@ -306,8 +288,7 @@ name: (_) @class_name body: (field_declaration_list (function_definition - declarator: (function_declarator - declarator: (_) @method_name))))) + declarator: (_) @method_name)))) { edge @class_name.node -> @method_name.node attr (@class_name.node -> @method_name.node) kind = "MEMBER" diff --git a/crates/codestory-indexer/rules/javascript.scm b/crates/codestory-indexer/rules/javascript.scm index 5ea4b6cd1..ffb55b3fe 100644 --- a/crates/codestory-indexer/rules/javascript.scm +++ b/crates/codestory-indexer/rules/javascript.scm @@ -1,5 +1,7 @@ -(function_declaration - name: (identifier) @name) @def +[ + (function_declaration name: (identifier) @name) + (generator_function_declaration name: (identifier) @name) +] @def { node @name.node attr (@name.node) kind = "FUNCTION" @@ -190,10 +192,11 @@ attr (@binding_name.node -> @module.node) kind = "IMPORT" } -;; Lambda assignment +;; Callable variable bindings. The expression's optional private name is not +;; a second declaration in the enclosing scope. (variable_declarator name: (identifier) @name - value: (arrow_function) @def) + value: [(arrow_function) (function_expression) (generator_function)] @def) { node @name.node attr (@name.node) kind = "FUNCTION" diff --git a/crates/codestory-indexer/rules/tsx.graph.scm b/crates/codestory-indexer/rules/tsx.graph.scm index d6339c1f2..78c64808f 100644 --- a/crates/codestory-indexer/rules/tsx.graph.scm +++ b/crates/codestory-indexer/rules/tsx.graph.scm @@ -1,5 +1,7 @@ -(function_declaration - name: (identifier) @name) @def +[ + (function_declaration name: (identifier) @name) + (generator_function_declaration name: (identifier) @name) +] @def { node @name.node attr (@name.node) kind = "FUNCTION" @@ -387,23 +389,10 @@ attr (@binding_name.node -> @module.node) kind = "IMPORT" } -;; Lambda assignment -(variable_declarator - name: (identifier) @name - value: (arrow_function) @def) -{ - node @name.node - attr (@name.node) kind = "FUNCTION" - attr (@name.node) name = (source-text @name) - attr (@name.node) start_row = (start-row @def) - attr (@name.node) start_col = (start-column @def) - attr (@name.node) end_row = (end-row @def) - attr (@name.node) end_col = (end-column @def) -} - +;; Callable variable bindings (variable_declarator name: (identifier) @name - value: (function_expression) @def) + value: [(arrow_function) (function_expression) (generator_function)] @def) { node @name.node attr (@name.node) kind = "FUNCTION" diff --git a/crates/codestory-indexer/rules/typescript.graph.scm b/crates/codestory-indexer/rules/typescript.graph.scm index d2ecbde53..dd513447b 100644 --- a/crates/codestory-indexer/rules/typescript.graph.scm +++ b/crates/codestory-indexer/rules/typescript.graph.scm @@ -1,5 +1,7 @@ -(function_declaration - name: (identifier) @name) @def +[ + (function_declaration name: (identifier) @name) + (generator_function_declaration name: (identifier) @name) +] @def { node @name.node attr (@name.node) kind = "FUNCTION" @@ -221,23 +223,10 @@ attr (@binding_name.node -> @module.node) kind = "IMPORT" } -;; Lambda assignment +;; Callable variable bindings (variable_declarator name: (identifier) @name - value: (arrow_function) @def) -{ - node @name.node - attr (@name.node) kind = "FUNCTION" - attr (@name.node) name = (source-text @name) - attr (@name.node) start_row = (start-row @def) - attr (@name.node) start_col = (start-column @def) - attr (@name.node) end_row = (end-row @def) - attr (@name.node) end_col = (end-column @def) -} - -(variable_declarator - name: (identifier) @name - value: (function_expression) @def) + value: [(arrow_function) (function_expression) (generator_function)] @def) { node @name.node attr (@name.node) kind = "FUNCTION" diff --git a/crates/codestory-indexer/src/cache.rs b/crates/codestory-indexer/src/cache.rs index 70fabf1bd..cbdd7424b 100644 --- a/crates/codestory-indexer/src/cache.rs +++ b/crates/codestory-indexer/src/cache.rs @@ -777,6 +777,35 @@ mod tests { assert_ne!(first, second); } + #[test] + fn parser_cache_key_invalidates_changed_callable_extraction_rules() { + for extension in ["c", "cpp", "js", "ts", "tsx"] { + let config = crate::get_language_for_ext(extension).expect("parser config"); + let key = |config: &crate::LanguageConfig| { + build_index_artifact_cache_key( + Path::new("project"), + Path::new("source"), + b"unchanged source", + config, + None, + false, + true, + ) + .expect("portable cache key") + }; + let current = key(&config); + let old_config = crate::LanguageConfig { + graph_query: "(older_callable_rule)", + ..config + }; + assert_ne!( + current, + key(&old_config), + "{extension} must not reuse the old projection" + ); + } + } + #[test] fn test_artifact_cache_key_skips_unportable_compile_paths() -> anyhow::Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/codestory-indexer/src/lib.rs b/crates/codestory-indexer/src/lib.rs index b521fbc0f..44b1c44ab 100644 --- a/crates/codestory-indexer/src/lib.rs +++ b/crates/codestory-indexer/src/lib.rs @@ -87,6 +87,7 @@ mod framework_routes; pub mod intermediate_storage; mod language_configs; mod languages; +mod native_declarators; mod proof_resolution; /// SRC-C2 fence classification: lives in its own file because @@ -5050,7 +5051,7 @@ struct ManualPreciseCallSpec { line: Option, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct GraphNodeSpan { start_line: u32, start_col: u32, @@ -15793,6 +15794,8 @@ fn index_file_with_resolution_inputs( let mut tag_definitions = extract_tag_definitions(compiled_rules, &tree, source)?; let declaration_span_overrides = collect_declaration_span_overrides(language_config.language_name, &tree, source); + let native_callable_names = + native_declarators::callable_names(language_config.language_name, &tree, source); let mut variables = Variables::new(); if let Some(info) = &compilation_info { @@ -15948,6 +15951,21 @@ fn index_file_with_resolution_inputs( let mut start_col_1 = start_col.map(|v| v + 1).unwrap_or(1); let mut end_line_1 = end_row.map(|v| v + 1).unwrap_or(start_line); let mut end_col_1 = end_col.map(|v| v + 1).unwrap_or(start_col_1); + if matches!(language_config.language_name, "c" | "cpp") && kind == NodeKind::FUNCTION { + let span = GraphNodeSpan { + start_line, + start_col: start_col_1, + end_line: end_line_1, + end_col: end_col_1, + }; + if let Some(name) = native_callable_names.get(&span) { + // A missing name on a definition is unknown. Other + // function-shaped captures (prototypes, lambdas) retain + // their own syntax-specific extraction. + let Some(name) = name else { continue }; + name_str.clone_from(name); + } + } if let Some(( normalized_name, normalized_start_line, diff --git a/crates/codestory-indexer/src/native_declarators.rs b/crates/codestory-indexer/src/native_declarators.rs new file mode 100644 index 000000000..ea0371312 --- /dev/null +++ b/crates/codestory-indexer/src/native_declarators.rs @@ -0,0 +1,60 @@ +//! Name native function definitions by their declarator chain, independently of +//! pointer depth. Parameters and unrelated descendants cannot supply the name. + +use std::collections::HashMap; +use tree_sitter::{Node, Tree}; + +use crate::{GraphNodeSpan, ts_node_graph_span, walk_tree_nodes}; + +pub(crate) fn callable_names( + language: &str, + tree: &Tree, + source: &str, +) -> HashMap> { + let mut names = HashMap::new(); + if !matches!(language, "c" | "cpp") { + return names; + } + walk_tree_nodes(tree.root_node(), &mut |definition| { + if definition.kind() != "function_definition" { + return; + } + let name = definition + .child_by_field_name("declarator") + .and_then(declarator_name) + .and_then(|node| node.utf8_text(source.as_bytes()).ok()) + .map(str::to_string); + names.insert(ts_node_graph_span(definition), name); + }); + names +} + +fn declarator_name(mut node: Node<'_>) -> Option> { + loop { + match node.kind() { + "identifier" + | "field_identifier" + | "qualified_identifier" + | "destructor_name" + | "operator_name" => return Some(node), + "function_declarator" | "pointer_declarator" | "array_declarator" => { + node = node.child_by_field_name("declarator")?; + } + "parenthesized_declarator" | "reference_declarator" | "attributed_declarator" => { + // These grammar wrappers do not name a `declarator` field. + // Only the sole non-attribute child can carry the declaration; + // never search arbitrary descendants or parameter lists. + let mut cursor = node.walk(); + let mut children = node + .named_children(&mut cursor) + .filter(|child| !matches!(child.kind(), "comment" | "attribute_declaration")); + let child = children.next()?; + if children.next().is_some() { + return None; + } + node = child; + } + _ => return None, + } + } +} diff --git a/crates/codestory-indexer/tests/callable_definition_fidelity.rs b/crates/codestory-indexer/tests/callable_definition_fidelity.rs new file mode 100644 index 000000000..3a93e7b0a --- /dev/null +++ b/crates/codestory-indexer/tests/callable_definition_fidelity.rs @@ -0,0 +1,244 @@ +use codestory_contracts::graph::{EdgeKind, NodeKind}; +use codestory_indexer::{get_language_for_ext, index_file}; +use std::path::Path; + +const NATIVE_FORMS: &[(&str, &str)] = &[ + ("scalar", "int TOKEN(int x) {\n return x + 1;\n}\n"), + ("pointer", "int *TOKEN(int *x) {\n return x;\n}\n"), + ("nested_pointer", "int **TOKEN(int **x) {\n return x;\n}\n"), + ( + "qualified_pointer", + "static const int *TOKEN(const int *x) {\n return x;\n}\n", + ), + ("parenthesized", "int (TOKEN)(int x) {\n return x;\n}\n"), + ( + "returned_callable", + "int (*TOKEN(void))(int) {\n return 0;\n}\n", + ), +]; + +const SCRIPT_FORMS: &[(&str, &str)] = &[ + ( + "declaration", + "function TOKEN(x) {\n return x + 1;\n}\nTOKEN(1);\n", + ), + ( + "arrow", + "const TOKEN = (x) => {\n return x + 1;\n};\nTOKEN(1);\n", + ), + ( + "named_expression", + "var TOKEN = function privateName(x) {\n return x + 1;\n};\nTOKEN(1);\n", + ), + ( + "anonymous_expression", + "const TOKEN = function(x) {\n return x + 1;\n};\nTOKEN(1);\n", + ), + ( + "async_expression", + "let TOKEN = async function(x) {\n return x + 1;\n};\nTOKEN(1);\n", + ), + ( + "generator_expression", + "const TOKEN = function*(x) {\n yield x + 1;\n};\nTOKEN(1);\n", + ), + ( + "generator_declaration", + "function* TOKEN(x) {\n yield x + 1;\n}\nTOKEN(1);\n", + ), + ( + "async_generator_expression", + "const TOKEN = async function*(x) {\n yield x + 1;\n};\nTOKEN(1);\n", + ), +]; + +fn assert_definition_matrix(extensions: &[&str], forms: &[(&str, &str)]) { + let mut failures = Vec::new(); + for extension in extensions { + let config = get_language_for_ext(extension).expect("supported parser"); + for (form, template) in forms { + for (name, prefix, directory) in [ + ("pebble", "", "alpha"), + ("opaque_942", "// shifted source\n\n", "unrelated"), + ] { + let source = format!("{prefix}{}", template.replace("TOKEN", name)); + let filename = format!("{directory}/fixture.{extension}"); + let result = index_file(Path::new(&filename), &source, &config, None, None) + .expect("index valid source"); + assert!( + result.files.iter().all(|file| file.complete), + "{filename} {form}" + ); + let start = prefix.lines().count() as u32 + 1; + let definitions = result + .nodes + .iter() + .filter(|node| { + node.kind == NodeKind::FUNCTION + && node.serialized_name == name + && node.start_line == Some(start) + && node.end_line == Some(start + 2) + }) + .count(); + if definitions != 1 { + failures.push(format!("{extension}/{form}/{name}: expected one complete definition, got {definitions}; nodes={:?}", result.nodes)); + } + assert!( + !result.nodes.iter().any(|node| { + node.kind == NodeKind::FUNCTION && node.serialized_name == "privateName" + }), + "a named expression's private name must not become an unscoped alias" + ); + } + } + } + assert!( + failures.is_empty(), + "{} definition failures:\n{}", + failures.len(), + failures.join("\n") + ); +} + +#[test] +fn native_definitions_follow_nested_declarators() { + assert_definition_matrix(&["c", "cpp"], NATIVE_FORMS); +} + +#[test] +fn script_definitions_follow_callable_bindings() { + assert_definition_matrix(&["js", "ts", "tsx"], SCRIPT_FORMS); +} + +#[test] +fn non_callable_names_do_not_become_definitions() { + for extension in ["c", "cpp", "js", "ts", "tsx"] { + let source = if matches!(extension, "c" | "cpp") { + "int value;\nint (*callback)(int parameter);\nint owner(int parameter) {\n if (parameter) { return callback(parameter); }\n return value;\n}\n" + } else { + "const value = 1;\nfunction owner(parameter) {\n if (parameter) { return callback(parameter); }\n return value;\n}\n" + }; + let config = get_language_for_ext(extension).expect("supported parser"); + let filename = format!("negative.{extension}"); + let result = index_file(Path::new(&filename), source, &config, None, None).expect("index"); + let names = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::FUNCTION) + .map(|node| node.serialized_name.as_str()) + .collect::>(); + assert_eq!( + names, + ["owner"], + "{extension} must not promote parameters, variables, or calls" + ); + } +} + +#[test] +fn native_definition_names_do_not_depend_on_pointer_depth() { + for extension in ["c", "cpp"] { + let config = get_language_for_ext(extension).expect("native parser"); + for depth in [1, 2, 8, 32] { + let pointers = "*".repeat(depth); + let source = + format!("int {pointers}pebble(int {pointers}argument) {{\n return argument;\n}}\n"); + let filename = format!("depth.{extension}"); + let result = + index_file(Path::new(&filename), &source, &config, None, None).expect("index"); + let names = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::FUNCTION) + .map(|node| node.serialized_name.as_str()) + .collect::>(); + assert_eq!(names, ["pebble"], "{extension} pointer depth {depth}"); + } + } +} + +#[test] +fn native_non_definition_callables_keep_their_existing_projection() { + for (extension, source, name) in [ + ("c", "int pebble(int argument);\n", "pebble"), + ( + "cpp", + "auto pebble = [](int argument) { return argument; };\n", + "pebble", + ), + ] { + let config = get_language_for_ext(extension).expect("native parser"); + let filename = format!("existing.{extension}"); + let result = index_file(Path::new(&filename), source, &config, None, None).expect("index"); + assert!( + result + .nodes + .iter() + .any(|node| node.kind == NodeKind::FUNCTION && node.serialized_name == name), + "{extension}: {:?}", + result.nodes + ); + } +} + +#[test] +fn cpp_declarators_keep_reference_and_qualified_names() { + let forms = [ + ("int &pebble(int &x) { return x; }", "pebble"), + ( + "int &&pebble(int &&x) { return static_cast(x); }", + "pebble", + ), + ( + "struct Stone { int &pebble(int &x) { return x; } };", + "Stone::pebble", + ), + ("struct Stone { ~Stone() {} };", "Stone::~Stone"), + ( + "struct Stone { int operator()(int x) { return x; } };", + "Stone::operator()", + ), + ]; + let config = get_language_for_ext("cpp").expect("C++ parser"); + let mut failures = Vec::new(); + for (source, name) in forms { + let result = + index_file(Path::new("forms.cpp"), source, &config, None, None).expect("index"); + assert!(result.files.iter().all(|file| file.complete)); + if !result + .nodes + .iter() + .any(|node| node.kind == NodeKind::FUNCTION && node.serialized_name == name) + { + failures.push(format!("{source}: missing {name}; {:?}", result.nodes)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn native_member_edges_keep_the_definition_identity() { + let source = "namespace sample {\nstruct Holder {\n int **pick(int **argument) {\n return argument;\n }\n};\nint **loose(int **argument) {\n return argument;\n}\n}\n"; + let config = get_language_for_ext("cpp").expect("C++ parser"); + let result = index_file(Path::new("members.cpp"), source, &config, None, None).expect("index"); + for (owner, member, start, end) in [("Holder", "Holder::pick", 3, 5), ("sample", "loose", 7, 9)] + { + let owner = result + .nodes + .iter() + .find(|node| node.serialized_name == owner) + .expect("owner"); + let target = result + .nodes + .iter() + .find(|node| node.serialized_name == member && node.kind == NodeKind::FUNCTION) + .unwrap_or_else(|| panic!("missing {member} definition: {:?}", result.nodes)); + assert_eq!( + (target.start_line, target.end_line), + (Some(start), Some(end)) + ); + assert!(result.edges.iter().any(|edge| edge.kind == EdgeKind::MEMBER + && edge.source == owner.id + && edge.target == target.id)); + } +} diff --git a/crates/codestory-indexer/tests/fixtures/language_snapshots/cpp_tictactoe.txt b/crates/codestory-indexer/tests/fixtures/language_snapshots/cpp_tictactoe.txt index ef2a27180..4a40c6268 100644 --- a/crates/codestory-indexer/tests/fixtures/language_snapshots/cpp_tictactoe.txt +++ b/crates/codestory-indexer/tests/fixtures/language_snapshots/cpp_tictactoe.txt @@ -42,6 +42,7 @@ node FUNCTION name=HumanPlayer::check qn=HumanPlayer::check canonical=game.cpp:H node FUNCTION name=HumanPlayer::input qn=HumanPlayer::input canonical=game.cpp:HumanPlayer::input#0 line=159 col=5 end=161:6 file=FILE:game.cpp@1 node FUNCTION name=HumanPlayer::turn qn=HumanPlayer::turn canonical=game.cpp:HumanPlayer::turn#0 line=148 col=5 end=156:6 file=FILE:game.cpp@1 node FUNCTION name=Player::Player qn=Player::Player canonical=game.cpp:Player::Player#0 line=126 col=5 end=126:92 file=FILE:game.cpp@1 +node FUNCTION name=Player::name qn=Player::name canonical=game.cpp:Player::name#0 line=135 col=5 end=137:6 file=FILE:game.cpp@1 node FUNCTION name=Player::token qn=Player::token canonical=game.cpp:Player::token#0 line=131 col=5 end=133:6 file=FILE:game.cpp@1 node FUNCTION name=Player::~Player qn=Player::~Player canonical=game.cpp:Player::~Player#0 line=127 col=5 end=127:33 file=FILE:game.cpp@1 node FUNCTION name=TicTacToe::TicTacToe qn=TicTacToe::TicTacToe canonical=game.cpp:TicTacToe::TicTacToe#0 line=241 col=5 end=241:27 file=FILE:game.cpp@1 @@ -153,15 +154,15 @@ edge CALL src=FUNCTION:TicTacToe::check_winner@249 dst=FUNCTION:Field::same_in_r edge CALL src=FUNCTION:TicTacToe::check_winner@249 dst=FUNCTION:Player::token@131 resolved_src=- resolved_dst=FUNCTION:Player::token@131 line=250 confidence=1.0000 certainty=Certain callsite=h0:250:1:h29 candidates=[] edge CALL src=FUNCTION:TicTacToe::is_draw@253 dst=FUNCTION:Field::is_draw@64 resolved_src=- resolved_dst=FUNCTION:Field::is_draw@64 line=254 confidence=1.0000 certainty=Certain callsite=h0:254:1:h4 candidates=[] edge CALL src=FUNCTION:TicTacToe::run@257 dst=FUNCTION:Field::make_move@90 resolved_src=- resolved_dst=FUNCTION:Field::make_move@90 line=262 confidence=1.0000 certainty=Certain callsite=h0:262:1:h6 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=FUNCTION:Player::name@135 resolved_src=- resolved_dst=FUNCTION:Player::name@135 line=267 confidence=1.0000 certainty=Certain callsite=h0:267:1:h30 candidates=[] edge CALL src=FUNCTION:TicTacToe::run@257 dst=FUNCTION:Player::token@131 resolved_src=- resolved_dst=FUNCTION:Player::token@131 line=262 confidence=1.0000 certainty=Certain callsite=h0:262:1:h29 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=METHOD:Player::turn@129 resolved_src=- resolved_dst=METHOD:Player::turn@129 line=261 confidence=1.0000 certainty=Certain callsite=h0:261:1:h30 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:announce@266 resolved_src=- resolved_dst=- line=266 confidence=- certainty=- callsite=h0:266:1:h31 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:announce@271 resolved_src=- resolved_dst=- line=271 confidence=- certainty=- callsite=h0:271:1:h32 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:check_winner@263 resolved_src=- resolved_dst=- line=263 confidence=- certainty=- callsite=h0:263:1:h33 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:check_winner@265 resolved_src=- resolved_dst=- line=265 confidence=- certainty=- callsite=h0:265:1:h34 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:is_draw@264 resolved_src=- resolved_dst=- line=264 confidence=- certainty=- callsite=h0:264:1:h35 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:is_draw@270 resolved_src=- resolved_dst=- line=270 confidence=- certainty=- callsite=h0:270:1:h36 candidates=[] -edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:name@267 resolved_src=- resolved_dst=- line=267 confidence=- certainty=- callsite=h0:267:1:h37|syntax:cpp-member-call candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=METHOD:Player::turn@129 resolved_src=- resolved_dst=METHOD:Player::turn@129 line=261 confidence=1.0000 certainty=Certain callsite=h0:261:1:h31 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:announce@266 resolved_src=- resolved_dst=- line=266 confidence=- certainty=- callsite=h0:266:1:h32 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:announce@271 resolved_src=- resolved_dst=- line=271 confidence=- certainty=- callsite=h0:271:1:h33 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:check_winner@263 resolved_src=- resolved_dst=- line=263 confidence=- certainty=- callsite=h0:263:1:h34 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:check_winner@265 resolved_src=- resolved_dst=- line=265 confidence=- certainty=- callsite=h0:265:1:h35 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:is_draw@264 resolved_src=- resolved_dst=- line=264 confidence=- certainty=- callsite=h0:264:1:h36 candidates=[] +edge CALL src=FUNCTION:TicTacToe::run@257 dst=UNKNOWN:is_draw@270 resolved_src=- resolved_dst=- line=270 confidence=- certainty=- callsite=h0:270:1:h37 candidates=[] edge CALL src=FUNCTION:TicTacToe::start@243 dst=UNKNOWN:make_unique@244 resolved_src=- resolved_dst=- line=244 confidence=- certainty=- callsite=h0:244:1:h38 candidates=[] edge CALL src=FUNCTION:TicTacToe::start@243 dst=UNKNOWN:make_unique@245 resolved_src=- resolved_dst=- line=245 confidence=- certainty=- callsite=h0:245:1:h39 candidates=[] edge CALL src=FUNCTION:main@290 dst=FUNCTION:TicTacToe::run@257 resolved_src=- resolved_dst=FUNCTION:TicTacToe::run@257 line=294 confidence=1.0000 certainty=Certain callsite=h0:294:1:h40 candidates=[] @@ -209,6 +210,7 @@ edge MEMBER src=CLASS:Node@178 dst=FIELD:Node::value@180 resolved_src=- resolved edge MEMBER src=CLASS:Player@124 dst=FIELD:Player::name_@141 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] edge MEMBER src=CLASS:Player@124 dst=FIELD:Player::token_@140 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] edge MEMBER src=CLASS:Player@124 dst=FUNCTION:Player::Player@126 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] +edge MEMBER src=CLASS:Player@124 dst=FUNCTION:Player::name@135 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] edge MEMBER src=CLASS:Player@124 dst=FUNCTION:Player::token@131 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] edge MEMBER src=CLASS:Player@124 dst=FUNCTION:Player::~Player@127 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] edge MEMBER src=CLASS:Player@124 dst=METHOD:Player::turn@129 resolved_src=- resolved_dst=- line=0 confidence=- certainty=Certain callsite=- candidates=[] @@ -271,6 +273,7 @@ occurrence element=FUNCTION:HumanPlayer::check@163 kind=DEFINITION span=163:5-17 occurrence element=FUNCTION:HumanPlayer::input@159 kind=DEFINITION span=159:5-161:6 occurrence element=FUNCTION:HumanPlayer::turn@148 kind=DEFINITION span=148:5-156:6 occurrence element=FUNCTION:Player::Player@126 kind=DEFINITION span=126:5-126:92 +occurrence element=FUNCTION:Player::name@135 kind=DEFINITION span=135:5-137:6 occurrence element=FUNCTION:Player::token@131 kind=DEFINITION span=131:5-133:6 occurrence element=FUNCTION:Player::~Player@127 kind=DEFINITION span=127:5-127:33 occurrence element=FUNCTION:TicTacToe::TicTacToe@241 kind=DEFINITION span=241:5-241:27 @@ -370,14 +373,15 @@ callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_k callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:HumanPlayer::input", node_id: NodeId(-8945466076844998251), signature_hash: 6160290327990667243, normalized_signature: Some("outline:-1795207361013140379"), body_hash: -7422102815381339974, start_line: 159, end_line: 161 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:HumanPlayer::turn", node_id: NodeId(3535214140666598154), signature_hash: -306840086604760518, normalized_signature: Some("shape:13251784612181120"), body_hash: 4409353235455712035, start_line: 148, end_line: 156 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:Player::Player", node_id: NodeId(-6707452965757007259), signature_hash: 4550575562027393611, normalized_signature: Some("shape:-1825518039466535303"), body_hash: 1233368089821784249, start_line: 126, end_line: 126 } +callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:Player::name", node_id: NodeId(-8564285562047925225), signature_hash: 2337928819342862641, normalized_signature: Some("outline:-1795207361013140379"), body_hash: -535932332243262681, start_line: 135, end_line: 137 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:Player::token", node_id: NodeId(2297597260245398927), signature_hash: -4748688299950841463, normalized_signature: Some("outline:-1795207361013140379"), body_hash: 1420159333204647503, start_line: 131, end_line: 133 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:Player::~Player", node_id: NodeId(5261213272621912777), signature_hash: 4092509602493783551, normalized_signature: Some("outline:-1793518511152586733"), body_hash: -16421324180773348, start_line: 127, end_line: 127 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::TicTacToe", node_id: NodeId(8436570454300766505), signature_hash: 6289965068366920095, normalized_signature: Some("outline:-1793518511152586733"), body_hash: 3499807729898560125, start_line: 241, end_line: 241 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::check_winner", node_id: NodeId(2059636451327768343), signature_hash: -8165208525270157247, normalized_signature: Some("shape:3685199810936236209"), body_hash: 2436777956544700027, start_line: 249, end_line: 251 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::is_draw", node_id: NodeId(-1332650651018566938), signature_hash: -3877471890058058, normalized_signature: Some("shape:6537664411452163601"), body_hash: -1935880710548064224, start_line: 253, end_line: 255 } -callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::run", node_id: NodeId(1168160839341222100), signature_hash: -1971120529097408000, normalized_signature: Some("shape:4166757559705214656"), body_hash: -8150260786177582825, start_line: 257, end_line: 277 } +callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::run", node_id: NodeId(1168160839341222100), signature_hash: -1971120529097408000, normalized_signature: Some("shape:-6502356988073650291"), body_hash: -4340161893706793605, start_line: 257, end_line: 277 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:TicTacToe::start", node_id: NodeId(-1627490076889452683), signature_hash: -7294090493278946021, normalized_signature: Some("shape:-7970075957712933318"), body_hash: -7667770342375933407, start_line: 243, end_line: 247 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:main", node_id: NodeId(-3541657835264805444), signature_hash: -4671339478033693080, normalized_signature: Some("shape:5768144545723247670"), body_hash: -4771657560400400094, start_line: 290, end_line: 297 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "13:probe_check_winner", node_id: NodeId(3899195334577589324), signature_hash: 8230045360579097032, normalized_signature: Some("shape:-5209275596118422264"), body_hash: -7020805036339482730, start_line: 284, end_line: 288 } callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "14:Player::turn", node_id: NodeId(1822818414193016855), signature_hash: -7658741235038170420, normalized_signature: Some("outline:-5083272169020481154"), body_hash: 4135080381464109188, start_line: 129, end_line: 129 } -callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "__file_structural__", node_id: NodeId(-8301684766251967502), signature_hash: 5816621205998537519, normalized_signature: None, body_hash: 2585227465145706207, start_line: 1, end_line: 297 } +callable_state CallableProjectionState { file_id: -8301684766251967502, symbol_key: "__file_structural__", node_id: NodeId(-8301684766251967502), signature_hash: -8566521835288987629, normalized_signature: None, body_hash: -157048179483698159, start_line: 1, end_line: 297 } diff --git a/crates/codestory-indexer/tests/fixtures/language_snapshots/tsx_tictactoe.txt b/crates/codestory-indexer/tests/fixtures/language_snapshots/tsx_tictactoe.txt index d51cb1a46..3b7b13e56 100644 --- a/crates/codestory-indexer/tests/fixtures/language_snapshots/tsx_tictactoe.txt +++ b/crates/codestory-indexer/tests/fixtures/language_snapshots/tsx_tictactoe.txt @@ -225,7 +225,6 @@ callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_ke callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:Row", node_id: NodeId(-6889767165843046629), signature_hash: -6253407580123594297, normalized_signature: Some("shape:6504831437327793549"), body_hash: 8949892768696320399, start_line: 35, end_line: 43 } callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:button", node_id: NodeId(-4348506163033861125), signature_hash: -9120599125457830493, normalized_signature: Some("shape:-3759517183692158321"), body_hash: 2012192245544887352, start_line: 29, end_line: 29 } callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:div", node_id: NodeId(-2809348445563293116), signature_hash: -935513631673984014, normalized_signature: Some("outline:-1793518511152586733"), body_hash: -8888091842825138237, start_line: 37, end_line: 37 } -callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:main", node_id: NodeId(4616488697868602066), signature_hash: -4671339478033693080, normalized_signature: Some("shape:-1550258063606445053"), body_hash: 856532883053763838, start_line: 161, end_line: 164 } callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:main", node_id: NodeId(4616489797380230277), signature_hash: -4671339478033693080, normalized_signature: Some("shape:5983920156585348712"), body_hash: -2287360435944530861, start_line: 153, end_line: 153 } callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "13:tokenLabel", node_id: NodeId(2951362405926791418), signature_hash: 2453353863296413412, normalized_signature: Some("outline:-1800836860548445349"), body_hash: -4156896032434162540, start_line: 17, end_line: 25 } callable_state CallableProjectionState { file_id: 4615304419513614810, symbol_key: "14:ArtificialPlayer.evaluate", node_id: NodeId(881930982258325029), signature_hash: -3339369090062122330, normalized_signature: Some("shape:-554031375901977189"), body_hash: -164477342352592581, start_line: 101, end_line: 106 } From 8c5772763aff614190518da0c484a4a93f794c2c Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 4 Sep 2026 01:07:30 -0400 Subject: [PATCH 2/4] preserve declarator forms and private call scope --- CHANGELOG.md | 3 +- crates/codestory-indexer/src/cache.rs | 42 ++++++++++ .../src/languages/javascript.rs | 50 +++++++++-- crates/codestory-indexer/src/lib.rs | 44 ++++++++-- .../src/native_declarators.rs | 10 ++- .../src/resolution/candidate_selection.rs | 7 ++ .../codestory-indexer/src/resolution/mod.rs | 10 +++ .../tests/callable_definition_fidelity.rs | 50 +++++++++++ .../tests/query_rule_regressions.rs | 83 +++++++++++++++++++ 9 files changed, 282 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef7e0ea0..76b333761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ - Pointer-returning C and C++ functions and variable-bound JavaScript, TypeScript, and TSX functions and generators retain their full definitions - in the source index. + in the source index. Calls through a function expression's private name no + longer resolve to an unrelated outer function with the same name. - Malformed text configuration files no longer block the entire repository index. Their source remains visible with an explicit coverage gap; stale diff --git a/crates/codestory-indexer/src/cache.rs b/crates/codestory-indexer/src/cache.rs index cbdd7424b..07ef52135 100644 --- a/crates/codestory-indexer/src/cache.rs +++ b/crates/codestory-indexer/src/cache.rs @@ -478,6 +478,15 @@ pub(crate) fn build_index_artifact_cache_key( mix_str(&mut state, language_config.language_name); mix_str(&mut state, language_config.graph_query); mix_optional_str(&mut state, language_config.tags_query); + // Rust-side callable identity/scope extraction changed independently of the + // graph rules. Invalidate affected languages without discarding unrelated + // parser artifacts or changing the shared cache serialization schema. + if matches!( + language_config.language_name, + "c" | "cpp" | "javascript" | "typescript" + ) { + mix_str(&mut state, "callable-identity-and-scope-v2"); + } mix_bool(&mut state, legacy_edge_identity); mix_bool(&mut state, lazy_graph_execution); mix_compilation_info(&mut state, root, compilation_info)?; @@ -806,6 +815,39 @@ mod tests { } } + #[test] + fn callable_scope_cache_revision_is_limited_to_affected_languages() { + for extension in ["c", "cpp", "js", "ts", "tsx", "rs", "py"] { + let config = crate::get_language_for_ext(extension).expect("parser config"); + let root = Path::new("project"); + let cache_path = Path::new("source"); + let source = b"unchanged source"; + // Reconstruct the previously shipped key, with identical grammar + // and source bytes, to test Rust-side extraction invalidation. + let mut previous = FNV_OFFSET_BASIS; + mix_str(&mut previous, "index-artifact"); + mix_u32(&mut previous, INDEX_ARTIFACT_CACHE_VERSION); + mix_path(&mut previous, cache_path).expect("portable path"); + mix_bytes(&mut previous, source); + mix_str(&mut previous, config.language_name); + mix_str(&mut previous, config.graph_query); + mix_optional_str(&mut previous, config.tags_query); + mix_bool(&mut previous, false); + mix_bool(&mut previous, true); + mix_compilation_info(&mut previous, root, None).expect("portable config"); + let previous = format!("v{INDEX_ARTIFACT_CACHE_VERSION}:{previous:016x}"); + let current = build_index_artifact_cache_key( + root, cache_path, source, &config, None, false, true, + ) + .expect("cache key"); + assert_eq!( + current == previous, + matches!(extension, "rs" | "py"), + "{extension}" + ); + } + } + #[test] fn test_artifact_cache_key_skips_unportable_compile_paths() -> anyhow::Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/codestory-indexer/src/languages/javascript.rs b/crates/codestory-indexer/src/languages/javascript.rs index cdc5e51a2..15155fcdb 100644 --- a/crates/codestory-indexer/src/languages/javascript.rs +++ b/crates/codestory-indexer/src/languages/javascript.rs @@ -42,11 +42,12 @@ use super::typescript::{ collect_typescript_imported_type_bindings, typescript_property_belongs_to_owner, }; use crate::{ - CompiledLanguageRules, ImportedTypeBinding, LanguageRuleset, ManualReceiverCallSpec, - ManualReceiverSource, OptionalReceiverOwnerBinding, ReceiverCallSiteKey, ReceiverOwnerBinding, - collect_receiver_call_specs_in_callable, declaration_name, enclosing_node_with_kind, - javascript_binding_has_prior_write, js_like_callable_source_name, - js_ts_local_binding_visible_at_call, js_ts_visible_local_type_name, member_call_method_col, + CompiledLanguageRules, GraphNodeSpan, ImportedTypeBinding, LanguageRuleset, + ManualReceiverCallSpec, ManualReceiverSource, OptionalReceiverOwnerBinding, + ReceiverCallSiteKey, ReceiverOwnerBinding, collect_receiver_call_specs_in_callable, + declaration_name, enclosing_node_with_kind, javascript_binding_has_prior_write, + js_like_callable_source_name, js_ts_local_binding_visible_at_call, + js_ts_visible_local_type_name, member_call_method_col, normalize_js_ts_private_receiver_surface, normalize_parameter_name, normalized_receiver_variable, receiver_call_belongs_to_callable, receiver_callsite_key, same_ts_span, trimmed_node_text, ts_node_graph_span, walk_tree_nodes, @@ -59,6 +60,45 @@ pub(crate) const MEMBER_CALLSITE_MARKER: &str = "syntax:js-member-call"; /// Callsite marker for a bare call whose exact local name is also a runtime import binding. pub(crate) const RUNTIME_IMPORT_CALLSITE_MARKER: &str = "syntax:js-runtime-import-call"; +/// A named expression's private binding has no unscoped lookup authority. +pub(crate) const PRIVATE_NAME_CALLSITE_MARKER: &str = "syntax:js-private-name-call"; + +pub(crate) fn private_name_call_spans(tree: &Tree, source: &str) -> HashSet { + let mut spans = HashSet::new(); + walk_tree_nodes(tree.root_node(), &mut |call| { + if call.kind() != "call_expression" { + return; + } + let Some(target) = call + .child_by_field_name("function") + .filter(|target| target.kind() == "identifier") + else { + return; + }; + let Some(name) = trimmed_node_text(target, source) else { + return; + }; + let mut ancestor = call.parent(); + while let Some(scope) = ancestor { + if matches!(scope.kind(), "function_expression" | "generator_function") + && scope + .child_by_field_name("name") + .and_then(|binding| trimmed_node_text(binding, source)) + .as_deref() + == Some(name.as_str()) + { + // Nested callable bodies still close over this private name. + // Without a complete binding resolution, do not guess at a + // same-spelled outer declaration, parameter, or local shadow. + spans.insert(ts_node_graph_span(target)); + break; + } + ancestor = scope.parent(); + } + }); + spans +} + const GRAPH_QUERY: &str = include_str!("../../rules/javascript.scm"); static RULES: OnceLock> = OnceLock::new(); diff --git a/crates/codestory-indexer/src/lib.rs b/crates/codestory-indexer/src/lib.rs index 44b1c44ab..5f3876a3a 100644 --- a/crates/codestory-indexer/src/lib.rs +++ b/crates/codestory-indexer/src/lib.rs @@ -10612,8 +10612,26 @@ fn annotate_exact_runtime_import_bare_calls( let exact_target_spans = specs .iter() .flat_map(|spec| spec.exact_bare_call_target_spans.iter()) - .map(|span| (span.start_line, span.start_col, span.end_line, span.end_col)) + .copied() .collect::>(); + annotate_callsite_target_spans( + &exact_target_spans, + languages::javascript::RUNTIME_IMPORT_CALLSITE_MARKER, + unique_nodes, + edges, + edge_keys, + flags, + ); +} + +fn annotate_callsite_target_spans( + exact_target_spans: &HashSet, + marker: &'static str, + unique_nodes: &HashMap, + edges: &mut [Edge], + edge_keys: &mut HashSet, + flags: IndexFeatureFlags, +) { if exact_target_spans.is_empty() { return; } @@ -10623,19 +10641,19 @@ fn annotate_exact_runtime_import_bare_calls( || !unique_nodes .get(&edge.target) .and_then(|target| { - Some(( - target.start_line?, - target.start_col?, - target.end_line?, - target.end_col?, - )) + Some(GraphNodeSpan { + start_line: target.start_line?, + start_col: target.start_col?, + end_line: target.end_line?, + end_col: target.end_col?, + }) }) .is_some_and(|span| exact_target_spans.contains(&span)) { continue; } edge_keys.remove(&edge_dedup_key(edge, flags)); - append_callsite_marker(edge, languages::javascript::RUNTIME_IMPORT_CALLSITE_MARKER); + append_callsite_marker(edge, marker); edge.id = EdgeId(generate_edge_id_for_edge(edge, flags)); edge_keys.insert(edge_dedup_key(edge, flags)); } @@ -16351,6 +16369,16 @@ fn index_file_with_resolution_inputs( &mut edge_keys, flags, ); + if matches!(language_config.language_name, "javascript" | "typescript") { + annotate_callsite_target_spans( + &languages::javascript::private_name_call_spans(&tree, source), + languages::javascript::PRIVATE_NAME_CALLSITE_MARKER, + &unique_nodes, + &mut result_edges, + &mut edge_keys, + flags, + ); + } append_schema_endpoint_call_edges( language_config.language_name, source, diff --git a/crates/codestory-indexer/src/native_declarators.rs b/crates/codestory-indexer/src/native_declarators.rs index ea0371312..2097c0135 100644 --- a/crates/codestory-indexer/src/native_declarators.rs +++ b/crates/codestory-indexer/src/native_declarators.rs @@ -35,6 +35,7 @@ fn declarator_name(mut node: Node<'_>) -> Option> { "identifier" | "field_identifier" | "qualified_identifier" + | "template_function" | "destructor_name" | "operator_name" => return Some(node), "function_declarator" | "pointer_declarator" | "array_declarator" => { @@ -45,9 +46,12 @@ fn declarator_name(mut node: Node<'_>) -> Option> { // Only the sole non-attribute child can carry the declaration; // never search arbitrary descendants or parameter lists. let mut cursor = node.walk(); - let mut children = node - .named_children(&mut cursor) - .filter(|child| !matches!(child.kind(), "comment" | "attribute_declaration")); + let mut children = node.named_children(&mut cursor).filter(|child| { + !matches!( + child.kind(), + "comment" | "attribute_declaration" | "ms_call_modifier" + ) + }); let child = children.next()?; if children.next().is_some() { return None; diff --git a/crates/codestory-indexer/src/resolution/candidate_selection.rs b/crates/codestory-indexer/src/resolution/candidate_selection.rs index adc54fef0..7102f5369 100644 --- a/crates/codestory-indexer/src/resolution/candidate_selection.rs +++ b/crates/codestory-indexer/src/resolution/candidate_selection.rs @@ -24,6 +24,13 @@ pub(super) fn compute_call_resolution( let mut semantic_fallback = UnambiguousBestCandidate::default(); let mut candidate_ids = OrderedCandidateIds::with_capacity(8); + if is_js_private_name_call(EdgeKind::CALL, callsite_identity.as_deref()) { + return Ok(ComputedResolution { + update: build_resolved_edge_update(*edge_id, None, &[])?, + strategy: None, + }); + } + if is_python_dotted_call_placeholder(EdgeKind::CALL, callsite_identity.as_deref()) && receiver_owner.is_none() { diff --git a/crates/codestory-indexer/src/resolution/mod.rs b/crates/codestory-indexer/src/resolution/mod.rs index 8e0c96d08..59184e28f 100644 --- a/crates/codestory-indexer/src/resolution/mod.rs +++ b/crates/codestory-indexer/src/resolution/mod.rs @@ -1291,6 +1291,7 @@ fn semantic_lookup_from_row<'a>( fn semantic_request_key(lookup: &SemanticEdgeLookup<'_>) -> Option { if semantic_language_bucket(lookup.file_path).is_none() + || is_js_private_name_call(lookup.edge_kind, lookup.callsite_identity) || is_python_dotted_call_placeholder(lookup.edge_kind, lookup.callsite_identity) || is_cpp_member_call_placeholder(lookup.edge_kind, lookup.callsite_identity) || is_js_member_call_placeholder(lookup.edge_kind, lookup.callsite_identity) @@ -1318,6 +1319,15 @@ fn semantic_request_key(lookup: &SemanticEdgeLookup<'_>) -> Option) -> bool { + edge_kind == EdgeKind::CALL + && callsite_identity.is_some_and(|identity| { + identity + .split('|') + .any(|part| part == crate::languages::javascript::PRIVATE_NAME_CALLSITE_MARKER) + }) +} + fn semantic_request_target_name(lookup: &SemanticEdgeLookup<'_>) -> String { if lookup.edge_kind == EdgeKind::IMPORT && import_alias_mismatch(lookup.source_name, lookup.target_name) diff --git a/crates/codestory-indexer/tests/callable_definition_fidelity.rs b/crates/codestory-indexer/tests/callable_definition_fidelity.rs index 3a93e7b0a..9b70e097b 100644 --- a/crates/codestory-indexer/tests/callable_definition_fidelity.rs +++ b/crates/codestory-indexer/tests/callable_definition_fidelity.rs @@ -15,6 +15,14 @@ const NATIVE_FORMS: &[(&str, &str)] = &[ "returned_callable", "int (*TOKEN(void))(int) {\n return 0;\n}\n", ), + ( + "calling_convention", + "int (__stdcall TOKEN)(int x) {\n return x;\n}\n", + ), + ( + "returned_calling_convention", + "int (__cdecl *TOKEN(void))(int) {\n return 0;\n}\n", + ), ]; const SCRIPT_FORMS: &[(&str, &str)] = &[ @@ -216,6 +224,48 @@ fn cpp_declarators_keep_reference_and_qualified_names() { assert!(failures.is_empty(), "{}", failures.join("\n")); } +#[test] +fn cpp_specializations_keep_their_own_definition_identity() { + for (name, prefix, directory) in [ + ("pebble", "", "alpha"), + ("opaque_942", "// shifted\n\n", "elsewhere"), + ] { + for (open, close) in [("", ""), ("namespace opaque_space {\n", "}\n")] { + for pointer in ["", "*"] { + let source = format!( + "{prefix}{open}template T {pointer}{name}(T {pointer}x) {{ return x; }}\ntemplate<> int {pointer}{name}(int {pointer}x) {{\n return x;\n}}\n{close}" + ); + let config = get_language_for_ext("cpp").expect("C++ parser"); + let result = index_file( + Path::new(&format!("{directory}/special.cpp")), + &source, + &config, + None, + None, + ) + .expect("index"); + assert!(result.files.iter().all(|file| file.complete)); + let specialization = format!("{name}"); + let target = result + .nodes + .iter() + .find(|node| { + node.kind == NodeKind::FUNCTION + && node.serialized_name == specialization + }) + .unwrap_or_else(|| { + panic!("missing {specialization} for {source}: {:?}", result.nodes) + }); + let start = prefix.lines().count() as u32 + open.lines().count() as u32 + 2; + assert_eq!( + (target.start_line, target.end_line), + (Some(start), Some(start + 2)) + ); + } + } + } +} + #[test] fn native_member_edges_keep_the_definition_identity() { let source = "namespace sample {\nstruct Holder {\n int **pick(int **argument) {\n return argument;\n }\n};\nint **loose(int **argument) {\n return argument;\n}\n}\n"; diff --git a/crates/codestory-indexer/tests/query_rule_regressions.rs b/crates/codestory-indexer/tests/query_rule_regressions.rs index cd0685124..70dbddc10 100644 --- a/crates/codestory-indexer/tests/query_rule_regressions.rs +++ b/crates/codestory-indexer/tests/query_rule_regressions.rs @@ -35,6 +35,89 @@ fn index_project(files: &[(&str, &str)]) -> anyhow::Result<(Vec, Vec Ok((storage.get_nodes()?, storage.get_edges()?)) } +#[test] +fn script_private_callable_names_cannot_resolve_to_enclosing_definitions() -> anyhow::Result<()> { + let mut failures = Vec::new(); + for extension in ["js", "ts", "tsx"] { + for (private, directory) in [("quartz", "alpha"), ("opaque_731", "elsewhere")] { + for callable in ["function", "async function", "function*", "async function*"] { + for outer in [false, true] { + for nested in [false, true] { + let outer_source = if outer { + format!("function {private}(x) {{ return 99; }}\n") + } else { + "// no outer declaration\n".into() + }; + let invocation = format!("return {private}(x-1);"); + let body = if nested { + format!("function child() {{ {invocation} }} return child();") + } else { + invocation + }; + let source = format!( + "{outer_source}const bound = {callable} {private}(x) {{\n {body}\n}};\nfunction outside() {{ return bound(2); }}\n" + ); + let (nodes, edges) = index_project(&[( + &format!("{directory}/fixture.{extension}"), + &source, + )])?; + let calls = edges + .iter() + .filter(|edge| { + edge.kind == EdgeKind::CALL + && nodes.iter().any(|node| { + node.id == edge.target + && node.serialized_name == private + && node.start_line == Some(3) + }) + }) + .collect::>(); + if calls.is_empty() + || calls.iter().any(|edge| { + edge.resolved_target.is_some() || !edge.candidate_targets.is_empty() + }) + { + failures.push(format!("{extension}/{callable}/outer={outer}/nested={nested}: private {private} escaped its scope: {calls:?}")); + } + } + } + let source = format!( + "function {private}(x) {{ return x; }}\nconst bound = {callable}(x) {{\n return {private}(x);\n}};\nfunction outside() {{ return {private}(1); }}\n" + ); + let (nodes, edges) = + index_project(&[(&format!("{directory}/control.{extension}"), &source)])?; + let target = nodes + .iter() + .find(|node| { + node.kind == NodeKind::FUNCTION + && node.serialized_name == private + && node.start_line == Some(1) + }) + .expect("outer control"); + assert!( + edges.iter().any(|edge| edge.kind == EdgeKind::CALL + && edge.line == Some(3) + && edge.resolved_target == Some(target.id)), + "anonymous {extension}/{callable} keeps lexical outer lookup" + ); + assert!( + edges.iter().any(|edge| edge.kind == EdgeKind::CALL + && edge.line == Some(5) + && edge.resolved_target == Some(target.id)), + "outside {extension}/{callable} keeps lexical outer lookup" + ); + } + } + } + assert!( + failures.is_empty(), + "{} failures:\n{}", + failures.len(), + failures.join("\n") + ); + Ok(()) +} + fn matches_name(actual: &str, wanted: &str) -> bool { actual == wanted || actual.ends_with(&format!(".{wanted}")) From 078e58ee0d6e14ac03c3df5e1b6500b307513848 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 4 Sep 2026 01:07:53 -0400 Subject: [PATCH 3/4] format specialization assertion --- crates/codestory-indexer/tests/callable_definition_fidelity.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/codestory-indexer/tests/callable_definition_fidelity.rs b/crates/codestory-indexer/tests/callable_definition_fidelity.rs index 9b70e097b..c5899025e 100644 --- a/crates/codestory-indexer/tests/callable_definition_fidelity.rs +++ b/crates/codestory-indexer/tests/callable_definition_fidelity.rs @@ -250,8 +250,7 @@ fn cpp_specializations_keep_their_own_definition_identity() { .nodes .iter() .find(|node| { - node.kind == NodeKind::FUNCTION - && node.serialized_name == specialization + node.kind == NodeKind::FUNCTION && node.serialized_name == specialization }) .unwrap_or_else(|| { panic!("missing {specialization} for {source}: {:?}", result.nodes) From 897f3ab78a70dcd5e70cdbb4df0cb9d5d676345f Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 4 Sep 2026 01:23:54 -0400 Subject: [PATCH 4/4] bind call scope before reference deduplication --- crates/codestory-indexer/src/cache.rs | 2 +- crates/codestory-indexer/src/lib.rs | 118 +++++++----------- .../tests/call_resolution_common_methods.rs | 36 ++++++ .../tests/query_rule_regressions.rs | 74 +++++++++++ 4 files changed, 155 insertions(+), 75 deletions(-) diff --git a/crates/codestory-indexer/src/cache.rs b/crates/codestory-indexer/src/cache.rs index 07ef52135..5d3defbe6 100644 --- a/crates/codestory-indexer/src/cache.rs +++ b/crates/codestory-indexer/src/cache.rs @@ -485,7 +485,7 @@ pub(crate) fn build_index_artifact_cache_key( language_config.language_name, "c" | "cpp" | "javascript" | "typescript" ) { - mix_str(&mut state, "callable-identity-and-scope-v2"); + mix_str(&mut state, "callable-identity-and-scope-v3"); } mix_bool(&mut state, legacy_edge_identity); mix_bool(&mut state, lazy_graph_execution); diff --git a/crates/codestory-indexer/src/lib.rs b/crates/codestory-indexer/src/lib.rs index 5f3876a3a..cd4fa87f9 100644 --- a/crates/codestory-indexer/src/lib.rs +++ b/crates/codestory-indexer/src/lib.rs @@ -10602,63 +10602,6 @@ fn append_runtime_import_edges( } } -fn annotate_exact_runtime_import_bare_calls( - specs: &[RuntimeImportSpec], - unique_nodes: &HashMap, - edges: &mut [Edge], - edge_keys: &mut HashSet, - flags: IndexFeatureFlags, -) { - let exact_target_spans = specs - .iter() - .flat_map(|spec| spec.exact_bare_call_target_spans.iter()) - .copied() - .collect::>(); - annotate_callsite_target_spans( - &exact_target_spans, - languages::javascript::RUNTIME_IMPORT_CALLSITE_MARKER, - unique_nodes, - edges, - edge_keys, - flags, - ); -} - -fn annotate_callsite_target_spans( - exact_target_spans: &HashSet, - marker: &'static str, - unique_nodes: &HashMap, - edges: &mut [Edge], - edge_keys: &mut HashSet, - flags: IndexFeatureFlags, -) { - if exact_target_spans.is_empty() { - return; - } - - for edge in edges { - if edge.kind != EdgeKind::CALL - || !unique_nodes - .get(&edge.target) - .and_then(|target| { - Some(GraphNodeSpan { - start_line: target.start_line?, - start_col: target.start_col?, - end_line: target.end_line?, - end_col: target.end_col?, - }) - }) - .is_some_and(|span| exact_target_spans.contains(&span)) - { - continue; - } - edge_keys.remove(&edge_dedup_key(edge, flags)); - append_callsite_marker(edge, marker); - edge.id = EdgeId(generate_edge_id_for_edge(edge, flags)); - edge_keys.insert(edge_dedup_key(edge, flags)); - } -} - fn collect_c_enum_member_pairs(tree: &Tree, source: &str) -> Vec<(String, String)> { let mut pairs = Vec::new(); walk_tree_nodes(tree.root_node(), &mut |node| { @@ -15874,6 +15817,9 @@ fn index_file_with_resolution_inputs( // 1. First pass: Create nodes and a temporary mapping from GraphNodeId -> OurNodeId let mut graph_to_node_id = HashMap::new(); + // A canonical reference node can represent multiple same-line calls. + // Keep each graph capture's address until its individual edge is built. + let mut graph_capture_spans = HashMap::new(); let line_offsets = LineOffsets::new(source); let mut unique_nodes: HashMap = HashMap::new(); let mut component_access_by_node_id: HashMap = HashMap::new(); @@ -15914,6 +15860,19 @@ fn index_file_with_resolution_inputs( _ => {} } } + if let (Some(start_row), Some(start_col), Some(end_row), Some(end_col)) = + (start_row, start_col, end_row, end_col) + { + graph_capture_spans.insert( + node_id, + GraphNodeSpan { + start_line: start_row + 1, + start_col: start_col + 1, + end_line: end_row + 1, + end_col: end_col + 1, + }, + ); + } if canonical_role == CanonicalNodeRole::Unspecified { canonical_role = if reference_graph_nodes.contains(&node_id) { CanonicalNodeRole::Reference @@ -16166,6 +16125,16 @@ fn index_file_with_resolution_inputs( &mut unique_nodes, symbol_table.as_ref(), ); + let runtime_import_call_spans = runtime_import_specs + .iter() + .flat_map(|spec| spec.exact_bare_call_target_spans.iter().copied()) + .collect::>(); + let private_call_spans = if matches!(language_config.language_name, "javascript" | "typescript") + { + languages::javascript::private_name_call_spans(&tree, source) + } else { + HashSet::new() + }; // 2. Second pass: Create edges using tree-sitter-graph output let mut edge_keys: HashSet = HashSet::new(); @@ -16257,6 +16226,24 @@ fn index_file_with_resolution_inputs( if let Some(marker) = callsite_marker { append_callsite_marker(&mut edge, marker); } + if edge.kind == EdgeKind::CALL + && let Some(span) = graph_capture_spans.get(&sink_ref) + { + for (spans, marker) in [ + ( + &runtime_import_call_spans, + languages::javascript::RUNTIME_IMPORT_CALLSITE_MARKER, + ), + ( + &private_call_spans, + languages::javascript::PRIVATE_NAME_CALLSITE_MARKER, + ), + ] { + if spans.contains(span) { + append_callsite_marker(&mut edge, marker); + } + } + } if !edge_keys.insert(edge_dedup_key(&edge, flags)) { continue; } @@ -16362,23 +16349,6 @@ fn index_file_with_resolution_inputs( &mut edge_keys, flags, ); - annotate_exact_runtime_import_bare_calls( - &runtime_import_specs, - &unique_nodes, - &mut result_edges, - &mut edge_keys, - flags, - ); - if matches!(language_config.language_name, "javascript" | "typescript") { - annotate_callsite_target_spans( - &languages::javascript::private_name_call_spans(&tree, source), - languages::javascript::PRIVATE_NAME_CALLSITE_MARKER, - &unique_nodes, - &mut result_edges, - &mut edge_keys, - flags, - ); - } append_schema_endpoint_call_edges( language_config.language_name, source, diff --git a/crates/codestory-indexer/tests/call_resolution_common_methods.rs b/crates/codestory-indexer/tests/call_resolution_common_methods.rs index 689e17750..6c893b471 100644 --- a/crates/codestory-indexer/tests/call_resolution_common_methods.rs +++ b/crates/codestory-indexer/tests/call_resolution_common_methods.rs @@ -8879,6 +8879,42 @@ function loneLoopWriteExternal(body) { Ok(()) } +#[test] +fn test_script_same_line_runtime_import_marks_only_the_unshadowed_occurrence() -> anyhow::Result<()> +{ + for extension in ["js", "ts", "tsx"] { + for imported_first in [false, true] { + let imported = "function outside() { dispatch(2); }"; + let shadowed = "function shadow() { const dispatch = (value) => value; dispatch(1); }"; + let source = if imported_first { + format!("const dispatch = require('opaque-module'); {imported} {shadowed}\n") + } else { + format!("const dispatch = require('opaque-module'); {shadowed} {imported}\n") + }; + let (nodes, edges) = index_single_file(&format!("neutral.{extension}"), &source)?; + let marked = edges + .iter() + .filter(|edge| { + edge.kind == EdgeKind::CALL + && edge.callsite_identity.as_deref().is_some_and(|identity| { + identity + .split('|') + .any(|part| part == "syntax:js-runtime-import-call") + }) + }) + .collect::>(); + assert_eq!( + marked.len(), + 1, + "{extension}/imported_first={imported_first}: {:?}", + describe_call_edges(&edges, &nodes) + ); + assert!(marked[0].resolved_target.is_none()); + } + } + Ok(()) +} + #[test] fn test_typescript_family_member_calls_never_inherit_runtime_import_markers() -> anyhow::Result<()> { diff --git a/crates/codestory-indexer/tests/query_rule_regressions.rs b/crates/codestory-indexer/tests/query_rule_regressions.rs index 70dbddc10..1725b5605 100644 --- a/crates/codestory-indexer/tests/query_rule_regressions.rs +++ b/crates/codestory-indexer/tests/query_rule_regressions.rs @@ -118,6 +118,80 @@ fn script_private_callable_names_cannot_resolve_to_enclosing_definitions() -> an Ok(()) } +#[test] +fn script_private_call_scope_belongs_to_each_occurrence() -> anyhow::Result<()> { + let mut failures = Vec::new(); + for extension in ["js", "ts", "tsx"] { + for private in ["quartz", "opaque_731"] { + for callable in ["function", "async function", "function*", "async function*"] { + for outside_first in [false, true] { + for nested in [false, true] { + let calls = format!("return {private}(x-1) + {private}(x-2);"); + let body = if nested { + format!("function child() {{ {calls} }} return child();") + } else { + calls + }; + let binding = + format!("const bound = {callable} {private}(x) {{ {body} }};"); + let ordinary = format!("{private}(77);"); + let line = if outside_first { + format!( + "/* λ */ function driver() {{ {ordinary} {binding} return bound; }}" + ) + } else { + format!( + "/* λ */ function driver() {{ {binding} {ordinary} return bound; }}" + ) + }; + let source = format!("function {private}(x) {{ return 99; }}\n{line}\n"); + let (nodes, edges) = + index_project(&[(&format!("nested/fixture.{extension}"), &source)])?; + let outer = nodes + .iter() + .find(|node| { + node.kind == NodeKind::FUNCTION + && node.serialized_name == private + && node.start_line == Some(1) + }) + .expect("outer definition"); + let calls = edges + .iter() + .filter(|edge| { + edge.kind == EdgeKind::CALL + && edge.line == Some(2) + && nodes.iter().any(|node| { + node.id == edge.target && node.serialized_name == private + }) + }) + .collect::>(); + let unresolved = calls + .iter() + .filter(|edge| { + edge.resolved_target.is_none() && edge.candidate_targets.is_empty() + }) + .count(); + let resolved = calls + .iter() + .filter(|edge| edge.resolved_target == Some(outer.id)) + .count(); + if calls.len() != 3 || unresolved != 2 || resolved != 1 { + failures.push(format!("{extension}/{callable}/{private}/outside_first={outside_first}/nested={nested}: {calls:?}")); + } + } + } + } + } + } + assert!( + failures.is_empty(), + "{} occurrence failures:\n{}", + failures.len(), + failures.join("\n") + ); + Ok(()) +} + fn matches_name(actual: &str, wanted: &str) -> bool { actual == wanted || actual.ends_with(&format!(".{wanted}"))