Skip to content

Commit cbb8ed8

Browse files
committed
feat(resolve): self-form calls resolve to cross-file same-type members
- `sole_same_tree_member_of_class` extends the self-form arm past the caller's file: the exactly-one same-language candidate carrying the caller's own named `ClassMember` evidence resolves — rust impl blocks and cpp out-of-line definitions put a type's members in files the same-file arm cannot see - own-type members outrank the inheritance walk: the arm sits inside `resolve_self_form_member`, ahead of every parent hop - `LanguageBehavior::type_members_span_files` gates the arm (rust, cpp, csharp): in one-declaration-per-file languages a same-named class in another file is a different class, and its members are never borrowed - any same-file member claimant for the called name vetoes the cross-file match: the caller's own file already claims the name, and independently minified twin bundles make the cross-file copy the wrong one - caller and candidate module paths must share their tree root; module None on either side fails closed — loose files carry no tree identity
1 parent 7e65d02 commit cbb8ed8

6 files changed

Lines changed: 393 additions & 0 deletions

File tree

‎src/indexing/facade.rs‎

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2672,6 +2672,288 @@ mod tests {
26722672
}
26732673
}
26742674

2675+
// Cross-file same-type member: a self-form call whose member is
2676+
// defined in another file of the SAME type (rust split impl
2677+
// blocks) resolves on the named-ClassMember match — caller and
2678+
// member both declare membership in Widget — behind exactly-one
2679+
// same-language discipline and the same-tree constraint. The
2680+
// Other.setup decoy is filtered by the named match. Production
2681+
// lanes only, and the indexed path is PRE-REGISTERED in settings:
2682+
// rust module identity is path-derived and the List lane has no
2683+
// walk root, so its strip base comes from the registered indexed
2684+
// paths — exactly the shape production incremental runs have
2685+
// (invariant: bare test contexts without registered paths
2686+
// degenerate to module None and the arm correctly fails closed).
2687+
#[test]
2688+
fn rust_split_impl_self_call_resolves_on_named_member() {
2689+
let dir = tempfile::tempdir().unwrap();
2690+
let src = dir.path().join("src");
2691+
std::fs::create_dir_all(&src).unwrap();
2692+
std::fs::write(
2693+
src.join("widget.rs"),
2694+
"pub struct Widget {}\n\nimpl Widget {\n pub fn setup(&self) {\n }\n}\n",
2695+
)
2696+
.unwrap();
2697+
let consumer = "use crate::widget::Widget;\n\nimpl Widget {\n pub fn make(&self) {\n self.setup();\n }\n}\n";
2698+
std::fs::write(src.join("consumer.rs"), consumer).unwrap();
2699+
std::fs::write(
2700+
src.join("other.rs"),
2701+
"pub struct Other {}\n\nimpl Other {\n pub fn setup(&self) {\n }\n}\n",
2702+
)
2703+
.unwrap();
2704+
2705+
let mut settings = Settings {
2706+
index_path: dir.path().join("index"),
2707+
workspace_root: None,
2708+
..Default::default()
2709+
};
2710+
settings
2711+
.add_indexed_path(src.clone())
2712+
.expect("register indexed path");
2713+
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
2714+
2715+
let assert_resolves = |facade: &IndexFacade, leg: &str| {
2716+
let makes = facade.find_symbols_by_name("make", None);
2717+
assert_eq!(makes.len(), 1, "one make symbol expected ({leg})");
2718+
let callees = facade.get_called_functions(makes[0].id);
2719+
let picked: Vec<String> = callees
2720+
.iter()
2721+
.map(|s| {
2722+
format!(
2723+
"{}@{}",
2724+
s.name,
2725+
facade.get_file_path(s.file_id).unwrap_or_default()
2726+
)
2727+
})
2728+
.collect();
2729+
assert!(
2730+
callees.iter().any(|s| s.name.as_ref() == "setup"
2731+
&& facade
2732+
.get_file_path(s.file_id)
2733+
.unwrap_or_default()
2734+
.ends_with("widget.rs")),
2735+
"split-impl self call must resolve to the same type's \
2736+
member on the named-ClassMember witness ({leg}), \
2737+
got: {picked:?}"
2738+
);
2739+
};
2740+
2741+
facade.index_directory(&src, true).unwrap();
2742+
assert_resolves(&facade, "fresh");
2743+
2744+
std::fs::write(src.join("consumer.rs"), format!("{consumer}\n// touched\n")).unwrap();
2745+
std::fs::File::options()
2746+
.write(true)
2747+
.open(src.join("consumer.rs"))
2748+
.unwrap()
2749+
.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(5))
2750+
.unwrap();
2751+
facade.index_directory(&src, false).unwrap();
2752+
assert_resolves(&facade, "seeded incremental");
2753+
}
2754+
2755+
// Same-file name claimant vetoes the cross-file borrow: when the
2756+
// caller's own file holds ANY member named like the call (under
2757+
// whatever class), the tree-wide named match must not borrow a
2758+
// same-named-class copy from another file. Witnessed leak:
2759+
// three.js minified twin bundles — independent minification
2760+
// scrambles class names, so the caller's class name matches the
2761+
// TWIN bundle's copy while its own bundle's copy sits same-file
2762+
// under a different name. The row still resolves through the
2763+
// local tier to the same-file claimant.
2764+
#[test]
2765+
fn same_file_claimant_vetoes_cross_file_named_borrow() {
2766+
let dir = tempfile::tempdir().unwrap();
2767+
let src = dir.path().join("src");
2768+
// Shared parent dir: both modules root at `lib`, so the (b)
2769+
// same-tree constraint admits the twin — the veto is the only
2770+
// discipline left between the caller and the wrong copy.
2771+
let lib = src.join("lib");
2772+
std::fs::create_dir_all(&lib).unwrap();
2773+
std::fs::write(
2774+
lib.join("appA.js"),
2775+
"class Painter {\n parse() {\n this.createNodeFromType();\n }\n}\n\nclass Registry {\n createNodeFromType() {\n }\n}\n",
2776+
)
2777+
.unwrap();
2778+
std::fs::write(
2779+
lib.join("appB.js"),
2780+
"class Painter {\n createNodeFromType() {\n }\n}\n",
2781+
)
2782+
.unwrap();
2783+
2784+
let mut settings = Settings {
2785+
index_path: dir.path().join("index"),
2786+
workspace_root: None,
2787+
..Default::default()
2788+
};
2789+
settings
2790+
.add_indexed_path(src.clone())
2791+
.expect("register indexed path");
2792+
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
2793+
facade.index_directory(&src, true).unwrap();
2794+
2795+
let parses = facade.find_symbols_by_name("parse", None);
2796+
assert_eq!(parses.len(), 1, "one parse symbol expected");
2797+
let callees = facade.get_called_functions(parses[0].id);
2798+
let picked: Vec<String> = callees
2799+
.iter()
2800+
.map(|s| {
2801+
format!(
2802+
"{}@{}",
2803+
s.name,
2804+
facade.get_file_path(s.file_id).unwrap_or_default()
2805+
)
2806+
})
2807+
.collect();
2808+
assert!(
2809+
!callees
2810+
.iter()
2811+
.any(|s| s.name.as_ref() == "createNodeFromType"
2812+
&& facade
2813+
.get_file_path(s.file_id)
2814+
.unwrap_or_default()
2815+
.ends_with("appB.js")),
2816+
"cross-file borrow past a same-file claimant is a wrong-copy \
2817+
pick, got: {picked:?}"
2818+
);
2819+
}
2820+
2821+
// Language gate on the split-type premise: php declares one class
2822+
// per file, so a same-named class in another file is a DIFFERENT
2823+
// class — the named match must not borrow its member (witnessed:
2824+
// laravel Schema\Grammars\SqlServerGrammar callers borrowing
2825+
// Query\Grammars\SqlServerGrammar's wrapTable — namespace twins,
2826+
// no inheritance relation). The arm runs only where the language
2827+
// has split-type syntax (rust impl blocks, cpp out-of-line,
2828+
// csharp partial).
2829+
#[test]
2830+
fn php_namespace_twin_class_member_stays_closed() {
2831+
let dir = tempfile::tempdir().unwrap();
2832+
let src = dir.path().join("src");
2833+
let a = src.join("schema");
2834+
let b = src.join("query");
2835+
std::fs::create_dir_all(&a).unwrap();
2836+
std::fs::create_dir_all(&b).unwrap();
2837+
std::fs::write(
2838+
a.join("Widget.php"),
2839+
"<?php\nnamespace App\\Schema;\n\nclass Widget {\n public function make() {\n $this->setup(1);\n }\n}\n",
2840+
)
2841+
.unwrap();
2842+
std::fs::write(
2843+
b.join("Widget.php"),
2844+
"<?php\nnamespace App\\Query;\n\nclass Widget {\n public function setup($x) {\n }\n}\n",
2845+
)
2846+
.unwrap();
2847+
2848+
let mut settings = Settings {
2849+
index_path: dir.path().join("index"),
2850+
workspace_root: None,
2851+
..Default::default()
2852+
};
2853+
settings
2854+
.add_indexed_path(src.clone())
2855+
.expect("register indexed path");
2856+
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
2857+
facade.index_directory(&src, true).unwrap();
2858+
2859+
let makes = facade.find_symbols_by_name("make", None);
2860+
assert_eq!(makes.len(), 1, "one make symbol expected");
2861+
let callees = facade.get_called_functions(makes[0].id);
2862+
assert!(
2863+
!callees.iter().any(|s| s.name.as_ref() == "setup"),
2864+
"a namespace twin's member is another class's member, got: {callees:?}"
2865+
);
2866+
}
2867+
2868+
// Duplicate type copies: two same-named types in one tree, both
2869+
// declaring the member — the named match cannot pick a copy, so
2870+
// exactly-one discipline fails closed.
2871+
#[test]
2872+
fn rust_split_impl_duplicate_type_copies_fail_closed() {
2873+
let dir = tempfile::tempdir().unwrap();
2874+
let src = dir.path().join("src");
2875+
std::fs::create_dir_all(&src).unwrap();
2876+
std::fs::write(
2877+
src.join("widget.rs"),
2878+
"pub struct Widget {}\n\nimpl Widget {\n pub fn setup(&self) {\n }\n}\n",
2879+
)
2880+
.unwrap();
2881+
std::fs::write(
2882+
src.join("twin.rs"),
2883+
"pub struct Widget {}\n\nimpl Widget {\n pub fn setup(&self) {\n }\n}\n",
2884+
)
2885+
.unwrap();
2886+
std::fs::write(
2887+
src.join("consumer.rs"),
2888+
"use crate::widget::Widget;\n\nimpl Widget {\n pub fn make(&self) {\n self.setup();\n }\n}\n",
2889+
)
2890+
.unwrap();
2891+
2892+
let mut settings = Settings {
2893+
index_path: dir.path().join("index"),
2894+
workspace_root: None,
2895+
..Default::default()
2896+
};
2897+
settings
2898+
.add_indexed_path(src.clone())
2899+
.expect("register indexed path");
2900+
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
2901+
facade.index_directory(&src, true).unwrap();
2902+
2903+
let makes = facade.find_symbols_by_name("make", None);
2904+
assert_eq!(makes.len(), 1, "one make symbol expected");
2905+
let callees = facade.get_called_functions(makes[0].id);
2906+
assert!(
2907+
!callees.iter().any(|s| s.name.as_ref() == "setup"),
2908+
"two named claimants cannot license a copy pick, got: {callees:?}"
2909+
);
2910+
}
2911+
2912+
// Cross-tree block, the (b) discipline: a single global claimant
2913+
// in ANOTHER tree (different module root) is not a candidate — the
2914+
// caller's own same-named class lacking the member must not borrow
2915+
// it across trees.
2916+
#[test]
2917+
fn python_cross_tree_single_claimant_stays_closed() {
2918+
let dir = tempfile::tempdir().unwrap();
2919+
let src = dir.path().join("src");
2920+
let pkg_a = src.join("pkg_a");
2921+
let pkg_b = src.join("pkg_b");
2922+
std::fs::create_dir_all(&pkg_a).unwrap();
2923+
std::fs::create_dir_all(&pkg_b).unwrap();
2924+
std::fs::write(pkg_a.join("__init__.py"), "").unwrap();
2925+
std::fs::write(pkg_b.join("__init__.py"), "").unwrap();
2926+
std::fs::write(
2927+
pkg_a.join("widget.py"),
2928+
"class Widget:\n def setup(self):\n pass\n",
2929+
)
2930+
.unwrap();
2931+
std::fs::write(
2932+
pkg_b.join("consumer.py"),
2933+
"class Widget:\n def make(self):\n self.setup()\n",
2934+
)
2935+
.unwrap();
2936+
2937+
let mut settings = Settings {
2938+
index_path: dir.path().join("index"),
2939+
workspace_root: None,
2940+
..Default::default()
2941+
};
2942+
settings
2943+
.add_indexed_path(src.clone())
2944+
.expect("register indexed path");
2945+
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
2946+
facade.index_directory(&src, true).unwrap();
2947+
2948+
let makes = facade.find_symbols_by_name("make", None);
2949+
assert_eq!(makes.len(), 1, "one make symbol expected");
2950+
let callees = facade.get_called_functions(makes[0].id);
2951+
assert!(
2952+
!callees.iter().any(|s| s.name.as_ref() == "setup"),
2953+
"a cross-tree claimant must not be borrowed, got: {callees:?}"
2954+
);
2955+
}
2956+
26752957
// Own-scope exemption: a bare call to the caller's own non-public
26762958
// member keeps its same-file evidence — the gate fires only on
26772959
// cross-file picks. The cross-file public decoy guards that the

