From 48ced28dc57f97a77109809c3cdedfb79be35f11 Mon Sep 17 00:00:00 2001 From: bompus Date: Thu, 29 Oct 2026 15:14:03 -0600 Subject: [PATCH 1/3] fix(kernel): port upstream's remaining JS binding-scope and ambient resolution debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing resolver suites go green: - js-receiver-binding-scope (#2589): a bound receiver whose binding says nothing falls through to the name strategies — an unannotated parameter (`(root)`, `{ root }: { root: any }`) or a `new C()` whose C is no class. Annotated params (`store: any`) and typed/call-result declarations stay terminal under the claim model. Strategy 3 now also filters React lifecycle methods on Component-derived classes for non-this receivers. - dynamic-import-binding (#2491): isOutOfRepoBinding + innerScopeShadows — the narrowest in-scope import binding (block-scoped dynamic rows win over file-wide statics) decides the name's module; when it's outside the repo only same-file declarations nested in a function still shadow it. - ambient-module-imports: the resolveOne ambient gate — a declaration inside `declare module 'x'` is reached only through an import of `x`, never by bare name from a file that reaches no `x`. Also in the batch: MEMBER_TYPE_NON_TYPES verbatim (Java `permits`/`throws`), and a `fn_ref` outcome channel so paren-less Pascal method pointers keep their fnRef edge metadata. Precision: vite 8/8, svelte 1/1, jq clean; golden dumps unchanged. --- .../precision-jq-9d241e2-9888e4a7.json | 24 ++++ .../precision-svelte-5895c63-9888e4a7.json | 35 +++++ .../precision-vite-8492422-9888e4a7.json | 95 +++++++++++++ .../src/resolve/declared_member.rs | 12 +- codegraph-kernel/src/resolve/imports.rs | 37 +++++ codegraph-kernel/src/resolve/method_call.rs | 127 +++++++++++++++++- codegraph-kernel/src/resolve/mod.rs | 6 + codegraph-kernel/src/resolve/names.rs | 50 +++++++ codegraph-kernel/src/resolve/pascal_scope.rs | 3 + codegraph-kernel/src/resolve/pipeline.rs | 17 +++ codegraph-kernel/src/resolve/receivers.rs | 2 +- codegraph-kernel/src/resolve/ts_known.rs | 108 +++++++++++++++ src/extraction/kernel/loader.ts | 2 + src/resolution/index.ts | 4 +- 14 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 __tests__/evaluation/results/precision-jq-9d241e2-9888e4a7.json create mode 100644 __tests__/evaluation/results/precision-svelte-5895c63-9888e4a7.json create mode 100644 __tests__/evaluation/results/precision-vite-8492422-9888e4a7.json diff --git a/__tests__/evaluation/results/precision-jq-9d241e2-9888e4a7.json b/__tests__/evaluation/results/precision-jq-9d241e2-9888e4a7.json new file mode 100644 index 0000000000..106f0d6289 --- /dev/null +++ b/__tests__/evaluation/results/precision-jq-9d241e2-9888e4a7.json @@ -0,0 +1,24 @@ +{ + "timestamp": "2026-10-29T21:12:33.608Z", + "corpus": "jq", + "repo": "https://github.com/jqlang/jq.git", + "commit": "9d241e2", + "codegraphSha": "9888e4a7", + "summary": { + "absentCases": 0, + "absentHeld": 0, + "presentCases": 0, + "presentHeld": 0, + "unjudgeable": 0 + }, + "resolvedBy": { + "exact-match": 5308, + "(none)": 2272, + "import": 115, + "function-ref": 87, + "file-path": 2, + "instance-method": 1 + }, + "edges": 7785, + "results": [] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-svelte-5895c63-9888e4a7.json b/__tests__/evaluation/results/precision-svelte-5895c63-9888e4a7.json new file mode 100644 index 0000000000..88ca879e10 --- /dev/null +++ b/__tests__/evaluation/results/precision-svelte-5895c63-9888e4a7.json @@ -0,0 +1,35 @@ +{ + "timestamp": "2026-10-29T21:12:32.224Z", + "corpus": "svelte", + "repo": "https://github.com/sveltejs/svelte.git", + "commit": "5895c63", + "codegraphSha": "9888e4a7", + "summary": { + "absentCases": 1, + "absentHeld": 1, + "presentCases": 0, + "presentHeld": 0, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 30920, + "import": 22673, + "exact-match": 9038, + "qualified-name": 1874, + "instance-method": 814, + "framework": 220, + "function-ref": 195, + "file-path": 58, + "fuzzy": 13 + }, + "edges": 65805, + "results": [ + { + "caseId": "svelte-bare-import-bundle", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-vite-8492422-9888e4a7.json b/__tests__/evaluation/results/precision-vite-8492422-9888e4a7.json new file mode 100644 index 0000000000..4a814855a5 --- /dev/null +++ b/__tests__/evaluation/results/precision-vite-8492422-9888e4a7.json @@ -0,0 +1,95 @@ +{ + "timestamp": "2026-10-29T21:12:20.882Z", + "corpus": "vite", + "repo": "https://github.com/vitejs/vite.git", + "commit": "8492422", + "codegraphSha": "9888e4a7", + "summary": { + "absentCases": 6, + "absentHeld": 6, + "presentCases": 2, + "presentHeld": 2, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 13392, + "import": 11144, + "exact-match": 3386, + "qualified-name": 1009, + "instance-method": 547, + "file-path": 197, + "function-ref": 163, + "framework": 1 + }, + "edges": 29839, + "results": [ + { + "caseId": "vite-factory-receiver-control", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:d321dd128d90578bc69867835ec73ef8", + "to": "method:db73e5f5d9c7af61d0f22c5b9971ced3", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "vite-external-receiver-not-project-method", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-bare-import-self-edge", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-bare-import-getEnv", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-fuzzy-nested-resolveConfig-getEnv", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-fuzzy-nested-build-scan", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-sealed-module-defineConfig", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-control-relative-import", + "expect": "present", + "pass": true, + "found": [ + { + "from": "file:packages/vite/src/node/config.ts", + "to": "function:0c2f36c32c5742aee412978220fd5b23", + "resolvedBy": "import" + } + ], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/codegraph-kernel/src/resolve/declared_member.rs b/codegraph-kernel/src/resolve/declared_member.rs index 931b118d57..9a19755d84 100644 --- a/codegraph-kernel/src/resolve/declared_member.rs +++ b/codegraph-kernel/src/resolve/declared_member.rs @@ -23,7 +23,7 @@ impl KernelResolver { let mut hits = line.match_indices(&call).map(|(i, _)| &line[..i]).peekable(); hits.peek().is_some() && hits.all(|before| before.ends_with("this.")) } - fn declaration_head(&mut self, n: &KNode, count: i64) -> String { + pub(super) fn declaration_head(&mut self, n: &KNode, count: i64) -> String { let Some(lines) = self.read_file(&n.file_path) else { return String::new() }; let from = ((n.start_line - 1).max(0) as usize).min(lines.len()); let to = ((n.start_line + count).max(0) as usize).min(lines.len()); @@ -56,7 +56,15 @@ impl KernelResolver { if let Some(m)=re.captures(line) { let before=&line[..m.get(0).unwrap().start()]; let in_params=*depth==1 && before.matches('(').count()>before.matches(')').count(); - if !in_params && !matches!(&m[1], "return"|"new"|"throw"|"var"|"val"|"get"|"set"|"init"|"class"|"interface"|"object"|"static"|"final") { + // A language keyword where a type would be is not a member's + // type — upstream's MEMBER_TYPE_NON_TYPES, verbatim. + if !in_params && !matches!(&m[1], + "return"|"new"|"case"|"throw"|"else"|"in"|"out"|"ref"|"params"|"await"|"yield"|"is"|"as"|"using" + |"var"|"val"|"goto"|"nameof"|"typeof"|"sizeof"|"default"|"when"|"where"|"get"|"set"|"init" + |"class"|"interface"|"enum"|"struct"|"record"|"object"|"namespace"|"package"|"import"|"extends" + |"implements"|"fun"|"static"|"final"|"abstract"|"sealed"|"override"|"virtual"|"delegate"|"event" + // Java's `sealed interface Shape permits Circle, Square` and `void run() throws Failure {`. + |"permits"|"throws") { found=if m[1].contains('.') {Some(m[1].to_string())} else {self.normalize_inferred_type_name(&m[1])?};break; } } diff --git a/codegraph-kernel/src/resolve/imports.rs b/codegraph-kernel/src/resolve/imports.rs index ad63e8c0ea..fe28863303 100644 --- a/codegraph-kernel/src/resolve/imports.rs +++ b/codegraph-kernel/src/resolve/imports.rs @@ -726,6 +726,43 @@ impl KernelResolver { Ok(false) } + /// isOutOfRepoBinding (name-matcher.ts #2491): the module the innermost + /// in-scope import binding for the ref's name comes from — a dynamic + /// `await import()`/`require()` binding, scoped to its block, wins over + /// the file's static one — or, for an `imports` ref, the module it + /// re-exports. True when that module is outside the repository. + pub(super) fn js_out_of_repo_binding(&mut self, r: &ResolveRefIn) -> Res { + if !is_js_family(&r.language) || !re!(r"^[A-Za-z_$][\w$]*$").is_match(&r.reference_name) { + return Ok(false); + } + // The bindings table scopes a dynamic `await import()`/`require()` + // row to its block and a static import to the whole file, so the + // narrowest import row in scope at the ref is upstream's + // `dynamicImportSource(name, ref) ?? binding?.source` in one lookup: + // an in-scope dynamic row is narrower and wins, and no in-scope row + // at all means no static mapping either. + let scoped = { + let rows = self.bindings(&r.file_path)?; + rows.iter() + .filter(|b| { + b.kind == "import" + && b.name == r.reference_name + && r.line >= b.scope_start + && r.line <= b.scope_end + }) + .min_by_key(|b| b.scope_end - b.scope_start) + .and_then(|b| b.target_spec.clone()) + }; + let Some(source) = scoped.or(if r.reference_kind == "imports" { + self.re_export_source(&r.reference_name, r)? + } else { + None + }) else { + return Ok(false); + }; + self.is_out_of_repo_import(&source, &r.file_path, &r.language) + } + /// isJsBareModuleRef (import-resolver.ts): the `imports` reference to the /// module of an import named by one bare segment. A name some import binds /// locally is that binding, not the module. diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index b24bbcf36b..037a979f41 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -936,6 +936,34 @@ if inferred.is_none() && r.language == "dart" { Ok(None) } + /// isReactLifecycleMethod (#2589): a lifecycle method (`render`, + /// `componentDidMount`, …) of a class whose head extends React's + /// `Component`/`PureComponent` — such calls are React's, never a + /// receiver's untyped guess. + fn is_react_lifecycle_method(&mut self, n: &KNode) -> Res { + if n.kind != "method" || !REACT_LIFECYCLE_METHODS.contains(&n.name.as_str()) { + return Ok(false); + } + let Some(owner_name) = n + .qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner.rsplit("::").next().unwrap_or(owner).to_string()) + else { + return Ok(false); + }; + let Some(owner) = self + .nodes_in_file(&n.file_path)? + .iter() + .find(|c| c.kind == "class" && c.name == owner_name && c.start_line <= n.start_line && c.end_line >= n.end_line) + .cloned() + else { + return Ok(false); + }; + let head = self.declaration_head(&owner, 4); + let head = head.split('{').next().unwrap_or(""); + Ok(re!(r"(?-u:\b)extends\s+(?:React\s*\.\s*)?(?:Pure)?Component(?-u:\b)").is_match(head)) + } + /// Whether the bound-receiver miss is a local the index cannot type — /// upstream's strategy-3 name match runs for it (`ctx`, bound from an /// assertion to a type a function declares); every other miss — @@ -1295,6 +1323,14 @@ if inferred.is_none() && r.language == "dart" { target.retain(|m| { !php_scope::is_vue_component_method(m) || (object_or_class == "this" && m.file_path == r.file_path) }); + // A React class component's `render()` and lifecycle methods + // are React's to call (#2589): an untyped `root.render()` + // is no error boundary's `render`, whatever holds the root. + if object_or_class != "this" && object_or_class != "super" { + let mut kept = Vec::new(); + for n in target { if !self.is_react_lifecycle_method(&n)? { kept.push(n); } } + target = kept; + } narrowed |= target.len() != before; } if super::call_shape::is_std_method(&r.language, &method_name) @@ -1722,6 +1758,14 @@ if inferred.is_none() && r.language == "dart" { return self.match_method_call_free(r); } if binding.kind == "param" { + // An unannotated parameter bound the name but says nothing of + // its value — matchMethodCall (#2589) lets the name strategies + // answer it: `items.forEach((root) => root.render())` guesses the + // one `render`. An annotated one (`store: any`) claims the name: + // the claim model keeps it terminal. + if self.js_param_unannotated(&binding, root, r)? { + return self.match_method_call_free(r); + } return Ok(None); } // An awaited binding reassigned before the call no longer holds its @@ -1729,7 +1773,79 @@ if inferred.is_none() && r.language == "dart" { if is_esm_family(&r.language) && self.infer_esm_awaited_call_type(root, r)?.is_some_and(|a| a.rebound) { return Ok(None); } - probe!(r, "brc:factory-tail", self.esm_factory_tail(&binding, root, method, r)) + if let Some(hit) = probe!(r, "brc:factory-tail", self.esm_factory_tail(&binding, root, method, r)?) { + return Ok(Some(hit)); + } + // A declaration that says nothing of the value — an unreadable + // initializer or `new` of a non-class binding — leaves the receiver + // untyped (#2589): the free strategies answer it. + if self.js_binding_says_nothing(&binding, root, r)? { + return self.match_method_call_free(r); + } + Ok(None) + } + + /// Whether `name`'s first occurrence on `line` carries a `name:` type + /// annotation — `jsParameterReading`/`jsDeclarationReading`'s `name:` + /// anchored at the binding site (#2589). A destructured leaf (`{ root }: + /// { root: any }`) reads its own position: followed by ` }`, it is + /// unannotated; only a plain `name: T` pins a type. + fn js_name_annotated(&mut self, line: &str, name: &str) -> Res { + let escaped = regex::escape(name); + let Some(m) = Self::cached_regex(&format!(r"(?:^|[^\w$]){escaped}(?-u:\b)"))?.find(line) else { + return Ok(false); + }; + Ok(re!(r"^[!?]?\s*:").is_match(&line[m.end()..])) + } + + /// A `param` binding says nothing when its own position carries no + /// annotation — `(root)`, `{ root }: { root: any }` — where `name: T`, + /// even `name: any`, does (the claim model pins annotated receivers). + fn js_param_unannotated(&mut self, binding: &KBinding, name: &str, r: &ResolveRefIn) -> Res { + let Some(source) = self.read_file(&r.file_path) else { return Ok(false) }; + let Some(line) = source.get((binding.line - 1).max(0) as usize) else { return Ok(false) }; + Ok(!self.js_name_annotated(line, name)?) + } + + /// jsBindingReading's "the binding says nothing of the value" (#2589): a + /// `name: T` annotation or a whole-value `= new C()` where `C` binds to a + /// function/class — or to nothing — says something; every other + /// declaration does not, and the name strategies still answer for it. + fn js_binding_says_nothing(&mut self, binding: &KBinding, name: &str, r: &ResolveRefIn) -> Res { + let Some(source) = self.read_file(&r.file_path) else { return Ok(true) }; + let Some(line) = source.get((binding.line - 1).max(0) as usize) else { return Ok(true) }; + let escaped = regex::escape(name); + if self.js_name_annotated(line, name)? { + return Ok(false); + } + // A `name = factory(…)` binding is typed when the factory's return + // annotation is known — upstream's jsDeclarationReading (#2589) — + // even though the binding itself carries none. + if self + .untyped_local_call_type(&source, (binding.line - 1).max(0) as usize, name, r, false)? + .is_some() + { + return Ok(false); + } + if let Some(m) = Self::cached_regex(&format!( + r"(?:^|[^\w$]){escaped}[^=;]*=\s*new\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*(?:<[^;(){{}}]*>\s*)?\(", + ))?.captures(line) { + let cls = m[1].split('.').next().unwrap_or(""); + return Ok(match self.receiver_binding(cls, r)? { + None => false, + Some(b) => match b.kind.as_str() { + "import" => false, + "decl" => !b.node_id.as_deref() + .and_then(|id| self.node_by_id(id).ok().flatten()) + .is_some_and(|n| matches!(n.kind.as_str(), "function" | "class" | "component" | "method")), + _ => true, + }, + }); + } + // Every other declaration claims the receiver's name — a call result + // the project declares (checked above) or anything else the claim + // model pins. Only a `new C()` whose `C` is no class says nothing. + Ok(false) } } @@ -1785,6 +1901,15 @@ fn ends_initializer(init_s: &str, call_end: i64) -> bool { /// (name-matcher.ts FACTORY_DECLARATION_LINES). const FACTORY_DECLARATION_LINES: usize = 40; +/// REACT_LIFECYCLE_METHODS (#2589): method names React itself calls on a +/// class component — a guess must never claim them for an untyped receiver. +const REACT_LIFECYCLE_METHODS: &[&str] = &[ + "render", "componentDidMount", "componentDidUpdate", "componentWillUnmount", + "shouldComponentUpdate", "getSnapshotBeforeUpdate", "componentDidCatch", + "componentWillMount", "componentWillReceiveProps", "componentWillUpdate", + "UNSAFE_componentWillMount", "UNSAFE_componentWillReceiveProps", "UNSAFE_componentWillUpdate", +]; + /// A binding initializer's factory call (see factory_initializer). pub(super) struct FactoryInit { awaited: bool, diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index b6d1d32d3a..5abbe520fc 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -267,6 +267,10 @@ pub struct ResolveOutcome { /// an import binds, so a sync can park the call under that import's module. pub type_head: Option, pub type_from: Option>, + /// A function used as a value resolved here (a paren-less Pascal method + /// pointer — TS's `metadata: { fnRef: true }` on matchPascalParenlessRef). + /// `function_ref` refs get the marker from their reference kind instead. + pub fn_ref: Option, } impl ResolveOutcome { @@ -284,6 +288,7 @@ impl ResolveOutcome { pre_framework: false, type_head: None, type_from: None, + fn_ref: None, } } /// Frameworks are active and the kernel found no name/import candidate — @@ -336,6 +341,7 @@ impl ResolveOutcome { pre_framework: false, type_head: None, type_from: None, + fn_ref: None, } } } diff --git a/codegraph-kernel/src/resolve/names.rs b/codegraph-kernel/src/resolve/names.rs index 493a67ed67..c74dadadea 100644 --- a/codegraph-kernel/src/resolve/names.rs +++ b/codegraph-kernel/src/resolve/names.rs @@ -791,6 +791,34 @@ impl KernelResolver { best } + /// innerScopeShadows (name-matcher.ts): of the same-name nodes, those the + /// ref's own file declares strictly inside a function or method — a + /// nested declaration shadows an out-of-repo import binding; a top-level + /// one does not (#2491). + fn inner_scope_shadows(&mut self, nodes: Vec>, r: &ResolveRefIn) -> Res>> { + let local: Vec> = nodes.into_iter().filter(|n| n.file_path == r.file_path).collect(); + if local.is_empty() { + return Ok(local); + } + let scopes: Vec> = self + .nodes_in_file(&r.file_path)? + .iter() + .filter(|n| n.kind == "function" || n.kind == "method") + .cloned() + .collect(); + Ok(local + .into_iter() + .filter(|n| { + scopes.iter().any(|s| { + s.id != n.id + && s.start_line <= n.start_line + && s.end_line >= n.end_line + && (s.start_line < n.start_line || s.end_line > n.end_line) + }) + }) + .collect()) + } + /// matchByExactName (name-matcher.ts). pub(super) fn match_by_exact_name(&mut self, r: &ResolveRefIn) -> Res> { let found = self.match_by_exact_name_uncapped(r)?; @@ -805,6 +833,18 @@ impl KernelResolver { let bare_go = self.is_bare_go_call(r)?; let bare_r = self.is_bare_r_call(r); let bare_php = self.is_bare_php_call(r)?; + // A type, a value or an import the file binds from a package outside + // the repository names nothing in it, whatever kind of reference it + // is (upstream's early null in matchByExactName, #2491). + if !bare_js + && is_js_family(&r.language) + && r.reference_kind != "calls" + && re!(r"^[A-Za-z_$][\w$]*$").is_match(&r.reference_name) + && self.js_out_of_repo_binding(r)? + { + return Ok(None); + } + let mut out_of_repo_call = false; if bare_js { if self.is_param_shadowed(r)? { return Ok(None); @@ -812,8 +852,18 @@ impl KernelResolver { if let Some(c) = self.match_js_store_binding_call(r)? { return Ok(Some(c)); } + // `import { useQuery } …`, or an in-scope `await import()`: the + // call means the package's, and no other file's same-named + // symbol — only a declaration nested in this file's own inner + // scope still shadows it. + out_of_repo_call = self.js_out_of_repo_binding(r)?; } let named = self.nodes_by_name(&r.reference_name)?; + let named = if out_of_repo_call { + Arc::new(self.inner_scope_shadows(named.to_vec(), r)?) + } else { + named + }; // Big same-name pools make a bare match pathological: a name like // `t` gets thousands of candidates whose retain/scope checks // dominate a whole batch (pretix's fabric.min.js: ~950ms per diff --git a/codegraph-kernel/src/resolve/pascal_scope.rs b/codegraph-kernel/src/resolve/pascal_scope.rs index 245eab4e15..9b51ed2176 100644 --- a/codegraph-kernel/src/resolve/pascal_scope.rs +++ b/codegraph-kernel/src/resolve/pascal_scope.rs @@ -210,6 +210,9 @@ impl KernelResolver { )?; if out.status == "resolved" { out.edge_kind = Some(edge_kind); + // A method pointer is a function used as a value, as `@Handler` + // is (#756). + if pointer { out.fn_ref = Some(true); } } Ok(Some(out)) } diff --git a/codegraph-kernel/src/resolve/pipeline.rs b/codegraph-kernel/src/resolve/pipeline.rs index 74b45378d5..bc52bf4749 100644 --- a/codegraph-kernel/src/resolve/pipeline.rs +++ b/codegraph-kernel/src/resolve/pipeline.rs @@ -943,6 +943,23 @@ if super::lang_scope::is_dart_member_read(r) { } else { None }; let answer = attribute.as_ref().map(|a| (a.claimed, a.target_node_id.clone())); let mut outcome = self.resolve_ref_inner(r, answer)?; + // A declaration of a declaration file's `declare module 'x'` is + // reached through an import of `x`, never by its bare name from a + // file that reaches no `x` (reachesAmbientModule, index.ts's + // resolveOne gate). + if outcome.status == "resolved" && outcome.resolved_by.as_deref() != Some("import") { + if let Some(tid) = outcome.target_node_id.clone() { + if let Some(target) = self.node_by_id(&tid)? { + if target.file_path != r.file_path { + if let Some(specifier) = self.ambient_specifier_of(&target)? { + if !self.reaches_ambient_module(r, &specifier, &target.file_path)? { + outcome = ResolveOutcome::unresolved(); + } + } + } + } + } + } // A Pascal call to data runs only a routine the data holds, in // scope, and one to a routine nested in another comes only from // inside that one (gatePascalCall, resolveOne). diff --git a/codegraph-kernel/src/resolve/receivers.rs b/codegraph-kernel/src/resolve/receivers.rs index 9ce3043f87..9393630ea4 100644 --- a/codegraph-kernel/src/resolve/receivers.rs +++ b/codegraph-kernel/src/resolve/receivers.rs @@ -378,7 +378,7 @@ impl KernelResolver { /// wrappers. The type must be declared, or imported under its own name, /// in the factory's module, and not collide with a different same-named /// type in the caller. `await` forms belong to infer_esm_awaited_call_type. - fn untyped_local_call_type( + pub(super) fn untyped_local_call_type( &mut self, lines: &[String], at: usize, diff --git a/codegraph-kernel/src/resolve/ts_known.rs b/codegraph-kernel/src/resolve/ts_known.rs index 68488c0b84..381e80b1ba 100644 --- a/codegraph-kernel/src/resolve/ts_known.rs +++ b/codegraph-kernel/src/resolve/ts_known.rs @@ -25,6 +25,11 @@ fn is_member_owner_kind(kind: &str) -> bool { /// Lines of a class header the type-parameter reader looks through: decorators, then `class C<…>` over several lines. const TS_CLASS_HEAD_LINES: usize = 24; +/// LAST_NAME_KINDS (import-resolver.ts): the kinds whose last name segment an +/// ambient declaration can answer even when no import binds the name itself. +const LAST_NAME_KINDS: &[&str] = + &["references", "extends", "implements", "type_of", "returns", "instantiates"]; + impl KernelResolver { /// tsTypeDeclaredIn: the class or interface named `name` that `file` declares. pub(super) fn ts_type_declared_in(&mut self, file: &str, name: &str) -> Res>> { @@ -1093,6 +1098,109 @@ impl KernelResolver { Ok(hit) } + /// ambientModuleDeclarations (index.ts): the specifier of the ambient + /// `declare module 'x'` block a block-level declaration sits in. + pub(super) fn ambient_specifier_of(&mut self, node: &KNode) -> Res> { + if node.qualified_name != node.name + || !matches!( + node.kind.as_str(), + "function" | "class" | "interface" | "type_alias" | "enum" | "constant" | "variable" | "component" + ) + { + return Ok(None); + } + let Some(scan) = self.ts_scope_scan(&node.file_path) else { return Ok(None) }; + Ok(scan + .blocks + .iter() + .find(|b| b.ambient && b.contains(node.start_line, node.start_column)) + .map(|b| b.name.clone())) + } + + /// isInModuleBlock: the ref itself sits inside `declare module + /// 'specifier'` — its declarations merge with the module's own. + fn ref_in_module_block(&mut self, r: &ResolveRefIn, specifier: &str) -> Res { + let Some(scan) = self.ts_scope_scan(&r.file_path) else { return Ok(false) }; + Ok(scan.blocks.iter().any(|b| { + b.ambient + && b.name.strip_prefix("node:").unwrap_or(&b.name) == specifier + && b.contains(r.line, r.column) + })) + } + + /// leadsTo (reachesAmbientModule): `source` reaches the ambient specifier + /// — it is the specifier, or a project path that resolves to the + /// declaring file, or (when `through`) to a file that imports the + /// specifier. + fn leads_to_ambient( + &mut self, + source: &str, + specifier: &str, + declaring_file: &str, + r: &ResolveRefIn, + through: bool, + ) -> Res { + if source == specifier { + return Ok(true); + } + if !(source.starts_with('.') || source.starts_with('/')) { + return Ok(false); + } + let Some(file) = self.resolve_import_path(source, &r.file_path, &r.language)? else { + return Ok(false); + }; + if file == declaring_file { + return Ok(true); + } + if !through { + return Ok(false); + } + let rows = self.bindings(&file)?; + Ok(rows.iter().any(|b| b.kind == "import" && b.target_spec.as_deref() == Some(specifier))) + } + + /// reachesAmbientModule (import-resolver.ts): the ambient declaration is + /// reached through an import of its specifier — the ref's own `declare + /// module` block merges with it; otherwise the name's binding (or, for a + /// last-name-kind ref, any of the file's imports) must reach the + /// specifier or the declaring file itself. + pub(super) fn reaches_ambient_module( + &mut self, + r: &ResolveRefIn, + specifier: &str, + declaring_file: &str, + ) -> Res { + if self.ref_in_module_block(r, specifier)? { + return Ok(true); + } + let head = r.reference_name.split('.').next().unwrap_or(""); + let binding = { + let rows = self.bindings(&r.file_path)?; + rows.iter() + .find(|b| b.kind == "import" && b.name == head) + .and_then(|b| b.target_spec.clone()) + }; + if let Some(source) = binding { + return self.leads_to_ambient(&source, specifier, declaring_file, r, true); + } + if !LAST_NAME_KINDS.contains(&r.reference_kind.as_str()) { + return Ok(false); + } + let sources: Vec = { + let rows = self.bindings(&r.file_path)?; + rows.iter() + .filter(|b| b.kind == "import") + .filter_map(|b| b.target_spec.clone()) + .collect() + }; + for source in sources { + if self.leads_to_ambient(&source, specifier, declaring_file, r, false)? { + return Ok(true); + } + } + Ok(false) + } + fn ts_declared_ambient_receiver(&mut self, receiver: &str, r: &ResolveRefIn) -> Res>> { let Some(source) = self.read_file(&r.file_path) else { return Ok(None) }; let Some(tree) = self.js_binding_tree(&source, r) else { return Ok(None) }; diff --git a/src/extraction/kernel/loader.ts b/src/extraction/kernel/loader.ts index 5317907151..60bccd3bc9 100644 --- a/src/extraction/kernel/loader.ts +++ b/src/extraction/kernel/loader.ts @@ -287,6 +287,8 @@ export interface ResolveOutcome { /** On an unresolved TS/JS member call, the head of the type its receiver is declared with. */ typeHead?: string; typeFrom?: string[]; + /** A function used as a value the kernel resolved (a paren-less Pascal method pointer). */ + fnRef?: boolean; } export interface KernelResolverLike { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 963fa72615..4b5749326f 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1448,7 +1448,9 @@ export class ReferenceResolver { targetNodeId: outcome.targetNodeId, edgeKind: outcome.edgeKind as ResolvedRef['edgeKind'], alsoTargets: outcome.alsoTargetNodeIds?.map(targetNodeId => ({ targetNodeId })), - ...(outcome.typeFrom?.length ? { metadata: { typeFrom: outcome.typeFrom } } : {}), + ...(outcome.typeFrom?.length || outcome.fnRef + ? { metadata: { ...(outcome.typeFrom?.length ? { typeFrom: outcome.typeFrom } : {}), ...(outcome.fnRef ? { fnRef: true } : {}) } } + : {}), confidence: outcome.confidence ?? 0, resolvedBy: (outcome.resolvedBy ?? 'exact-match') as ResolvedRef['resolvedBy'], }; From 45e6d66eeb8ca1974a3a4934931587fa27f68fa8 Mon Sep 17 00:00:00 2001 From: bompus Date: Thu, 29 Oct 2026 15:14:08 -0600 Subject: [PATCH 2/3] fix(explore): honor the fork's explicit substring ordering and bound long covering-test ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - name-substring-index: the fork (#258) made result order explicit — (length, kind, rowid) — and reshaped idx_nodes_kind for it. Keep upstream's memoized substring index but order its hits by that contract instead of simulating whichever plan SQLite picked; the plan-detection machinery goes with it. - explore-source-ranges/tools: a covering test longer than WHOLE_TEST_MAX_LINES emitted its whole span and truncated before the requested call+assertion. Long callbacks now emit their header plus the statement ranges, and sourceMinimums reserves every no-nodeId range of a covering test, not just the first. --- __tests__/explore-long-query.test.ts | 17 ++++---- src/db/name-substring-index.ts | 36 +++++++--------- src/db/queries.ts | 62 +++++----------------------- src/mcp/explore-source-ranges.ts | 15 +++++-- src/mcp/tools.ts | 4 +- 5 files changed, 48 insertions(+), 86 deletions(-) diff --git a/__tests__/explore-long-query.test.ts b/__tests__/explore-long-query.test.ts index 5f635564fe..d44fc8d6d3 100644 --- a/__tests__/explore-long-query.test.ts +++ b/__tests__/explore-long-query.test.ts @@ -48,12 +48,12 @@ const DEF = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum const CALL = ['function', 'method', 'component']; const SEED = ['function', 'method', 'component', 'variable', 'constant']; -/** The lookup as it was before the index: a LIKE scan, ties left to the plan. */ +/** The lookup as a LIKE scan: the rows the in-memory index must reproduce. */ function likeScan(db: SqliteDatabase, substring: string, kinds: string[], excludePrefix: boolean, limit: number): string[] { let sql = 'SELECT nodes.*, 1.0 as score FROM nodes WHERE name LIKE ?'; const params: (string | number)[] = [`%${substring}%`]; if (excludePrefix) { sql += ' AND name NOT LIKE ?'; params.push(`${substring}%`); } - sql += ` AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY length(name) ASC LIMIT ?`; + sql += ` AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY length(name) ASC, kind ASC, nodes.rowid ASC LIMIT ?`; return (db.prepare(sql).all(...params, ...kinds, limit) as Array<{ id: string }>).map((row) => row.id); } @@ -108,7 +108,7 @@ describe('name-substring lookups give the rows a LIKE scan gives (#2620)', () => return cg; } - function expectSameRows(cg: CodeGraph, plan: 'rowid' | 'index'): void { + function expectSameRows(cg: CodeGraph): void { const q = queriesOf(cg); const db = rawDb(cg); let compared = 0; @@ -125,13 +125,10 @@ describe('name-substring lookups give the rows a LIKE scan gives (#2620)', () => } } expect(compared).toBeGreaterThan(900); - // The answers came from the index, under the plan SQLite chose. - const memo = (q as unknown as { readMemos: Map } | null }> }) + // The answers came from the index. + const memo = (q as unknown as { readMemos: Map }) .readMemos.get('nameSubstringIndex'); expect(memo?.value).toBeTruthy(); - const plans = [...memo!.value!.plans.values()]; - expect(plans).toContain(plan); - expect(plans).not.toContain(null); } it('matches when SQLite scans the table (a small index)', async () => { @@ -140,7 +137,7 @@ describe('name-substring lookups give the rows a LIKE scan gives (#2620)', () => `EXPLAIN QUERY PLAN SELECT * FROM nodes WHERE name LIKE '%x%' AND kind IN (${DEF.map(() => '?').join(',')}) ORDER BY length(name) LIMIT 200`, ).all(...DEF) as Array<{ detail: string }>; expect(plan.map((r) => r.detail)).toContain('SCAN nodes'); - expectSameRows(cg, 'rowid'); + expectSameRows(cg); }); it('matches when SQLite reads through the kind index (a large index)', async () => { @@ -155,7 +152,7 @@ describe('name-substring lookups give the rows a LIKE scan gives (#2620)', () => `EXPLAIN QUERY PLAN SELECT * FROM nodes WHERE name LIKE '%x%' AND kind IN (${DEF.map(() => '?').join(',')}) ORDER BY length(name) LIMIT 200`, ).all(...DEF) as Array<{ detail: string }>; expect(plan.map((r) => r.detail).some((d) => d.startsWith('SEARCH nodes USING INDEX idx_nodes_kind'))).toBe(true); - expectSameRows(cg, 'index'); + expectSameRows(cg); }); it('asks SQLite inside a transaction, and for a lookup without a kind filter', async () => { diff --git a/src/db/name-substring-index.ts b/src/db/name-substring-index.ts index 8ba3491e86..9da33955d0 100644 --- a/src/db/name-substring-index.ts +++ b/src/db/name-substring-index.ts @@ -12,11 +12,11 @@ * * It returns exactly what SQLite would. LIKE folds case for ASCII letters only, * `_` matches any one character, and `length()` counts characters, not UTF-16 - * units, so all three are done that way here. SQLite leaves rows of equal - * length in the order its plan read them: through `idx_nodes_kind` (kind, - * file_path, start_line, id) on a large index, by rowid when it scans a small - * one. The index keeps both orders, and the caller says which plan SQLite - * chose. A pattern with a `%` is left to SQLite. + * units, so all three are done that way here. The lookup's ORDER BY states its + * tie order — length, then kind, then rowid — so the answer does not move with + * the index shape (#258). NAME_SUBSTRING_KINDS is alphabetical, so an entry's + * kind code compares the way the `kind` column does. A pattern with a `%` is + * left to SQLite. */ /** @@ -170,15 +170,9 @@ function patternMatcher(needle: string): { next(hay: string, from: number): numb } /** - * The order SQLite's plan reads candidate rows in, which its ORDER BY keeps - * for rows of equal length: the kind index's, or the table's by rowid. - */ -export type ScanOrder = 'index' | 'rowid'; - -/** - * The rowids SQLite's substring lookup returns, in its order, or null when the - * index can't answer exactly (a `%` in the pattern, an empty pattern, or a - * kind it doesn't hold). `order` is the order the plan reads rows in. + * The rowids SQLite's substring lookup returns, in its ORDER BY order, or null + * when the index can't answer exactly (a `%` in the pattern, an empty pattern, + * or a kind it doesn't hold). */ export function searchNameSubstring( index: NameSubstringIndex, @@ -186,7 +180,6 @@ export function searchNameSubstring( kinds: readonly string[], excludePrefix: boolean, limit: number, - order: ScanOrder, ): number[] | null { const needle = asciiLower(substring); if (needle.length === 0 || needle.includes('%') || needle.includes(SEPARATOR)) return null; @@ -210,12 +203,15 @@ export function searchNameSubstring( } at = matcher.next(hay, starts[name + 1]!); } - // Entry numbers follow the kind index, rowids the table: either breaks - // length ties the way SQLite's plan does. + // The lookup's ORDER BY ties break by kind (the codes follow the + // alphabetical NAME_SUBSTRING_KINDS) and then rowid. const rowid = index.entryRowid; - hits.sort(order === 'index' - ? (a, b) => (entryLength[a]! - entryLength[b]!) || (a - b) - : (a, b) => (entryLength[a]! - entryLength[b]!) || (rowid[a]! - rowid[b]!)); + hits.sort( + (a, b) => + (entryLength[a]! - entryLength[b]!) || + (entryKind[a]! - entryKind[b]!) || + (rowid[a]! - rowid[b]!), + ); // A negative LIMIT is no limit in SQLite. if (limit >= 0 && hits.length > limit) hits.length = limit; return hits.map((entry) => index.entryRowid[entry]!); diff --git a/src/db/queries.ts b/src/db/queries.ts index fa6a08cce1..f75735acc3 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -23,7 +23,7 @@ import { parseQuery, boundedEditDistance } from '../search/query-parser'; import { isGeneratedFile } from '../extraction/generated-detection'; import { splitIdentifierSegments } from '../search/identifier-segments'; import { isModuleKey, referenceNameTail, MEMBER_TAIL_SQL, MODULE_PARKED_SQL } from './reference-tail'; -import { NAME_SUBSTRING_KINDS, buildNameSubstringIndex, searchNameSubstring, type NameSubstringIndex, type ScanOrder } from './name-substring-index'; +import { NAME_SUBSTRING_KINDS, buildNameSubstringIndex, searchNameSubstring, type NameSubstringIndex } from './name-substring-index'; /** * Files that should not be candidates for "dominant file" detection: tests, @@ -2512,15 +2512,14 @@ export class QueryBuilder { // Explore asks this four times for every word of its query, and the // leading `%` makes each one a scan of every node of those kinds (#2620). - // The in-memory index answers with the same rows in the same order: ties - // in length come out in the order SQLite's plan for this very statement - // reads rows, the kind index's or the table's. Anything it can't answer - // exactly (no kind filter, a language filter, a `%` in the word, another - // plan, a transaction) is asked of SQLite. + // The in-memory index answers with the same rows in the same order: the + // ORDER BY states its ties (length, kind, rowid), so the answer does not + // depend on SQLite's plan or the index shape (#258). Anything it can't + // answer exactly (no kind filter, a language filter, a `%` in the word, a + // transaction) is asked of SQLite. if (kinds && kinds.length > 0 && !(languages && languages.length > 0)) { const index = this.memoizedRead('nameSubstringIndex', () => this.readNameSubstringIndex()); - const order = index ? this.substringScanOrder(index, sql, params, excludePrefix ? 2 : 1) : null; - const rowids = order ? searchNameSubstring(index!.index, substring, kinds, excludePrefix === true, limit, order) : null; + const rowids = index ? searchNameSubstring(index, substring, kinds, excludePrefix === true, limit) : null; if (rowids) { return this.getNodeRowsByRowid(rowids).map((row) => ({ node: rowToNode(row), score: 1.0 })); } @@ -2533,55 +2532,14 @@ export class QueryBuilder { })); } - /** - * The order SQLite's plan for a substring lookup reads candidate rows in, - * which is the order its ORDER BY leaves ties of equal length in: the kind - * index's (kind, file_path, start_line, id), or the table's (rowid). Which - * one depends on the statistics — a small index is scanned whole — so it is - * asked of SQLite once per statement shape. Null for any other plan. - */ - private substringScanOrder( - memo: { index: NameSubstringIndex; plans: Map }, - sql: string, - params: readonly (string | number)[], - patternCount: number, - ): ScanOrder | null { - // The patterns (the first `patternCount` parameters) can't change the - // plan, since a leading `%` uses no index; the kinds and the limit can. - const key = `${sql}\u0000${params.slice(patternCount).join('\u0000')}`; - let order = memo.plans.get(key); - if (order === undefined) { - order = null; - try { - const details = (this.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{ detail: string }>) - .map((row) => row.detail) - .filter((detail) => /^(SCAN|SEARCH) (TABLE )?nodes\b/.test(detail)); - if (details.length === 1) { - if (/^SEARCH (TABLE )?nodes USING (COVERING )?INDEX idx_nodes_kind \(/.test(details[0]!)) order = 'index'; - else if (/^SCAN (TABLE )?nodes$/.test(details[0]!)) order = 'rowid'; - } - } catch { - order = null; - } - memo.plans.set(key, order); - } - return order; - } - - /** - * Every node of the kinds the name-substring index holds, read in - * idx_nodes_kind order (kind, file_path, start_line, id): the order its - * results break length ties by. - */ - private readNameSubstringIndex(): { index: NameSubstringIndex; plans: Map } | null { + /** Every node of the kinds the name-substring index holds. */ + private readNameSubstringIndex(): NameSubstringIndex | null { const rows = this.db .prepare( `SELECT rowid AS rowid, kind, name FROM nodes WHERE kind IN (${NAME_SUBSTRING_KINDS.map(() => '?').join(',')})` - + ' ORDER BY kind, file_path, start_line, id' ) .all(...NAME_SUBSTRING_KINDS) as Array<{ rowid: number; kind: string; name: string }>; - const index = buildNameSubstringIndex(rows); - return index ? { index, plans: new Map() } : null; + return buildNameSubstringIndex(rows); } /** Whole node rows for `rowids`, in that order; a rowid no longer present is skipped. */ diff --git a/src/mcp/explore-source-ranges.ts b/src/mcp/explore-source-ranges.ts index 25c89b5be5..90b73bd7a4 100644 --- a/src/mcp/explore-source-ranges.ts +++ b/src/mcp/explore-source-ranges.ts @@ -12,6 +12,13 @@ export interface RequestedSourceRange { nodeId?: string; } +/** + * A test callback at most this many lines renders as one range. A longer one + * shows its opening lines plus its matching statements separately: the single + * range would truncate before the call site and assertion it wraps. + */ +const WHOLE_TEST_MAX_LINES = 80; + /** Source evidence inside an already selected file; never adds graph nodes or edges. */ export async function requestedSourceRanges( filePath: string, source: string, language: Language, query: string, nodes: readonly Node[] = [], @@ -103,9 +110,11 @@ export async function requestedSourceRanges( const callHit = callLines.some(line => line >= node.startPosition.row + 1 && line <= node.endPosition.row + 1); const hit = score(node.text) + score(args[0]?.text ?? '') + (callHit ? scoreCeiling : 0); if (hit > 0) { - ranges.push({ - start: node.startPosition.row + 1, end: node.endPosition.row + 1, name: 'test', score: hit + 1, - }); + const start = node.startPosition.row + 1; + const end = node.endPosition.row + 1; + ranges.push(end - start + 1 <= WHOLE_TEST_MAX_LINES + ? { start, end, name: 'test', score: hit + 1 } + : { start, end: Math.min(end, start + 4), name: 'test', score: hit + 1 }); // A long test may not fit whole. Its matching setup/assertion // statements remain complete units, including multiline arrays. const testStatements: SyntaxNode[] = []; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 323afcc672..f7b2fc8a12 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -6503,7 +6503,9 @@ export class ToolHandler { && n.endLine - n.startLine + 1 <= sourceLines.length / 2) .map(n => ({ start: n.startLine, end: n.endLine })); if (supporting && localCallees.size === 0) needed.push(...requested.filter(r => r.nodeId && r.score === supporting.score)); - if (region) needed.push(...(fp.endsWith('.vue') && !supporting ? requested.filter(r => !r.nodeId) : [region])); + if (region) needed.push(...(fp.endsWith('.vue') && !supporting ? requested.filter(r => !r.nodeId) + : coveringTestLines.has(fp) ? requested.filter(r => !r.nodeId) + : [region])); for (const n of group.nodes) { if ((localCallers.has(n.id) || localCallees.has(n.id)) && n.endLine - n.startLine < 200) { needed.push({ start: n.startLine, end: n.endLine }); From 9954a3608ec8c8442f1af91d820f047927ecaad7 Mon Sep 17 00:00:00 2001 From: bompus Date: Thu, 29 Oct 2026 15:31:40 -0600 Subject: [PATCH 3/3] perf(kernel): share Go declaration walks across a ref's resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go receiver path re-ran the same ancestor scans at the same site: go_written_qualifier and resolve_go_cross_package each shadow-checked the import root with a full local_at walk; go_asserted_local_type ran twice per ref (once in go_asserted_member, again inside go_named_local_written); and go_named_local_written repeated the local_declaration_in ancestor walk go_local_declaration_at had just done for the same name+site. - resolve_go_cross_package skips its is_shadowed_import when go_ref_qualifier already proved the receiver is the written qualifier - go_local_decl_memo (keyed file+name+site byte) shares the local_decl_at walk — its value keeps the target identifier's byte range so go_named_local_written re-derives the node instead of re-walking - go_asserted_type_memo dedupes the asserted-type scan kernel-prof on the gin corpus, calls|go nonbare refs: ~261 -> ~206 ms (-21%), bound_receiver_claim 208 -> ~165 ms. Precision identical (8417 edges, 3/3 held); golden dumps and all 459 Go tests green. --- .../precision-gin-dcaa429-45e6d66e.json | 59 +++++++++++++++++++ codegraph-kernel/src/resolve/go_asserted.rs | 43 +++++++++++--- codegraph-kernel/src/resolve/go_value.rs | 29 +++++---- codegraph-kernel/src/resolve/imports.rs | 7 ++- codegraph-kernel/src/resolve/method_call.rs | 10 ++-- codegraph-kernel/src/resolve/mod.rs | 11 ++++ 6 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 __tests__/evaluation/results/precision-gin-dcaa429-45e6d66e.json diff --git a/__tests__/evaluation/results/precision-gin-dcaa429-45e6d66e.json b/__tests__/evaluation/results/precision-gin-dcaa429-45e6d66e.json new file mode 100644 index 0000000000..c8ca88f0e9 --- /dev/null +++ b/__tests__/evaluation/results/precision-gin-dcaa429-45e6d66e.json @@ -0,0 +1,59 @@ +{ + "timestamp": "2026-10-29T21:30:48.620Z", + "corpus": "gin", + "repo": "https://github.com/gin-gonic/gin.git", + "commit": "dcaa429", + "codegraphSha": "45e6d66e", + "summary": { + "absentCases": 1, + "absentHeld": 1, + "presentCases": 2, + "presentHeld": 2, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 3593, + "exact-match": 2568, + "instance-method": 1814, + "framework": 228, + "file-path": 104, + "import": 96, + "function-ref": 14 + }, + "edges": 8417, + "results": [ + { + "caseId": "gin-declared-factory-receiver", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:285dcfdab6ec00ce5d1c87eddc233f0d", + "to": "method:08a6ca18de8a7f08f33790ca286e0a20", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "gin-composite-receiver-json-control", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:e6a071cb9b3ce2eeba6fb82f941cef3d", + "to": "method:3ab506d0531aac0489e0c51fcc2128aa", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "gin-composite-receiver-not-bson", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/codegraph-kernel/src/resolve/go_asserted.rs b/codegraph-kernel/src/resolve/go_asserted.rs index e30c9ba06a..1a0a08bc43 100644 --- a/codegraph-kernel/src/resolve/go_asserted.rs +++ b/codegraph-kernel/src/resolve/go_asserted.rs @@ -146,6 +146,14 @@ impl KernelResolver { /// of `name` in scope at `r` binds the value of a type assertion. pub(super) fn go_asserted_local_type(&mut self, name: &str, r: &ResolveRefIn) -> Option { if r.language != "go" || name.is_empty() || !name.bytes().all(|b| b == b'_' || b.is_ascii_alphanumeric()) { return None; } + let key = (r.file_path.clone(), name.to_string(), r.line, r.column); + if let Some(hit) = self.go_asserted_type_memo.get(&key) { return hit.clone(); } + let out = self.go_asserted_local_type_uncached(name, r); + self.go_asserted_type_memo.insert(key, out.clone()); + out + } + + fn go_asserted_local_type_uncached(&mut self, name: &str, r: &ResolveRefIn) -> Option { let file = self.read_file(&r.file_path)?; let text = file.text(); // Only a file that asserts a type can bind from one. @@ -344,8 +352,13 @@ pub(super) fn local_declaration_in<'a>(scope: TsNode<'a>, name: &str, site: usiz } #[cfg_attr(feature = "addon", napi(object))] +#[derive(Clone)] pub struct GoLocalDeclaration { pub line: i64, pub column: i64, pub declared_type: Option } +/// A memoized `local_decl_at` result: the target identifier's byte range +/// plus its declaration, for callers that re-derive the node. +pub(super) type GoDeclHit = Option<(usize, usize, GoLocalDeclaration)>; + /// Source-only lexical lookup. Synthesis must not attach to the live WAL. #[cfg_attr(feature = "addon", napi)] pub struct GoSourceScope { @@ -367,14 +380,14 @@ impl GoSourceScope { } } -fn local_at(tree: &tree_sitter::Tree, source: &str, name: &str, line: i64, column: i64) -> Option { - let site = super::iteration::descendant_for_position(tree.root_node(), source, - ((line - 1).max(0) as usize, column.max(0) as usize)); +/// The ancestor walk shared by `local_at`, the resolver's decl memo and +/// `go_named_local_written`: the target identifier node plus its declaration. +pub(super) fn local_decl_at<'a>(source: &str, name: &str, site: TsNode<'a>) -> Option<(TsNode<'a>, GoLocalDeclaration)> { let mut scope = Some(site); while let Some(node) = scope { if let Some((target, ty)) = local_declaration_in(node, name, site.start_byte(), source) { - return Some(GoLocalDeclaration { line: target.start_position().row as i64 + 1, - column: target.start_position().column as i64, declared_type: ty }); + return Some((target, GoLocalDeclaration { line: target.start_position().row as i64 + 1, + column: target.start_position().column as i64, declared_type: ty })); } if matches!(node.kind(), "function_declaration" | "method_declaration") { break; } scope = node.parent(); @@ -382,13 +395,27 @@ fn local_at(tree: &tree_sitter::Tree, source: &str, name: &str, line: i64, colum None } +fn local_at(tree: &tree_sitter::Tree, source: &str, name: &str, line: i64, column: i64) -> Option { + let site = super::iteration::descendant_for_position(tree.root_node(), source, + ((line - 1).max(0) as usize, column.max(0) as usize)); + local_decl_at(source, name, site).map(|(_, decl)| decl) +} + impl KernelResolver { pub(super) fn go_local_declaration_at(&mut self, name: String, file_path: String, line: i64, column: i64) -> Result> { let r = ResolveRefIn { row_id: None, from_node_id: String::new(), reference_name: name.clone(), reference_kind: "references".into(), line, column, candidates: None, file_path: file_path.clone(), language: "go".into(), failure_reason: None, generated: None }; - let Some(file) = self.read_file(&file_path) else { return Ok(None) }; - let Some(tree) = self.parsed_tree(&file, &r) else { return Ok(None) }; - Ok(local_at(&tree, file.text(), &name, line, column)) + let out = self.read_file(&file_path).and_then(|file| self.parsed_tree(&file, &r).map(|tree| { + let site = super::iteration::descendant_for_position(tree.root_node(), file.text(), + ((line - 1).max(0) as usize, column.max(0) as usize)); + let key = (file_path.clone(), name.clone(), site.start_byte()); + if let Some(hit) = self.go_local_decl_memo.get(&key) { return hit.clone(); } + let hit = local_decl_at(file.text(), &name, site) + .map(|(t, decl)| (t.start_byte(), t.end_byte(), decl)); + self.go_local_decl_memo.insert(key, hit.clone()); + hit + })).unwrap_or_default(); + Ok(out.map(|(_, _, decl)| decl)) } } diff --git a/codegraph-kernel/src/resolve/go_value.rs b/codegraph-kernel/src/resolve/go_value.rs index 814c31abfa..6b34926fb0 100644 --- a/codegraph-kernel/src/resolve/go_value.rs +++ b/codegraph-kernel/src/resolve/go_value.rs @@ -517,20 +517,21 @@ impl KernelResolver { file_text: &str, r: &ResolveRefIn, ) -> Res> { - use super::go_asserted::local_declaration_in; let file = r.file_path.clone(); - let mut scope = anchor; - let found = loop { - if let Some(hit) = local_declaration_in(scope, name, anchor.start_byte(), file_text) { - break Some(hit); - } - match scope.parent() { - Some(p) => scope = p, - None => break None, + // The same walk `go_local_declaration_at` memoizes — the typed path + // asks for this name at this site more than once per ref. + let key = (file.clone(), name.to_string(), anchor.start_byte()); + let found = match self.go_local_decl_memo.get(&key) { + Some(hit) => hit.clone(), + None => { + let hit = super::go_asserted::local_decl_at(file_text, name, anchor) + .map(|(t, decl)| (t.start_byte(), t.end_byte(), decl)); + self.go_local_decl_memo.insert(key, hit.clone()); + hit } }; - if let Some((target, declared)) = found { - if let Some(t) = declared { + if let Some((ts, te, decl)) = found { + if let Some(t) = decl.declared_type { return Ok(Some((t, file))); } if let Some(w) = self.go_asserted_local_type(name, r) { @@ -538,7 +539,11 @@ impl KernelResolver { } // An untyped `:=`/`var` — read the initializer itself: // `&m.F`, `f()`, `x[i]`, `[]T(v)` all carry a type. - if let Some((rhs, pos, range)) = Self::decl_rhs_node(target, name, file_text) { + let target_tree = self.read_file(&file).and_then(|f| self.parsed_tree(&f, r)); + let target = target_tree + .as_ref() + .and_then(|tree| tree.root_node().descendant_for_byte_range(ts, te)); + if let Some((rhs, pos, range)) = target.and_then(|t| Self::decl_rhs_node(t, name, file_text)) { let w = self.go_expr_written_at(rhs, if range { 0 } else { pos }, file_text, r)? .map(|(t, f)| if range { (Self::go_range_pick(&t, pos), f) } else { (t, f) }); if let Some(t) = w { diff --git a/codegraph-kernel/src/resolve/imports.rs b/codegraph-kernel/src/resolve/imports.rs index fe28863303..e762ec4e73 100644 --- a/codegraph-kernel/src/resolve/imports.rs +++ b/codegraph-kernel/src/resolve/imports.rs @@ -1724,9 +1724,12 @@ impl KernelResolver { let proven = self.go_ref_qualifier(r)?; let (receiver, member_name) = if let Some((receiver, member)) = r.reference_name.split_once('.') { (receiver.to_string(), member) - } else if let Some(imp) = proven { (imp.local_name, r.reference_name.as_str()) } + } else if let Some(imp) = &proven { (imp.local_name.clone(), r.reference_name.as_str()) } else { return Ok(None); }; - if member_name.is_empty() || self.is_shadowed_import(&receiver, r)? { return Ok(None); } + // `go_ref_qualifier` already shadow-checked the written root — a + // second `go_local_declaration_at` walk costs more than it proves. + let shadow_checked = proven.is_some_and(|imp| imp.local_name == receiver); + if member_name.is_empty() || (!shadow_checked && self.is_shadowed_import(&receiver, r)?) { return Ok(None); } for imp in imports { if imp.local_name != receiver { continue; } let Some(pkg_dir) = self.go_package_dir(&imp.source, &r.file_path) else { continue }; diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index 037a979f41..12b86d6dc3 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -1614,7 +1614,7 @@ if inferred.is_none() && r.language == "dart" { let bindings = self.bindings(&r.file_path)?; // Svelte/Astro markup and a second script sit outside the rows' // scopes but still see a script's top-level import. - let binding = self.receiver_binding(root, r)? + let binding = probe!(r, "brc:recv-binding", self.receiver_binding(root, r)?) .or_else(|| { is_sfc_scoped_script(&r.language) .then(|| sfc_top_level_binding(&bindings, root).filter(|b| b.kind == "import").cloned()) @@ -1633,7 +1633,7 @@ if inferred.is_none() && r.language == "dart" { if (r.language == "java" || r.language == "kotlin" || r.language == "python") && self.resolve_bound_type(root, r, 0)?.is_some() { return if receiver == root { self.match_bound_type_member(root, method, r) } else { self.enum_constant_call(receiver, method, r) }; } - let via_import = self.resolve_via_import_member(r)?.filter(|c| { + let via_import = probe!(r, "brc:via-import", self.resolve_via_import_member(r)?).filter(|c| { // A Go `pkg.Name` can name any exported package member — // `pkg.Type(…)` conversions included; edge-kind rewriting // decides calls-vs-references downstream. @@ -1655,7 +1655,7 @@ if inferred.is_none() && r.language == "dart" { .captures(receiver) .or_else(|| re!(r"^([A-Za-z_]\w*\.[A-Za-z_]\w*)$").captures(receiver)) { - Some(m) => self.go_method_on_declared_type(&m[1], &r.file_path, method, r)?, + Some(m) => probe!(r, "brc:go-tail", self.go_method_on_declared_type(&m[1], &r.file_path, method, r)?), None => None, } } @@ -1664,7 +1664,7 @@ if inferred.is_none() && r.language == "dart" { } let hit = probe!(r, "brc:match_method_call", self.match_method_call(r)?); if hit.is_none() && r.language == "go" && self.take_go_mc_untyped() { - return self.match_method_call_free(r); + return probe!(r, "brc:mc-free", self.match_method_call_free(r)); } return Ok(hit); } @@ -1755,7 +1755,7 @@ if inferred.is_none() && r.language == "dart" { return Ok(Some(c)); } if r.language == "go" && self.take_go_mc_untyped() { - return self.match_method_call_free(r); + return probe!(r, "brc:mc-free", self.match_method_call_free(r)); } if binding.kind == "param" { // An unannotated parameter bound the name but says nothing of diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 5abbe520fc..df26225b60 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -869,6 +869,15 @@ pub struct KernelResolver { /// local_declaration_scope, by candidate id — its own map so the key /// needs no "local:"-prefixed format! alloc. local_decl_scope_memo: HashMap>, + /// local_decl_at, by (file, name, site byte) — the typed Go receiver + /// path (`go_local_declaration_at`, `go_named_local_written`) and the + /// import-shadow gate walk the same ancestors for the same name+site. + /// The value keeps the target identifier's byte range so a caller that + /// needs the node itself can re-derive it from the tree. + go_local_decl_memo: HashMap<(String, String, usize), go_asserted::GoDeclHit>, + /// go_asserted_local_type, by (file, name, line, column) — the asserted + /// receiver check runs again inside go_named_local_written. + go_asserted_type_memo: HashMap<(String, String, i64, i64), Option>, language_type_scope_memo: HashMap>, scala_package_object_membership_memo: HashMap>, cpp_default_declaration_memo: HashMap, @@ -1130,6 +1139,8 @@ impl KernelResolver { bare_receiver_memo: HashMap::new(), gen_path_memo: HashMap::new(), local_decl_scope_memo: HashMap::new(), + go_local_decl_memo: HashMap::new(), + go_asserted_type_memo: HashMap::new(), language_type_scope_memo: HashMap::new(), scala_package_object_membership_memo: HashMap::new(), cpp_default_declaration_memo: HashMap::new(),