‎src/indexing/pipeline/stages/resolve.rs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,8 @@ impl ResolveStage {
10951095
) -> Option<ResolvedRelationship> {
10961096
let caller_sym = self.symbol_cache.get_ref(from_id)?;
10971097
let caller_file = caller_sym.file_id;
1098+
let caller_language = caller_sym.language_id;
1099+
let caller_module = caller_sym.module_path.clone();
10981100
let enclosing = match caller_sym.scope_context.as_ref() {
10991101
Some(crate::symbol::ScopeContext::ClassMember {
11001102
class_name: Some(name),
@@ -1104,6 +1106,22 @@ impl ResolveStage {
11041106
drop(caller_sym);
11051107

11061108
self.sole_member_of_class(caller_file, &enclosing, &unresolved.to_name)
1109+
.or_else(|| {
1110+
let language_id = caller_language.as_ref()?;
1111+
if !self
1112+
.get_behavior(language_id)
1113+
.is_some_and(|b| b.type_members_span_files())
1114+
{
1115+
return None;
1116+
}
1117+
self.sole_same_tree_member_of_class(
1118+
caller_file,
1119+
&enclosing,
1120+
&unresolved.to_name,
1121+
language_id,
1122+
caller_module.as_deref()?,
1123+
)
1124+
})
11071125
.map(|to_id| ResolvedRelationship {
11081126
from_id,
11091127
to_id,
@@ -1112,6 +1130,73 @@ impl ResolveStage {
11121130
})
11131131
}
11141132

1133+
/// Cross-file variant of `sole_member_of_class` for split member
1134+
/// definitions (rust impl blocks across files, cpp out-of-line
1135+
/// members): the caller and the member both declare membership in
1136+
/// the same-named type. The named match is weaker than the file
1137+
/// identity it relaxes, so three disciplines bound it: any
1138+
/// same-file member claimant for the name vetoes the borrow (the
1139+
/// caller's own file already claims the name under another class;
1140+
/// witnessed leak: minified twin bundles, where scrambled class
1141+
/// names make the caller's class match the TWIN bundle's copy
1142+
/// while its own copy sits same-file under a different name);
1143+
/// exactly one candidate over the whole tree (duplicate type
1144+
/// copies fail closed); and a shared module-tree root (cross-tree
1145+
/// twins are not candidates). Module None on either side fails
1146+
/// closed: loose files carry no tree identity.
1147+
fn sole_same_tree_member_of_class(
1148+
&self,
1149+
caller_file: FileId,
1150+
class_name: &str,
1151+
member_name: &str,
1152+
language_id: &LanguageId,
1153+
caller_module: &str,
1154+
) -> Option<SymbolId> {
1155+
let separator = self
1156+
.get_behavior(language_id)
1157+
.map(|b| b.module_separator())
1158+
.unwrap_or("::");
1159+
let caller_root = caller_module.split(separator).next()?;
1160+
1161+
let same_file_claimant = self
1162+
.symbol_cache
1163+
.lookup_candidates(member_name)
1164+
.into_iter()
1165+
.any(|id| {
1166+
self.symbol_cache.get_ref(id).is_some_and(|sym| {
1167+
sym.file_id == caller_file && Self::is_member_kind_or_scope(&sym)
1168+
})
1169+
});
1170+
if same_file_claimant {
1171+
return None;
1172+
}
1173+
1174+
let mut members = self
1175+
.symbol_cache
1176+
.lookup_candidates(member_name)
1177+
.into_iter()
1178+
.filter(|&id| {
1179+
self.symbol_cache.get_ref(id).is_some_and(|sym| {
1180+
sym.language_id.as_ref() == Some(language_id)
1181+
&& matches!(
1182+
sym.scope_context.as_ref(),
1183+
Some(crate::symbol::ScopeContext::ClassMember {
1184+
class_name: Some(name),
1185+
}) if name.as_ref() == class_name
1186+
)
1187+
&& sym
1188+
.module_path
1189+
.as_deref()
1190+
.and_then(|m| m.split(separator).next())
1191+
== Some(caller_root)
1192+
})
1193+
});
1194+
match (members.next(), members.next()) {
1195+
(Some(id), None) => Some(id),
1196+
_ => None,
1197+
}
1198+
}
1199+
11151200
/// True when the caller's own scope names its enclosing type.
11161201
fn caller_has_member_scope(&self, from_id: SymbolId) -> bool {
11171202
self.symbol_cache.get_ref(from_id).is_some_and(|sym| {

0 commit comments

Comments
 (0)