From 9d26c70ab0c8999a327a54ae0f0d1de0abbe3e24 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Mon, 31 Aug 2026 21:15:12 +0800 Subject: [PATCH 1/4] fix: port upstream graph correctness and watcher reloads --- AGENTS.md | 35 +- README.md | 14 + .../fixtures/cpp/plain_derived.h | 12 + .../fixtures/cpp/plain_header_negatives.h | 18 + .../fixtures/erlang/client.erl | 11 + .../codegraph-bench/fixtures/erlang/deleg.erl | 13 + .../codegraph-bench/fixtures/erlang/multi.erl | 8 + .../fixtures/erlang/single.erl | 5 + .../fixtures/erlang/spawner.erl | 6 + .../codegraph-bench/fixtures/erlang/store.erl | 8 + .../codegraph-bench/fixtures/lua/handlers.lua | 35 + .../fixtures/python/aliases.py | 15 + .../fixtures/python/pkg/__init__.py | 1 + .../fixtures/python/pkg/module.py | 2 + .../fixtures/python/top_level.py | 2 + .../fixtures/rust/impl_ownership.rs | 95 ++ .../fixtures/rust/self_field.rs | 92 ++ .../fixtures/typescript/alias_consumer.ts | 5 + .../typescript/config/tsconfig.base.json | 9 + .../fixtures/typescript/legacy_consumer.xsjs | 7 + .../typescript/legacy_helpers.xsjslib | 1 + .../fixtures/typescript/legacy_priority.js | 1 + .../fixtures/typescript/legacy_priority.xsjs | 1 + .../fixtures/typescript/namespace.ts | 21 + .../fixtures/typescript/namespace_consumer.ts | 7 + .../fixtures/typescript/src/aliased.ts | 1 + .../fixtures/typescript/tsconfig.json | 3 + crates/codegraph-bench/tests/equivalence.rs | 29 + .../tests/batch_m_long_lived_mcp.rs | 125 +- crates/codegraph-extract/src/engine.rs | 93 +- crates/codegraph-extract/src/lang/cpp.rs | 2 +- crates/codegraph-extract/src/lang/erlang.rs | 130 +- crates/codegraph-extract/src/lang/lua.rs | 2 +- crates/codegraph-extract/src/lang/luau.rs | 2 +- crates/codegraph-extract/src/lang/mod.rs | 7 +- crates/codegraph-extract/src/lang/rust.rs | 102 +- crates/codegraph-extract/src/walker.rs | 865 ++++++++++++- crates/codegraph-mcp/src/engine.rs | 56 +- .../codegraph-resolve/src/import_resolver.rs | 620 ++++++++-- crates/codegraph-resolve/src/name_matcher.rs | 988 +++++++++++++++ crates/codegraph-resolve/src/path_aliases.rs | 408 +++++- crates/codegraph-resolve/src/resolver.rs | 124 +- crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- crates/codegraph-watch/src/sync.rs | 156 ++- crates/codegraph-watch/src/watcher.rs | 637 ++++++++-- docs/architecture.md | 8 +- docs/cli.md | 28 + docs/equivalence.md | 187 ++- docs/languages.md | 62 +- docs/mcp.md | 7 + docs/readme/README.zh-CN.md | 23 +- docs/upstream-sync/V1_6_TRIAGE_2026-08-27.md | 15 +- reference/golden/cpp/colby.db | Bin 221184 -> 229376 bytes reference/golden/cpp/edges.json | 102 ++ reference/golden/cpp/files.json | 18 + reference/golden/cpp/nodes.json | 176 +++ reference/golden/erlang/colby.db | Bin 155648 -> 163840 bytes reference/golden/erlang/edges.json | 240 +++- reference/golden/erlang/files.json | 54 + reference/golden/erlang/nodes.json | 532 +++++++- reference/golden/erlang/refs.json | 62 +- reference/golden/lua/colby.db | Bin 0 -> 155648 bytes reference/golden/lua/edges.json | 161 +++ reference/golden/lua/files.json | 11 + reference/golden/lua/nodes.json | 178 +++ reference/golden/lua/refs.json | 1 + reference/golden/lua/schema.sql | 119 ++ reference/golden/python/colby.db | Bin 155648 -> 163840 bytes reference/golden/python/edges.json | 147 ++- reference/golden/python/files.json | 36 + reference/golden/python/nodes.json | 264 ++++ reference/golden/python/refs.json | 20 + reference/golden/rust/colby.db | Bin 155648 -> 200704 bytes reference/golden/rust/edges.json | 600 +++++++++ reference/golden/rust/files.json | 18 + reference/golden/rust/nodes.json | 1096 +++++++++++++++-- reference/golden/rust/refs.json | 50 + reference/golden/typescript/colby.db | Bin 155648 -> 180224 bytes reference/golden/typescript/edges.json | 357 ++++++ reference/golden/typescript/files.json | 72 ++ reference/golden/typescript/nodes.json | 550 +++++++++ reference/golden/typescript/refs.json | 20 + skills/codegraph/SKILL.md | 13 + 84 files changed, 9315 insertions(+), 690 deletions(-) create mode 100644 crates/codegraph-bench/fixtures/cpp/plain_derived.h create mode 100644 crates/codegraph-bench/fixtures/cpp/plain_header_negatives.h create mode 100644 crates/codegraph-bench/fixtures/erlang/client.erl create mode 100644 crates/codegraph-bench/fixtures/erlang/deleg.erl create mode 100644 crates/codegraph-bench/fixtures/erlang/multi.erl create mode 100644 crates/codegraph-bench/fixtures/erlang/single.erl create mode 100644 crates/codegraph-bench/fixtures/erlang/spawner.erl create mode 100644 crates/codegraph-bench/fixtures/erlang/store.erl create mode 100644 crates/codegraph-bench/fixtures/lua/handlers.lua create mode 100644 crates/codegraph-bench/fixtures/python/aliases.py create mode 100644 crates/codegraph-bench/fixtures/python/pkg/__init__.py create mode 100644 crates/codegraph-bench/fixtures/python/pkg/module.py create mode 100644 crates/codegraph-bench/fixtures/python/top_level.py create mode 100644 crates/codegraph-bench/fixtures/rust/impl_ownership.rs create mode 100644 crates/codegraph-bench/fixtures/rust/self_field.rs create mode 100644 crates/codegraph-bench/fixtures/typescript/alias_consumer.ts create mode 100644 crates/codegraph-bench/fixtures/typescript/config/tsconfig.base.json create mode 100644 crates/codegraph-bench/fixtures/typescript/legacy_consumer.xsjs create mode 100644 crates/codegraph-bench/fixtures/typescript/legacy_helpers.xsjslib create mode 100644 crates/codegraph-bench/fixtures/typescript/legacy_priority.js create mode 100644 crates/codegraph-bench/fixtures/typescript/legacy_priority.xsjs create mode 100644 crates/codegraph-bench/fixtures/typescript/namespace.ts create mode 100644 crates/codegraph-bench/fixtures/typescript/namespace_consumer.ts create mode 100644 crates/codegraph-bench/fixtures/typescript/src/aliased.ts create mode 100644 crates/codegraph-bench/fixtures/typescript/tsconfig.json create mode 100644 reference/golden/lua/colby.db create mode 100644 reference/golden/lua/edges.json create mode 100644 reference/golden/lua/files.json create mode 100644 reference/golden/lua/nodes.json create mode 100644 reference/golden/lua/refs.json create mode 100644 reference/golden/lua/schema.sql diff --git a/AGENTS.md b/AGENTS.md index a364d641..914826e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,27 +19,41 @@ Protocol) stdio server. No AI / vector / LLM anywhere in the binary — output i `crates/codegraph-bench/fixtures/python/`; guards six bare class-as-value `References` edges — return, assignment RHS, registry pair, call argument, list literal, and one cross-file unique match — plus tuple-return and bare-method - negatives, byte-for-byte), `reference/golden/kotlin/` (corpus + negatives, plus #1626 Python module/member import aliases and their no-global-fallback + boundaries, byte-for-byte), `reference/golden/kotlin/` (corpus `crates/codegraph-bench/fixtures/kotlin/`; guards #1495 Kotlin callable signatures for explicit, inferred, generic, multiline nullable, and extension returns, plus a primary-constructor negative — byte-for-byte), `reference/golden/typescript/` (corpus `crates/codegraph-bench/fixtures/typescript/`; guards #1482 local export aliases and #1482b TypeScript `.js`-specifier - substitution, including TS-over-JS collision precedence — byte-for-byte), and + substitution, object-literal callable members, inherited/config-relative TS + path aliases, and `.xsjs`/`.xsjslib` import candidates with ordinary-JS + precedence — byte-for-byte), and `reference/golden/cpp/` (corpus `crates/codegraph-bench/fixtures/cpp/`; guards #1043 C++ class/struct inheritance incl. templated-base stripping byte-for-byte, and retroactively - the earlier C++ extraction work; grown to 17 files by Tier 3 with the MSVC COM + the earlier C++ extraction work; grown to 19 files with full-source lexically + masked plain-derived `.h` detection and its text/bitfield/label/ternary + negatives, plus the Tier 3 MSVC COM `interface` positives + five guard negatives (#1519) and the C / C++ / ObjC `union` fixtures incl. both `typedef union` re-kinds and both instantiation paths (PR #1516) — it is the C-FAMILY corpus, not a strictly-C++ one, since it holds `.c` and `.mm` files too), and `reference/golden/rust/` - (corpus `crates/codegraph-bench/fixtures/rust/`; the repo's only Rust corpus, + (four-file corpus `crates/codegraph-bench/fixtures/rust/`; the repo's only Rust corpus, guards #1513/PR #1514 unit structs — 3 `struct` nodes and 4 `implements` edges, where the unit struct produced NO node before — plus the Rust half of PR #1516's - first-class `union` kind, byte-for-byte; it deliberately claims NO `instantiates` - edge, because that edge fires only for the call-expression construction form and - a Rust union is constructible only as `U { … }`), and `reference/golden/go/` + first-class `union` kind, generic/lifetime/reference/qualified impl ownership, + and the statically safe subset of `self.field.method()` resolution, byte-for-byte; + it deliberately claims NO `instantiates` edge, because that edge fires only for + the call-expression construction form and a Rust union is constructible only as + `U { … }`), `reference/golden/lua/` (single-file corpus + `crates/codegraph-bench/fixtures/lua/`; guards #1616 local/table-field function + expressions, nested qualified names, body-call ownership, dot/colon calls, and + dynamic-key negatives — byte-for-byte), `reference/golden/erlang/` (seven-file + corpus `crates/codegraph-bench/fixtures/erlang/`; guards bare display names plus + `module::function/arity` identity across clauses, exports, specs, local/remote + calls, fun values, safe MFA dispatch, and binary-literal comma counting — + byte-for-byte), and `reference/golden/go/` (corpus `crates/codegraph-bench/fixtures/go/`; the repo's only Go corpus, and the only corpus where `files.generated` is anything other than `0` — it byte-pins #1500 content-header detection at BOTH values, three each way: `payroll.go` @@ -52,9 +66,10 @@ Protocol) stdio server. No AI / vector / LLM anywhere in the binary — output i import Gate 3b is deliberately unreachable for real Python nodes because the extractor does not mark them exported. Regen recipe: `docs/equivalence.md` "Godot fixture" / "Ruby fixture" / "Python fixture" / "Kotlin fixture" / - "TypeScript resolution fixture" / "C++ fixture" / "Rust fixture" / "Go fixture" - sections. `mini` has no re-indexable provenance and a schema migration cannot - normalise it in place, so it has its own "Mini schema rebuild" recipe there. + "TypeScript resolution fixture" / "C++ fixture" / "Rust fixture" / "Lua fixture" / + "Erlang fixture" / "Go fixture" sections. `mini` has no re-indexable provenance + and a schema migration cannot normalise it in place, so it has its own + "Mini schema rebuild" recipe there. - **node-id formula**: `{kind}:{sha256("{filePath}:{kind}:{name}:{line}").hex[:32]}`; file nodes are the literal `file:{relpath}`; lines are 1-based; paths relative with `/`. - **No AI / vector / LLM crates** — enforced by `scripts/guardrail.sh` (CI gate): diff --git a/README.md b/README.md index 45e9a37c..347a53cc 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,11 @@ Set `CODEGRAPH_NO_DAEMON=1` to force foreground mode (useful in CI). The daemon watches files with a 2 s debounce; pass `--no-watch` or set `CODEGRAPH_NO_WATCH=1` to disable. Custom extension mapping goes in `.codegraph/codegraph.json`; exclude patterns in `.codegraph/config.toml` under `[indexing] exclude`. +Changes to those two files, or to the project-root `.gitignore`, hot-reload the +running watcher and trigger one full reconcile. Newly excluded files are removed +and newly included/custom-extension files are added without restarting the MCP +server. Invalid TOML keeps the last valid watcher scope and reports the error; +malformed JSON keeps the existing tolerant empty-override behavior. Full env-var table, HTTP server details, filesystem fallback behavior, and the Claude prompt-hook: [`docs/mcp.md`](docs/mcp.md) and @@ -478,6 +483,15 @@ graph for that file, and does not stop other files from being indexed. Increment `sync` removes any older graph for the failed file and converges with `index --force`. +Graph correctness includes Erlang `module::function/arity` identity, Rust +generic/reference/qualified `impl` ownership and validated +`self.field.method()` calls, JavaScript-family object-literal namespace members, +inherited TypeScript/JavaScript path aliases, Python import aliases, Lua +function-expression callables, extensionless `.xsjs`/`.xsjslib` imports, and +plain derived C++ classes/structs in `.h` files. See +[`docs/languages.md`](docs/languages.md) for the exact static-analysis +boundaries. + **Doesn't:** no AI / vector / embedding / LLM anywhere inside the binary (hard constraint, `scripts/guardrail.sh`-enforced); no semantic search; no languages beyond the fixed `LANGUAGES` set. diff --git a/crates/codegraph-bench/fixtures/cpp/plain_derived.h b/crates/codegraph-bench/fixtures/cpp/plain_derived.h new file mode 100644 index 00000000..3115f55c --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/plain_derived.h @@ -0,0 +1,12 @@ +#pragma once + +#include "base.hpp" + +class HeaderDerived : public Base { +}; + +struct HeaderTemplateDerived : protected virtual ns::Tpl { +}; + +class HeaderQualifiedDerived : private ns::Tpl { +}; diff --git a/crates/codegraph-bench/fixtures/cpp/plain_header_negatives.h b/crates/codegraph-bench/fixtures/cpp/plain_header_negatives.h new file mode 100644 index 00000000..b1607575 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/plain_header_negatives.h @@ -0,0 +1,18 @@ +#pragma once + +struct HeaderBits { + unsigned enabled : 1; +}; + +static inline int choose_value(int flag) +{ +label: + return flag ? 1 : 0; +} + +static const char *header_text = "class HeaderGhost : HeaderBits"; + +/* +struct HeaderCommentGhost : HeaderBits { +}; +*/ diff --git a/crates/codegraph-bench/fixtures/erlang/client.erl b/crates/codegraph-bench/fixtures/erlang/client.erl new file mode 100644 index 00000000..ce3303b5 --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/client.erl @@ -0,0 +1,11 @@ +-module(client). +-export([fetch/1, broken/1, mapper/1]). + +fetch(Key) -> + store:get(Key, nil). + +broken(Key) -> + store:get(Key, nil, extra). + +mapper(Values) -> + lists:map(fun store:get/1, Values). diff --git a/crates/codegraph-bench/fixtures/erlang/deleg.erl b/crates/codegraph-bench/fixtures/erlang/deleg.erl new file mode 100644 index 00000000..6f206d5f --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/deleg.erl @@ -0,0 +1,13 @@ +-module(deleg). +-export([header/2, header/3, binary_arg/1]). + +-spec header(binary(), map()) -> any(). +header(Name, Req) -> + header(Name, Req, undefined). + +-spec header(binary(), map(), any()) -> any(). +header(Name, Headers, Default) -> + maps:get(Name, Headers, Default). + +binary_arg(<>) -> + header(Name, #{value => Value}). diff --git a/crates/codegraph-bench/fixtures/erlang/multi.erl b/crates/codegraph-bench/fixtures/erlang/multi.erl new file mode 100644 index 00000000..4de82116 --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/multi.erl @@ -0,0 +1,8 @@ +-module(multi). +-export([job/1, job/2]). + +job(Value) -> + Value. + +job(Left, Right) -> + {Left, Right}. diff --git a/crates/codegraph-bench/fixtures/erlang/single.erl b/crates/codegraph-bench/fixtures/erlang/single.erl new file mode 100644 index 00000000..939c6bda --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/single.erl @@ -0,0 +1,5 @@ +-module(single). +-export([work/1]). + +work(Value) -> + Value. diff --git a/crates/codegraph-bench/fixtures/erlang/spawner.erl b/crates/codegraph-bench/fixtures/erlang/spawner.erl new file mode 100644 index 00000000..2ef21e1e --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/spawner.erl @@ -0,0 +1,6 @@ +-module(spawner). +-export([go/1]). + +go(Args) -> + erlang:spawn(single, work, Args), + erlang:spawn(multi, job, Args). diff --git a/crates/codegraph-bench/fixtures/erlang/store.erl b/crates/codegraph-bench/fixtures/erlang/store.erl new file mode 100644 index 00000000..47a92c7d --- /dev/null +++ b/crates/codegraph-bench/fixtures/erlang/store.erl @@ -0,0 +1,8 @@ +-module(store). +-export([get/1, get/2]). + +get(Key) -> + get(Key, undefined). + +get(Key, Default) -> + {Key, Default}. diff --git a/crates/codegraph-bench/fixtures/lua/handlers.lua b/crates/codegraph-bench/fixtures/lua/handlers.lua new file mode 100644 index 00000000..25ae7f9f --- /dev/null +++ b/crates/codegraph-bench/fixtures/lua/handlers.lua @@ -0,0 +1,35 @@ +local function helper() + return 1 +end + +local localFn = function() + return helper() +end + +local M = { + callbacks = { + onStart = function() + return helper() + end, + ["onStop"] = function() + return helper() + end, + [DYNAMIC] = function() + return helper() + end, + }, +} + +M.assignedFn = function() + return helper() +end + +M["bracketFn"] = function() + return helper() +end + +localFn() +M.assignedFn() +M:assignedFn() + +return M diff --git a/crates/codegraph-bench/fixtures/python/aliases.py b/crates/codegraph-bench/fixtures/python/aliases.py new file mode 100644 index 00000000..3066b7fa --- /dev/null +++ b/crates/codegraph-bench/fixtures/python/aliases.py @@ -0,0 +1,15 @@ +from pkg import module as mod_alias +import top_level as tl +from imported_types import ImportedClass as ImportedAlias + + +def from_import_caller(): + return mod_alias.func() + + +def plain_import_caller(): + return tl.top_func() + + +def member_alias_value(): + return ImportedAlias diff --git a/crates/codegraph-bench/fixtures/python/pkg/__init__.py b/crates/codegraph-bench/fixtures/python/pkg/__init__.py new file mode 100644 index 00000000..026156f4 --- /dev/null +++ b/crates/codegraph-bench/fixtures/python/pkg/__init__.py @@ -0,0 +1 @@ +"""Package marker for module-alias resolution fixtures.""" diff --git a/crates/codegraph-bench/fixtures/python/pkg/module.py b/crates/codegraph-bench/fixtures/python/pkg/module.py new file mode 100644 index 00000000..54807821 --- /dev/null +++ b/crates/codegraph-bench/fixtures/python/pkg/module.py @@ -0,0 +1,2 @@ +def func(): + return 1 diff --git a/crates/codegraph-bench/fixtures/python/top_level.py b/crates/codegraph-bench/fixtures/python/top_level.py new file mode 100644 index 00000000..362dd3ab --- /dev/null +++ b/crates/codegraph-bench/fixtures/python/top_level.py @@ -0,0 +1,2 @@ +def top_func(): + return 2 diff --git a/crates/codegraph-bench/fixtures/rust/impl_ownership.rs b/crates/codegraph-bench/fixtures/rust/impl_ownership.rs new file mode 100644 index 00000000..8933f7eb --- /dev/null +++ b/crates/codegraph-bench/fixtures/rust/impl_ownership.rs @@ -0,0 +1,95 @@ +pub trait Source { + fn read(&mut self) -> usize; +} + +pub struct FileSource { + pub n: usize, +} + +impl Source for FileSource { + fn read(&mut self) -> usize { + self.n + } +} + +pub struct BufSource { + pub inner: T, +} + +impl Source for BufSource { + fn read(&mut self) -> usize { + 0 + } +} + +pub struct Parents<'a> { + pub current: &'a u32, +} + +impl<'a> Iterator for Parents<'a> { + type Item = u32; + + fn next(&mut self) -> Option { + None + } +} + +pub struct Wrapper { + pub n: usize, +} + +impl Source for &Wrapper { + fn read(&mut self) -> usize { + 1 + } +} + +pub mod nested { + pub struct Scoped { + pub n: usize, + } +} + +impl Source for nested::Scoped { + fn read(&mut self) -> usize { + 2 + } +} + +pub struct Own { + pub n: usize, +} + +impl From for Own { + fn from(n: u32) -> Self { + Own { n: n as usize } + } +} + +pub trait Base { + fn id(&self) -> u32; +} + +impl Base for (u32, u32) { + fn id(&self) -> u32 { + 0 + } +} + +impl Base for dyn Base { + fn id(&self) -> u32 { + 1 + } +} + +impl Base for *const u8 { + fn id(&self) -> u32 { + 2 + } +} + +impl Base for u32 { + fn id(&self) -> u32 { + 3 + } +} diff --git a/crates/codegraph-bench/fixtures/rust/self_field.rs b/crates/codegraph-bench/fixtures/rust/self_field.rs new file mode 100644 index 00000000..b7db6036 --- /dev/null +++ b/crates/codegraph-bench/fixtures/rust/self_field.rs @@ -0,0 +1,92 @@ +pub struct Inner { + pub n: usize, +} + +impl Inner { + pub fn run(&mut self) { + self.n += 1; + } + + pub fn take(&mut self) {} +} + +pub struct Outer { + pub inner: Inner, +} + +impl Outer { + pub fn run(&mut self) { + self.inner.run(); + } +} + +pub struct Boxed { + pub inner: Box, +} + +impl Boxed { + pub fn go(&mut self) { + self.inner.run(); + } +} + +pub struct Borrowed<'a> { + pub inner: &'a mut Inner, +} + +impl<'a> Borrowed<'a> { + pub fn go(&mut self) { + self.inner.run(); + } +} + +pub struct Optional { + pub inner: Option, +} + +impl Optional { + pub fn go(&mut self) { + self.inner.take(); + } +} + +pub struct Scanner { + pub items: std::vec::IntoIter, +} + +impl Scanner { + pub fn next(&mut self) -> Option { + self.items.next() + } +} + +pub struct Other; + +impl Other { + pub fn next(&mut self) -> Option { + None + } +} + +pub struct Holder { + pub item: T, +} + +impl Holder { + pub fn go(&mut self) { + self.item.run(); + } +} + +pub struct Countdown { + pub n: usize, +} + +impl Countdown { + pub fn run(&mut self) { + if self.n > 0 { + self.n -= 1; + self.run(); + } + } +} diff --git a/crates/codegraph-bench/fixtures/typescript/alias_consumer.ts b/crates/codegraph-bench/fixtures/typescript/alias_consumer.ts new file mode 100644 index 00000000..7ce15dd7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/alias_consumer.ts @@ -0,0 +1,5 @@ +import { aliasedTarget } from '@fixture/aliased'; + +export function runAlias() { + aliasedTarget(); +} diff --git a/crates/codegraph-bench/fixtures/typescript/config/tsconfig.base.json b/crates/codegraph-bench/fixtures/typescript/config/tsconfig.base.json new file mode 100644 index 00000000..8ea7e770 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/config/tsconfig.base.json @@ -0,0 +1,9 @@ +{ + // baseUrl belongs to this config directory, not the extending file. + "compilerOptions": { + "baseUrl": "../src", + "paths": { + "@fixture/*": ["*"], + }, + }, +} diff --git a/crates/codegraph-bench/fixtures/typescript/legacy_consumer.xsjs b/crates/codegraph-bench/fixtures/typescript/legacy_consumer.xsjs new file mode 100644 index 00000000..b04d804f --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/legacy_consumer.xsjs @@ -0,0 +1,7 @@ +import { legacyTarget } from './legacy_helpers'; +import { preferredTarget } from './legacy_priority'; + +export function runLegacy() { + legacyTarget(); + preferredTarget(); +} diff --git a/crates/codegraph-bench/fixtures/typescript/legacy_helpers.xsjslib b/crates/codegraph-bench/fixtures/typescript/legacy_helpers.xsjslib new file mode 100644 index 00000000..e5f270f3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/legacy_helpers.xsjslib @@ -0,0 +1 @@ +export function legacyTarget() {} diff --git a/crates/codegraph-bench/fixtures/typescript/legacy_priority.js b/crates/codegraph-bench/fixtures/typescript/legacy_priority.js new file mode 100644 index 00000000..6a51f4ef --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/legacy_priority.js @@ -0,0 +1 @@ +export function preferredTarget() {} diff --git a/crates/codegraph-bench/fixtures/typescript/legacy_priority.xsjs b/crates/codegraph-bench/fixtures/typescript/legacy_priority.xsjs new file mode 100644 index 00000000..6a51f4ef --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/legacy_priority.xsjs @@ -0,0 +1 @@ +export function preferredTarget() {} diff --git a/crates/codegraph-bench/fixtures/typescript/namespace.ts b/crates/codegraph-bench/fixtures/typescript/namespace.ts new file mode 100644 index 00000000..0cc5bc80 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/namespace.ts @@ -0,0 +1,21 @@ +function helper() {} + +export const api = { + run() { + helper(); + }, + stop: () => helper(), + nested: { + run() { + helper(); + }, + }, +}; + +export function localCaller() { + api.run(); + api.stop(); + api.nested.run(); +} + +export function run() {} diff --git a/crates/codegraph-bench/fixtures/typescript/namespace_consumer.ts b/crates/codegraph-bench/fixtures/typescript/namespace_consumer.ts new file mode 100644 index 00000000..cde0db6b --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/namespace_consumer.ts @@ -0,0 +1,7 @@ +import { api } from './namespace'; + +export function importedCaller() { + api.run(); + api.stop(); + api.nested.run(); +} diff --git a/crates/codegraph-bench/fixtures/typescript/src/aliased.ts b/crates/codegraph-bench/fixtures/typescript/src/aliased.ts new file mode 100644 index 00000000..0a911399 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/src/aliased.ts @@ -0,0 +1 @@ +export function aliasedTarget() {} diff --git a/crates/codegraph-bench/fixtures/typescript/tsconfig.json b/crates/codegraph-bench/fixtures/typescript/tsconfig.json new file mode 100644 index 00000000..9d4e1dd7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./config/tsconfig.base.json", +} diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index 1ab71041..11b9492c 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -128,6 +128,27 @@ fn rust_db_is_self_equivalent_to_rust_golden() { assert_equivalent(&rust_db(), &rust_golden_dir()).unwrap(); } +#[test] +fn generated_golden_matches_committed_lua_fixture() { + // Guards #1616 Lua function expressions: local assignment functions become + // Function nodes without duplicate Variables, table members become Methods + // with full qualified names, nested table members retain their namespace, + // body calls belong to the synthesized callable, and both dot/colon calls + // resolve to the same static table member. + let tempdir = TestDir::new("generated-golden-lua"); + write_golden(&lua_db(), tempdir.path()).unwrap(); + + let expected = load_golden(&lua_golden_dir()).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); +} + +#[test] +fn lua_db_is_self_equivalent_to_lua_golden() { + assert_equivalent(&lua_db(), &lua_golden_dir()).unwrap(); +} + #[test] fn generated_golden_matches_committed_go_fixture() { // Guards #1500 content-header detection: `files.generated` is byte-pinned at @@ -458,6 +479,14 @@ fn rust_golden_dir() -> PathBuf { workspace_root().join("reference/golden/rust") } +fn lua_db() -> PathBuf { + workspace_root().join("reference/golden/lua/colby.db") +} + +fn lua_golden_dir() -> PathBuf { + workspace_root().join("reference/golden/lua") +} + fn go_db() -> PathBuf { workspace_root().join("reference/golden/go/colby.db") } diff --git a/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs b/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs index 80e3cbb7..adcea74f 100644 --- a/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs +++ b/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs @@ -37,25 +37,20 @@ //! # The replacement graph cannot come from a reindex //! //! `serve --mcp --path` runs a one-shot startup catch-up sync on a detached -//! thread (`spawn_catch_up`), and `--no-watch` does not disable it. If the served -//! project's SOURCES could produce the replacement graph, that background sync -//! could satisfy the post-replacement assertions without the replaced bytes ever -//! being read — and could equally delete the replacement rows again, making the -//! test flaky in both directions. +//! thread (`spawn_catch_up`), and `--no-watch` does not disable it. A catch-up +//! that started only after the parent replaced the database could restore the +//! original source graph and erase the supplied replacement rows. //! -//! So the two distinguishing source files live under a directory the served -//! project's own root `.gitignore` excludes: +//! The test therefore gives the neutral source a unique OFFLINE edit after +//! `init`, starts the server, and polls search over the same MCP connection until +//! that marker is visible. Visibility proves the catch-up transaction committed; +//! because `--no-watch` is active, no later source-driven writer exists. The +//! served project never contains the replacement source at all, so no reindex can +//! create its rows. //! -//! - No sync can ever CREATE those rows: `scan_project` never yields the paths. -//! The test asserts this with the shipped scanner before the acceptance runs. -//! - No sync can ever DELETE those rows: the cold sync's removal pass only -//! considers a tracked path that is absent from disk, both files stay present, -//! and the same ignore policy filters them out of `should_handle_file` anyway. -//! -//! What remains scannable is one neutral file that is byte-identical in both -//! graphs, so the startup catch-up is a proven no-op instead of a race. The ONLY -//! way request 2 can observe the replacement file is by reading the database -//! bytes the parent supplied. +//! The exclusive lease acquired after request 1 remains the handle-release proof. +//! The ONLY way request 2 can observe the replacement file is by reading the +//! database bytes the parent supplied. //! //! # Scope //! @@ -73,19 +68,16 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use codegraph_core::IndexPaths; -use codegraph_extract::ExtractOptions; use codegraph_store::IndexLease; use serde_json::{Value, json}; /// Finite deadlock guard for every blocking wait. Never ordering evidence. const WAIT: Duration = Duration::from_secs(60); -/// The scannable file both graphs share, byte-identical. Its only job is to keep -/// the startup catch-up sync a proven no-op instead of a degenerate empty scan. +/// The scannable file both graphs share. An offline-only marker added to this +/// file proves startup catch-up committed before the database replacement. const NEUTRAL_FILE: &str = "src/pwqneutral.ts"; -/// The gitignored directory holding the two supplied-graph-only sources. Nothing -/// under it is ever scanned in the SERVED project. -const SUPPLIED_DIR: &str = "supplied"; +const CATCH_UP_SYMBOL: &str = "pwqcatchupdone"; /// The symbol that exists ONLY in the original graph. const ORIGINAL_SYMBOL: &str = "qxwoolrig"; /// The repo-relative file that exists ONLY in the original graph. @@ -147,6 +139,10 @@ fn neutral_source() -> String { source_for("pwqneutral") } +fn caught_up_neutral_source() -> String { + format!("{}{}", neutral_source(), source_for(CATCH_UP_SYMBOL)) +} + /// Run the SHIPPED `codegraph init` over `project`, producing a real v2 /// namespace (permanent lock + `Current` state slots + stamped, checkpointed /// database). Never a hand-authored fixture. @@ -332,47 +328,10 @@ fn tool_text(response: &Value, context: &str) -> String { .to_string() } -/// Freeze the supplied-graph sources out of every future scan of the SERVED -/// project, then PROVE it with the shipped scanner. -/// -/// This is what makes the acceptance deterministic against the startup catch-up -/// sync: the scanner is the same `scan_project` a sync feeds from, so an empty -/// result for both supplied paths means no sync in this project can invent the -/// replacement rows (or the original ones) from disk. -fn freeze_supplied_sources_out_of_scans(project: &Path) { - fs::write(project.join(".gitignore"), format!("{SUPPLIED_DIR}/\n")) - .expect("write root .gitignore"); - - let scanned = codegraph_extract::engine::scan_project(project, &ExtractOptions::default()) - .expect("scan the served project with the shipped scanner"); - assert!( - scanned.contains(&NEUTRAL_FILE.to_string()), - "the neutral source must stay scannable so startup catch-up is a real no-op: {scanned:?}" - ); - assert!( - !scanned.contains(&REPLACEMENT_FILE.to_string()), - "no sync may be able to index {REPLACEMENT_FILE}, or request 2 could pass by reindexing \ - instead of by reading the replaced database: {scanned:?}" - ); - assert!( - !scanned.contains(&ORIGINAL_FILE.to_string()), - "no sync may be able to re-index {ORIGINAL_FILE} back into the replacement graph: \ - {scanned:?}" - ); - // Both supplied sources stay PRESENT on disk, so the cold sync's removal - // pass (tracked-but-absent) can never delete their rows either. - for rel in [ORIGINAL_FILE, REPLACEMENT_FILE] { - assert!( - project.join(rel).is_file(), - "{rel} must remain on disk so no sync can classify it as removed" - ); - } -} - /// Build the replacement database in a staging project and return its path. The /// staging project holds the neutral file plus ONLY the replacement supplied -/// source and carries NO `.gitignore`, so its graph contains the replacement -/// file and cannot contain the original one. +/// source, so its graph contains the replacement file and cannot contain the +/// original one. fn build_replacement_database(staging: &TestDir) -> PathBuf { let project = staging.path().join("replacement-build"); fs::create_dir_all(&project).expect("create staging project"); @@ -443,9 +402,8 @@ fn replace_database(project: &Path, replacement_db: &Path) { #[test] fn long_lived_v2_mcp_releases_handles_per_request() { // GIVEN a really indexed v2 project whose graph contains the original - // supplied symbol, with both supplied sources frozen out of every future - // scan, and a separately built replacement database that contains ONLY the - // replacement supplied symbol. + // supplied symbol, plus a separately built replacement database that + // contains ONLY the replacement supplied symbol. let home = TestDir::new("served"); let project = home.path().join("project"); fs::create_dir_all(&project).expect("create served project"); @@ -453,8 +411,9 @@ fn long_lived_v2_mcp_releases_handles_per_request() { write_source(&project, ORIGINAL_FILE, &source_for(ORIGINAL_SYMBOL)); shipped_init(&project); - write_source(&project, REPLACEMENT_FILE, &source_for(REPLACEMENT_SYMBOL)); - freeze_supplied_sources_out_of_scans(&project); + // Offline edit: seeing this marker through MCP proves startup catch-up has + // committed before the parent replaces the database. + write_source(&project, NEUTRAL_FILE, &caught_up_neutral_source()); let staging = TestDir::new("staging"); let replacement_db = build_replacement_database(&staging); @@ -471,9 +430,28 @@ fn long_lived_v2_mcp_releases_handles_per_request() { ); server.send(&initialized); + // First wait for the one-shot catch-up to publish the offline marker. Search + // visibility, not elapsed time, is the completion proof. + let catch_up_deadline = Instant::now() + WAIT; + let mut request_id = 2_i64; + loop { + server.send(&search_frame(request_id, &project, CATCH_UP_SYMBOL)); + let text = tool_text(&server.await_response(request_id), "catch-up barrier"); + request_id += 1; + if text.contains(NEUTRAL_FILE) { + break; + } + assert!( + Instant::now() < catch_up_deadline, + "startup catch-up did not publish {CATCH_UP_SYMBOL} before the finite deadline: {text}" + ); + std::thread::yield_now(); + } + // WHEN request 1 completes (its framed response IS the READY barrier) ... - server.send(&search_frame(2, &project, ORIGINAL_SYMBOL)); - let first = tool_text(&server.await_response(2), "request 1"); + server.send(&search_frame(request_id, &project, ORIGINAL_SYMBOL)); + let first = tool_text(&server.await_response(request_id), "request 1"); + request_id += 1; assert!( first.contains(ORIGINAL_FILE), "request 1 must observe the original graph: {first}" @@ -486,8 +464,9 @@ fn long_lived_v2_mcp_releases_handles_per_request() { replace_database(&project, &replacement_db); // THEN the CONTINUE frame (request 2) serves ONLY the replacement graph. - server.send(&search_frame(3, &project, REPLACEMENT_SYMBOL)); - let second = tool_text(&server.await_response(3), "request 2"); + server.send(&search_frame(request_id, &project, REPLACEMENT_SYMBOL)); + let second = tool_text(&server.await_response(request_id), "request 2"); + request_id += 1; assert!( second.contains("## Search Results ("), "request 2 must render real search results, not an echo: {second}" @@ -504,8 +483,8 @@ fn long_lived_v2_mcp_releases_handles_per_request() { // And the original symbol is GONE from the same long-lived session: the // absence is observed through a lookup that resolves nothing, not through a // response that merely omits it. - server.send(&search_frame(4, &project, ORIGINAL_SYMBOL)); - let third = tool_text(&server.await_response(4), "request 3"); + server.send(&search_frame(request_id, &project, ORIGINAL_SYMBOL)); + let third = tool_text(&server.await_response(request_id), "request 3"); assert!( third.contains("No results found"), "the original symbol must be absent from the replacement graph: {third}" diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 10483675..f0b88ece 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -18,7 +18,7 @@ use std::time::Instant; use tree_sitter::Parser; use crate::ext_config::ExtensionOverrides; -use crate::lang::spec_for_language; +use crate::lang::{cpp_code_mask, spec_for_language}; use crate::walker::TreeSitterWalker; #[derive(Debug, Clone)] @@ -165,20 +165,36 @@ pub fn detect_language_with( } /// A `.h` file maps to `Language::C` by extension, but may hold C++ or -/// Objective-C. Match the upstream 8 KB-prefix content sniff (`grammars.ts` -/// `looksLikeCpp`). The `class MACRO Name : Base` alternative recognizes an -/// export-macro-annotated class whose only C++ signal is the macro — the -/// two-token ` ` shape never occurs in valid C, so genuine C -/// headers stay C. Lookahead-free (the `regex` crate has none). +/// Objective-C. The ordinary unique-C++ probes retain their bounded 8 KiB pass; +/// a second full-source pass recognizes a plain class/struct base clause, whose +/// shape is never valid C. Both passes run over a lexical code-only view so +/// comments, quoted strings, character literals, raw strings, and preprocessor +/// text cannot fabricate a C++ signal. fn looks_like_cpp(source: &str) -> bool { - static RE: OnceLock = OnceLock::new(); - let re = RE.get_or_init(|| { + static PREFIX_RE: OnceLock = OnceLock::new(); + static BASE_CLAUSE_RE: OnceLock = OnceLock::new(); + let prefix_re = PREFIX_RE.get_or_init(|| { Regex::new( r"\bnamespace\b|\bclass\s+\w+\s*[:{]|\b(?:class|struct)\s+[A-Z][A-Z0-9_]+\s+\w+\s*(?:final\s*)?[:{]|\btemplate\s*<|\b(?:public|private|protected)\s*:|\bvirtual\b|\busing\s+(?:namespace\b|\w+\s*=)", ) .expect("looks-like-cpp regex") }); - re.is_match(prefix_8k(source)) + let base_clause_re = BASE_CLAUSE_RE.get_or_init(|| { + Regex::new( + r"\b(?:class|struct)\s+[A-Za-z_]\w*\s*(?:final\s*)?:\s*(?:(?:public|protected|private|virtual)\s+)*(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*(?:\s*<[^{};]*>)?\s*[{,]", + ) + .expect("C++ base-clause regex") + }); + + let mask = cpp_code_mask(source); + let mut code = source.as_bytes().to_vec(); + for (index, is_code) in mask.into_iter().enumerate() { + if !is_code && code[index] != b'\n' && code[index] != b'\r' { + code[index] = b' '; + } + } + let code = String::from_utf8(code).expect("mask preserves UTF-8"); + prefix_re.is_match(prefix_8k(&code)) || base_clause_re.is_match(&code) } fn looks_like_objc(source: &str) -> bool { @@ -1287,6 +1303,46 @@ mod tests { assert!(!looks_like_cpp("struct Foo { int x; };\nvoid f(void);\n")); } + #[test] + fn looks_like_cpp_plain_base_clause_forms() { + for source in [ + "struct Base {};\nstruct Derived : Base {};\n", + "struct Derived : public Base {};\n", + "struct Derived : ns::Base {};\n", + "struct Derived : Base> {};\n", + "struct Derived final : Base {};\n", + "class Derived : public A, private B\n{\n};\n", + "struct Derived : virtual protected Base {};\n", + ] { + assert!(looks_like_cpp(source), "expected C++: {source}"); + } + } + + #[test] + fn looks_like_cpp_base_clause_scans_past_prefix() { + let preamble = + "#ifndef BIG_H\n#define BIG_H\n".to_string() + &"#define VALUE_0 0\n".repeat(700); + assert!(preamble.len() > 8192); + assert!(looks_like_cpp(&format!( + "{preamble}struct Base {{}};\nstruct Derived : Base {{}};\n#endif\n" + ))); + } + + #[test] + fn looks_like_cpp_masks_c_false_positives() { + for source in [ + "struct S { unsigned int a : 3; unsigned int b : 5; };\n", + "static inline int sz(int x) { return x ? sizeof(struct foo) : 0; }\n", + "static void g(void) {\nstruct_end:\n return;\n}\n", + "/* struct Fake : Base { }; */\nstruct Real { int x; };\n", + "const char *s = \"struct Fake : Base {\";\nstruct Real { int x; };\n", + "const char c = ':';\nstruct Real { int x; };\n", + "#define FAKE struct Fake : Base {\nstruct Real { int x; };\n", + ] { + assert!(!looks_like_cpp(source), "expected C: {source}"); + } + } + #[test] fn looks_like_objc_interface() { assert!(looks_like_objc("@interface Foo\n@end\n")); @@ -1310,6 +1366,25 @@ mod tests { ); } + #[test] + fn dot_h_plain_derived_struct_is_cpp_without_phantom_function() { + let result = extract_source( + "min.h", + "struct Base {};\nstruct Derived : Base {};\n", + None, + ); + let derived = result + .nodes + .iter() + .find(|node| node.name == "Derived") + .expect("Derived node"); + assert_eq!(derived.kind, codegraph_core::types::NodeKind::Struct); + assert_eq!(derived.language, Language::Cpp); + assert!(!result.nodes.iter().any(|node| { + node.name == "Base" && node.kind == codegraph_core::types::NodeKind::Function + })); + } + #[test] fn dot_h_plain_c_stays_c() { let result = extract_source("plain.h", "int add(int a, int b);\n", None); diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index 9815a40d..96c32811 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -526,7 +526,7 @@ fn blank_msvc_com_interface_keyword(bytes: Vec) -> Vec { /// COMPLETE declaration (`/* interface IFoo; */`) that becomes the FOLLOWING /// node's docstring, so a purely textual match corrupts extraction output even /// though it never mints a node. -fn cpp_code_mask(src: &str) -> Vec { +pub(crate) fn cpp_code_mask(src: &str) -> Vec { let bytes = src.as_bytes(); let mut mask = vec![true; bytes.len()]; let mut i = 0usize; diff --git a/crates/codegraph-extract/src/lang/erlang.rs b/crates/codegraph-extract/src/lang/erlang.rs index 06278d1f..b0737b9a 100644 --- a/crates/codegraph-extract/src/lang/erlang.rs +++ b/crates/codegraph-extract/src/lang/erlang.rs @@ -13,18 +13,17 @@ //! //! Two config hooks ARE used through the generic machinery, exactly as //! upstream: `package_types` (`module_attribute` → a file namespace, so every -//! function's qualified name is `m::f` — the same shape the remote-call branch -//! emits, so `mod:f(...)` resolves through the standard qualified-name matcher -//! with ZERO resolver changes) and `import_types` +//! function's qualified name is `m::f/arity` — the same shape the remote-call +//! branch emits, so `mod:f(...)` resolves through the qualified-name matcher) +//! and `import_types` //! (`import_attribute`/`pp_include`/`pp_include_lib` → an `Import` node + an //! `Imports` file/module edge). //! //! The non-Godot framework RESOLVER bridges — `-behaviour(x)` callback -//! contracts, `gen_server:call/cast(?MODULE|?SERVER)` → `handle_call`/ -//! `handle_cast`, the `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument -//! callee lift, var-module dispatch, and `.app`/`.app.src` resource-tuple wiring -//! — are DEFERRED, consistent with ArkTS/Nix/Terraform (the port has exactly one -//! concrete `FrameworkResolver`, `GodotResolver`). +//! contracts, var-module dispatch, and `.app`/`.app.src` resource-tuple wiring +//! — are DEFERRED. Static `gen_server` targets and the +//! `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument callee lift are handled +//! directly by the walker because they are deterministic AST facts. //! //! The pure AST helper fns below are `pub(crate)` and re-exported through //! [`crate::lang`] so the walker can reach them as `crate::lang::`. @@ -176,20 +175,42 @@ pub(crate) fn erlang_collapse_ws(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } -/// Exported function names for a module, or [`ErlangExports::All`] for -/// `-compile(export_all)`. Ports `moduleExports` (erlang.ts:53-79). The result -/// is used only for the `is_exported` membership check — it never iterates into -/// output — so a `HashSet` is deterministic here. +/// Argument count of an Erlang clause, type signature, or call. The grammar's +/// `args` field is an `expr_args`; its direct named-child count is the language +/// arity, so commas nested inside `<>` never inflate it. +pub(crate) fn erlang_node_arity(node: Node<'_>) -> usize { + child_by_field(node, "args") + .map(|args| args.named_child_count()) + .unwrap_or(0) +} + +fn erlang_written_arity(node: Node<'_>, source: &str) -> Option { + let arity = child_by_field(node, "arity")?; + let value = child_by_field(arity, "value")?; + let text = node_text(value, source); + if !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) { + Some(text) + } else { + None + } +} + +/// Exported function `name/arity` keys for a module, or +/// [`ErlangExports::All`] for `-compile(export_all)`. A malformed export lacking +/// an arity falls back to a bare-name key. The result is used only for +/// membership checks, so a `HashSet` is deterministic here. pub(crate) enum ErlangExports { All, Names(HashSet), } impl ErlangExports { - pub(crate) fn contains(&self, name: &str) -> bool { + pub(crate) fn contains(&self, name: &str, arity: usize) -> bool { match self { ErlangExports::All => true, - ErlangExports::Names(set) => set.contains(name), + ErlangExports::Names(set) => { + set.contains(&format!("{name}/{arity}")) || set.contains(name) + } } } } @@ -212,7 +233,10 @@ pub(crate) fn erlang_module_exports(root: Node<'_>, source: &str) -> ErlangExpor if let Some(fun) = child_by_field(fa, "fun") { let n = erlang_atom_text(fun, source); if !n.is_empty() { - names.insert(n); + let key = erlang_written_arity(fa, source) + .map(|arity| format!("{n}/{arity}")) + .unwrap_or(n); + names.insert(key); } } } @@ -226,6 +250,7 @@ pub(crate) fn erlang_module_exports(root: Node<'_>, source: &str) -> ErlangExpor pub(crate) fn erlang_preceding_spec<'t>( fun_decl: Node<'t>, name: &str, + arity: usize, source: &str, ) -> Option> { let mut prev = fun_decl.prev_named_sibling(); @@ -240,7 +265,19 @@ pub(crate) fn erlang_preceding_spec<'t>( if p.kind() == "spec" { let spec_fun = child_by_field(p, "fun")?; if erlang_atom_text(spec_fun, source) == name { - return Some(p); + let mut saw_signature = false; + for signature in p.named_children(&mut p.walk()) { + if signature.kind() != "type_sig" { + continue; + } + saw_signature = true; + if erlang_node_arity(signature) == arity { + return Some(p); + } + } + if !saw_signature { + return Some(p); + } } } None @@ -310,11 +347,11 @@ pub(crate) fn erlang_macro_name(node: Node<'_>, source: &str) -> Option /// The qualified callee name of a `call` node, or `None` for a dynamic /// (var/macro-module) target that has no static callee. A remote call -/// (`mod:f(...)`) yields `mod::f`; a local call (`f(...)`) yields `f`; -/// `?MODULE:f(...)` yields the bare `f` (same-file preference resolves it). +/// (`mod:f(A, B)`) yields `mod::f/2`; a local call (`f(A)`) yields `f/1`; +/// `?MODULE:f(...)` yields the bare name plus arity (same-file preference +/// resolves it). /// Ports the `node.type === 'call'` arm of the erlang branch in -/// `extractCall` (tree-sitter.ts:3684-3722), MINUS the DEFERRED gen_server and -/// MFA-argument lifts. +/// `extractCall` (tree-sitter.ts:3684-3722). pub(crate) fn erlang_call_ref_name(call: Node<'_>, source: &str) -> Option { let mut callee = child_by_field(call, "expr")?; let mut module_node: Option> = None; @@ -333,35 +370,36 @@ pub(crate) fn erlang_call_ref_name(call: Node<'_>, source: &str) -> Option { - Some(format!("{}::{}", erlang_atom_text(m, source), fn_bare)) - } - Some(m) => { - // Non-atom module. `?MODULE:f(X)` targets THIS module — keep the - // bare name so same-file preference resolves it. Anything else - // (`Mod:f(X)`) is dynamic dispatch with no static target: stay - // silent rather than link an arbitrary same-named function. - if m.kind() == "macro_call_expr" { - if let Some(mname) = child_by_field(m, "name") { - if node_text(mname, source) == "MODULE" { - return Some(fn_bare); - } + let base = if let Some(module_node) = module_node { + // `remote_module` wraps the module expression in a `module` field. + let module_expr = child_by_field(module_node, "module"); + match module_expr { + Some(m) if m.kind() == "atom" => { + format!("{}::{}", erlang_atom_text(m, source), fn_bare) + } + Some(m) => { + // Non-atom module. `?MODULE:f(X)` targets THIS module — keep + // the bare name so same-file preference resolves it. Anything + // else (`Mod:f(X)`) is dynamic dispatch with no static target. + if m.kind() == "macro_call_expr" + && child_by_field(m, "name") + .is_some_and(|mname| node_text(mname, source) == "MODULE") + { + fn_bare + } else { + return None; } } - None + None => fn_bare, } - None => Some(fn_bare), - } + } else { + fn_bare + }; + Some(format!("{base}/{}", erlang_node_arity(call))) } -/// The referenced fun name of an `internal_fun` (`fun f/1` → `f`) or -/// `external_fun` (`fun mod:f/1` → `mod::f`), or `None` for a var-part +/// The referenced fun name of an `internal_fun` (`fun f/1` → `f/1`) or +/// `external_fun` (`fun mod:f/1` → `mod::f/1`), or `None` for a var-part /// (`fun Mod:F/A`) that is dynamic. These are function VALUES — /// `EdgeKind::References`, not calls. Ports the `internal_fun`/`external_fun` /// arm (tree-sitter.ts:3785-3803). @@ -379,6 +417,10 @@ pub(crate) fn erlang_fun_value_ref_name(node: Node<'_>, source: &str) -> Option< } name = format!("{}::{}", erlang_atom_text(module_atom, source), name); } + if let Some(arity) = erlang_written_arity(node, source) { + name.push('/'); + name.push_str(&arity); + } Some(name) } diff --git a/crates/codegraph-extract/src/lang/lua.rs b/crates/codegraph-extract/src/lang/lua.rs index 01022d29..9a37720d 100644 --- a/crates/codegraph-extract/src/lang/lua.rs +++ b/crates/codegraph-extract/src/lang/lua.rs @@ -62,7 +62,7 @@ impl LanguageSpec for LuaSpec { } fn variable_types(&self) -> &'static [&'static str] { - &["variable_declaration"] + &["variable_declaration", "assignment_statement"] } fn name_field(&self) -> &'static str { diff --git a/crates/codegraph-extract/src/lang/luau.rs b/crates/codegraph-extract/src/lang/luau.rs index 1c3b2feb..90f646c4 100644 --- a/crates/codegraph-extract/src/lang/luau.rs +++ b/crates/codegraph-extract/src/lang/luau.rs @@ -62,7 +62,7 @@ impl LanguageSpec for LuauSpec { } fn variable_types(&self) -> &'static [&'static str] { - &["variable_declaration"] + &["variable_declaration", "assignment_statement"] } fn name_field(&self) -> &'static str { diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index d1625579..73bceb1f 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -45,7 +45,7 @@ pub(crate) use cfml::{ }; pub use cpp::CPP_SPEC; pub(crate) use cpp::{ - ExplicitOperatorCall, ExportMacroClass, detect_export_macro_class, + ExplicitOperatorCall, ExportMacroClass, cpp_code_mask, detect_export_macro_class, recover_explicit_operator_call, }; pub use csharp::CSHARP_SPEC; @@ -54,8 +54,8 @@ pub use erlang::ERLANG_SPEC; pub(crate) use erlang::{ erlang_atom_text, erlang_call_ref_name, erlang_clause_header, erlang_clause_name, erlang_collapse_ws, erlang_fun_value_ref_name, erlang_function_clauses, erlang_macro_name, - erlang_module_exports, erlang_preceding_spec, erlang_record_field_name, erlang_record_ref_name, - erlang_type_alias_name, + erlang_module_exports, erlang_node_arity, erlang_preceding_spec, erlang_record_field_name, + erlang_record_ref_name, erlang_type_alias_name, }; pub use gdscript::GDSCRIPT_SPEC; pub use go::GO_SPEC; @@ -78,6 +78,7 @@ pub use python::PYTHON_SPEC; pub use r::R_SPEC; pub use ruby::RUBY_SPEC; pub use rust::RUST_SPEC; +pub(crate) use rust::rust_impl_type_name; pub use scala::SCALA_SPEC; pub use solidity::SOLIDITY_SPEC; pub use swift::SWIFT_SPEC; diff --git a/crates/codegraph-extract/src/lang/rust.rs b/crates/codegraph-extract/src/lang/rust.rs index 461fb237..7177cfc2 100644 --- a/crates/codegraph-extract/src/lang/rust.rs +++ b/crates/codegraph-extract/src/lang/rust.rs @@ -8,6 +8,25 @@ pub struct RustSpec; pub static RUST_SPEC: RustSpec = RustSpec; +/// Return the implementing type's simple name from an `impl_item` `type` field. +/// +/// Generic, scoped, and reference wrappers are unwrapped recursively. Shapes +/// that do not name one concrete type (tuples, `dyn`, raw pointers, primitive +/// and function types) deliberately return `None`. +pub(crate) fn rust_impl_type_name(type_node: Option>, source: &str) -> Option { + let type_node = type_node?; + match type_node.kind() { + "type_identifier" | "identifier" => Some(node_text(type_node, source)), + "generic_type" | "reference_type" => { + rust_impl_type_name(child_by_field(type_node, "type"), source) + } + "scoped_type_identifier" | "scoped_identifier" => { + rust_impl_type_name(child_by_field(type_node, "name"), source) + } + _ => None, + } +} + impl LanguageSpec for RustSpec { fn language(&self) -> Language { Language::Rust @@ -149,27 +168,7 @@ impl LanguageSpec for RustSpec { let mut parent = node.parent(); while let Some(current) = parent { if current.kind() == "impl_item" { - let children = current - .named_children(&mut current.walk()) - .collect::>(); - if let Some(type_node) = children - .iter() - .rev() - .find(|child| child.kind() == "type_identifier") - { - return Some(node_text(*type_node, source)); - } - if let Some(generic_type) = - children.iter().find(|child| child.kind() == "generic_type") - { - if let Some(inner) = generic_type - .named_children(&mut generic_type.walk()) - .find(|child| child.kind() == "type_identifier") - { - return Some(node_text(inner, source)); - } - } - return None; + return rust_impl_type_name(child_by_field(current, "type"), source); } parent = current.parent(); } @@ -277,6 +276,67 @@ mod tests { assert_eq!(RUST_SPEC.get_receiver_type(func, src).as_deref(), Some("S")); } + #[test] + fn trait_impl_receivers_use_the_implementing_type_field() { + for (src, expected) in [ + ( + "trait Tr { fn m(&self); }\nstruct G(T);\nimpl Tr for G { fn m(&self) {} }\n", + Some("G"), + ), + ( + "trait Tr { fn m(&self); }\nstruct P<'a>(&'a u8);\nimpl<'a> Tr for P<'a> { fn m(&self) {} }\n", + Some("P"), + ), + ( + "trait Tr { fn m(&self); }\nstruct S;\nimpl Tr for &S { fn m(&self) {} }\n", + Some("S"), + ), + ( + "trait Tr { fn m(&self); }\nmod m { pub struct S; }\nimpl Tr for m::S { fn m(&self) {} }\n", + Some("S"), + ), + ( + "trait Tr { fn m(&self); }\nimpl Tr for (u8, u8) { fn m(&self) {} }\n", + None, + ), + ( + "trait Tr { fn m(&self); }\nimpl Tr for dyn Tr { fn m(&self) {} }\n", + None, + ), + ( + "trait Tr { fn m(&self); }\nimpl Tr for *const u8 { fn m(&self) {} }\n", + None, + ), + ( + "trait Tr { fn m(&self); }\nimpl Tr for u32 { fn m(&self) {} }\n", + None, + ), + ] { + let tree = parse(src); + let funcs = { + let mut out = Vec::new(); + fn walk<'t>(node: Node<'t>, out: &mut Vec>) { + if node.kind() == "function_item" { + out.push(node); + } + for index in 0..node.named_child_count() { + if let Some(child) = node.named_child(index as u32) { + walk(child, out); + } + } + } + walk(tree.root_node(), &mut out); + out + }; + let implementation = *funcs.last().expect("impl method"); + assert_eq!( + RUST_SPEC.get_receiver_type(implementation, src).as_deref(), + expected, + "{src}" + ); + } + } + #[test] fn use_root_module_scoped_and_leaf_forms() { let src = "use a::b::c;\nuse solo;\n"; diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index b2d20b0b..28af4314 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -13,6 +13,7 @@ use regex::Regex; use std::path::Path; use tree_sitter::Node as SyntaxNode; +use crate::lang::rust_impl_type_name; use crate::spec::{LanguageSpec, has_type}; pub fn node_text(node: SyntaxNode<'_>, source: &str) -> String { @@ -211,10 +212,12 @@ pub struct TreeSitterWalker<'a, 'tree> { /// symbols' `qualified_name`. Prefix-only (no namespace node) to avoid the /// #1093 crowd-out; empty outside C++. namespace_prefix: Vec, - /// Erlang clause-merge state: the previously-emitted function's name and - /// node id. A `fun_decl` whose clause repeats that name is a continuation - /// clause and attaches to the existing node. Empty outside Erlang. + /// Erlang clause-merge state: the previously-emitted function's name, + /// arity, and node id. A `fun_decl` whose clause repeats the same + /// `(name, arity)` is a continuation clause and attaches to the existing + /// node. Empty outside Erlang. erlang_last_fn_name: Option, + erlang_last_fn_arity: Option, erlang_last_fn_id: Option, /// CFML tag-dialect state: the byte offset up to which a /// `cf_component_open_tag`'s following siblings (the implicit-end-tag @@ -248,6 +251,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { fn_ref_candidates: Vec::new(), namespace_prefix: Vec::new(), erlang_last_fn_name: None, + erlang_last_fn_arity: None, erlang_last_fn_id: None, cfml_consumed_until: 0, walk_depth: 0, @@ -476,13 +480,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { /// Form dispatch: `fun_decl` → merged Function (clause-merge dedup); /// `record_decl` → Struct + Field children; `type_alias`/`opaque` → /// TypeAlias; `pp_define` → Constant. Call/reference edges: a `call` → - /// `Calls` (remote qualified `mod::f`); `internal_fun`/`external_fun` and the - /// four record-expression forms → `References` (function VALUES / record - /// USAGES, not calls). `spec`/`callback`/`type_alias`/`opaque`/`record_decl` - /// are consumed WITHOUT descending into their type-position `call` children, - /// so `-spec f(integer()) -> integer().` mints no bogus `integer` call ref. - /// The `-behaviour`, gen_server, spawn/apply MFA, and `.app` resource-tuple - /// bridges are DEFERRED. + /// `Calls` (remote qualified `mod::f/arity`); `internal_fun`/`external_fun` + /// and the four record-expression forms → `References` (function VALUES / + /// record USAGES, not calls). `spec`/`callback`/`type_alias`/`opaque`/ + /// `record_decl` are consumed WITHOUT descending into their type-position + /// `call` children, so `-spec f(integer()) -> integer().` mints no bogus + /// `integer` call ref. Static gen_server and spawn/apply MFA dispatch is + /// lifted here; behaviour and resource-tuple bridges remain deferred. /// CFML extraction (upstream `CfmlExtractor`, `cfml-extractor.ts`, #1153 — /// scope-B extraction slice). A first-token sniff (`is_bare_script_cfml`) /// selects the dialect. Bare-script: the cfscript type-set config drives the @@ -740,11 +744,11 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } /// `fun_decl` → merged Function with the clause-merge dedup rule: the grammar - /// emits one `fun_decl` per clause, so a `fun_decl` whose clause name equals - /// the previously-emitted function's name is a CONTINUATION — no new symbol; - /// its clause bodies' refs attribute to the existing function id. A clause - /// with no static name (macro-templated) is skipped. Ports `handleFunDecl` - /// (erlang.ts:99-143). + /// emits one `fun_decl` per clause, so a `fun_decl` whose clause name and + /// arity equal the previously-emitted function is a CONTINUATION — no new + /// symbol; its clause bodies' refs attribute to the existing function id. + /// A same-name different-arity declaration is a distinct function. A clause + /// with no static name (macro-templated) is skipped. fn visit_erlang_fun_decl(&mut self, node: SyntaxNode<'tree>) { let clauses = crate::lang::erlang_function_clauses(node); let Some(first) = clauses.first().copied() else { @@ -753,10 +757,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { let Some(name) = crate::lang::erlang_clause_name(first, self.source) else { return; }; + let arity = crate::lang::erlang_node_arity(first); // Continuation clause: extend the existing node's span and attribute // this clause's refs to it. - if self.erlang_last_fn_name.as_deref() == Some(name.as_str()) { + if self.erlang_last_fn_name.as_deref() == Some(name.as_str()) + && self.erlang_last_fn_arity == Some(arity) + { if let Some(existing_id) = self.erlang_last_fn_id.clone() { let end_line = node.end_position().row as i64 + 1; if let Some(existing) = self.nodes.iter_mut().find(|n| n.id == existing_id) { @@ -773,7 +780,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } - let spec = crate::lang::erlang_preceding_spec(node, &name, self.source); + let spec = crate::lang::erlang_preceding_spec(node, &name, arity, self.source); let signature = match spec { Some(spec_node) => Some( crate::lang::erlang_collapse_ws(&node_text(spec_node, self.source)) @@ -785,7 +792,8 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { }; let docstring = self.preceding_docstring(spec.unwrap_or(node)); let exports = crate::lang::erlang_module_exports(self.root, self.source); - let is_exported = exports.contains(&name); + let is_exported = exports.contains(&name, arity); + let qualified_name = format!("{}/{}", self.build_qualified_name(&name), arity); let Some(func) = self.create_node( NodeKind::Function, @@ -795,6 +803,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { docstring, signature, is_exported, + qualified_name: Some(qualified_name), ..NodeExtra::default() }, ) else { @@ -808,6 +817,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } self.node_stack.pop(); self.erlang_last_fn_name = Some(name); + self.erlang_last_fn_arity = Some(arity); self.erlang_last_fn_id = Some(func.id); } @@ -851,16 +861,134 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.node_stack.pop(); } - /// A `call` node → a `Calls` edge to the (possibly remote-qualified) callee. - /// A dynamic (var/macro-module) target emits nothing. Ports the `call` arm - /// of the erlang `extractCall` branch (tree-sitter.ts:3684-3690), MINUS the - /// DEFERRED gen_server / MFA-argument lifts. + /// A `call` node → a `Calls` edge to the (possibly remote-qualified, + /// arity-bearing) callee. Static gen_server targets and static + /// `(Module, Function, Args)` pairs are lifted to their real callback. + /// Dynamic module/function targets emit nothing. fn visit_erlang_call(&mut self, node: SyntaxNode<'tree>) { - if let Some(name) = crate::lang::erlang_call_ref_name(node, self.source) { - if let Some(from) = self.node_stack.last().cloned() { - self.push_ref(&from, &name, EdgeKind::Calls, node); + let Some(name) = crate::lang::erlang_call_ref_name(node, self.source) else { + return; + }; + let Some(from) = self.node_stack.last().cloned() else { + return; + }; + self.push_ref(&from, &name, EdgeKind::Calls, node); + + let base = strip_erlang_arity(&name); + let args = child_by_field(node, "args"); + + if matches!( + base, + "gen_server::call" | "gen_server::cast" | "gen_server::send_request" + ) && let Some(target) = args.and_then(|args| args.named_child(0)) + && let Some(module) = self.resolve_erlang_gen_server_target(target) + { + let handler = if base == "gen_server::cast" { + "handle_cast/2" + } else { + "handle_call/3" + }; + self.push_ref( + &from, + &format!("{module}::{handler}"), + EdgeKind::Calls, + node, + ); + } + + if !is_erlang_mfa_call(base) { + return; + } + let Some(args) = args else { + return; + }; + let expressions: Vec> = args.named_children(&mut args.walk()).collect(); + for pair_start in 0..expressions.len().saturating_sub(1) { + let module = expressions[pair_start]; + let function = expressions[pair_start + 1]; + if function.kind() != "atom" { + continue; + } + let is_local_module = module.kind() == "macro_call_expr" + && child_by_field(module, "name") + .is_some_and(|name| node_text(name, self.source) == "MODULE"); + if module.kind() != "atom" && !is_local_module { + continue; + } + let function_name = crate::lang::erlang_atom_text(function, self.source); + let mut target = if is_local_module { + function_name + } else { + format!( + "{}::{function_name}", + crate::lang::erlang_atom_text(module, self.source) + ) + }; + if expressions + .get(pair_start + 2) + .is_some_and(|list| list.kind() == "list") + { + let arity = expressions[pair_start + 2].named_child_count(); + target.push('/'); + target.push_str(&arity.to_string()); + } + self.push_ref(&from, &target, EdgeKind::Calls, function); + break; + } + } + + fn resolve_erlang_gen_server_target(&self, target: SyntaxNode<'tree>) -> Option { + if target.kind() == "atom" { + let name = crate::lang::erlang_atom_text(target, self.source); + return (!name.is_empty()).then_some(name); + } + if target.kind() != "macro_call_expr" { + return None; + } + let macro_name = child_by_field(target, "name") + .map(|name| node_text(name, self.source)) + .filter(|name| !name.is_empty())?; + let own_module = || { + self.nodes + .iter() + .find(|node| { + node.kind == NodeKind::Namespace + && node.language == Language::Erlang + && node.file_path == self.file_path + }) + .map(|node| node.name.clone()) + }; + if macro_name == "MODULE" { + return own_module(); + } + + for form in self.root.named_children(&mut self.root.walk()) { + if form.kind() != "pp_define" { + continue; } + let Some(lhs) = child_by_field(form, "lhs") else { + continue; + }; + let Some(name) = child_by_field(lhs, "name") else { + continue; + }; + if node_text(name, self.source) != macro_name { + continue; + } + let replacement = child_by_field(form, "replacement")?; + if replacement.kind() == "atom" { + let name = crate::lang::erlang_atom_text(replacement, self.source); + return (!name.is_empty()).then_some(name); + } + if replacement.kind() == "macro_call_expr" + && child_by_field(replacement, "name") + .is_some_and(|name| node_text(name, self.source) == "MODULE") + { + return own_module(); + } + return None; } + None } /// Terraform/HCL extraction (upstream `terraformExtractor.visitNode`, @@ -1923,7 +2051,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return true; } } - if node.kind() == "variable_declaration" { + if matches!(node.kind(), "variable_declaration" | "assignment_statement") { for call in descendants_of_kind(node, "function_call") { self.emit_lua_require(call); } @@ -2671,7 +2799,11 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { var_node.id }); if let Some(value) = value_node { - if value.kind() != "object" && value.kind() != "object_expression" { + if matches!(value.kind(), "object" | "object_expression") { + if let Some(owner_id) = declared_id.as_deref() { + self.extract_js_object_literal_members(value, owner_id); + } + } else { match declared_id { Some(id) => { self.node_stack.push(id); @@ -2685,6 +2817,48 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } + /// Emit callable members directly declared in one JS-family object literal. + /// + /// The holder constant/variable remains the namespace anchor; each direct + /// method or function-valued property becomes a Function node whose source + /// range is inside that holder. Nested objects are deliberately not recursed + /// into, which lets resolution distinguish `api.run()` from + /// `api.nested.run()` by direct range containment. + fn extract_js_object_literal_members(&mut self, object: SyntaxNode<'tree>, owner_id: &str) { + self.node_stack.push(owner_id.to_string()); + for member in object.named_children(&mut object.walk()) { + if member.kind() == "method_definition" { + let Some(name_node) = child_by_field(member, "name") else { + continue; + }; + let Some(name) = js_object_member_name(name_node, self.source) else { + continue; + }; + self.extract_function(member, Some(name)); + continue; + } + if member.kind() != "pair" { + continue; + } + let Some(value) = child_by_field(member, "value") else { + continue; + }; + if !matches!(value.kind(), "arrow_function" | "function_expression") { + continue; + } + let Some(key) = + child_by_field(member, "key").or_else(|| child_by_field(member, "name")) + else { + continue; + }; + let Some(name) = js_object_member_name(key, self.source) else { + continue; + }; + self.extract_function(value, Some(name)); + } + self.node_stack.pop(); + } + fn extract_python_assignment(&mut self, node: SyntaxNode<'tree>) { let Some(left) = child_by_field(node, "left").or_else(|| node.named_child(0)) else { return; @@ -2738,32 +2912,169 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } fn extract_lua_variable(&mut self, node: SyntaxNode<'tree>) { - let is_local = node_text(node, self.source) - .trim_start() - .starts_with("local"); - let _ = is_local; - let kind = NodeKind::Variable; - for name_node in descendants_of_kind(node, "identifier") { - let Some(parent) = name_node.parent() else { + let assignment = if node.kind() == "variable_declaration" { + node.named_children(&mut node.walk()) + .find(|child| child.kind() == "assignment_statement") + .unwrap_or(node) + } else { + node + }; + let variable_list = assignment + .named_children(&mut assignment.walk()) + .find(|child| child.kind() == "variable_list"); + let expression_list = assignment + .named_children(&mut assignment.walk()) + .find(|child| child.kind() == "expression_list"); + let targets: Vec<_> = variable_list + .map(|list| list.named_children(&mut list.walk()).collect()) + .unwrap_or_default(); + let values: Vec<_> = expression_list + .map(|list| list.named_children(&mut list.walk()).collect()) + .unwrap_or_default(); + let docstring = self.preceding_docstring(node); + + for (index, target_node) in targets.into_iter().enumerate() { + let Some((name, receiver, full_name)) = self.lua_assignment_target(target_node) else { continue; }; - if !matches!(parent.kind(), "variable_list" | "variable_declarator") { - continue; + let value = values.get(index).copied(); + if let Some(value) = value { + if value.kind() == "function_definition" { + self.extract_lua_function_value(value, name, receiver, docstring.clone()); + continue; + } + if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, full_name); + } } - let name = node_text(name_node, self.source); - if name == "require" { + + // Member assignments update a table rather than declaring a + // standalone variable. Function-valued members were handled above. + if receiver.is_some() || node.kind() == "assignment_statement" || name == "require" { continue; } + + let signature = value.map(|value| { + let init = node_text(value, self.source) + .chars() + .take(100) + .collect::(); + format!("= {}{}", init, if init.len() >= 100 { "..." } else { "" }) + }); self.create_node( - kind, + NodeKind::Variable, &name, - name_node, + target_node, NodeExtra { + docstring: docstring.clone(), + signature, is_exported: false, ..NodeExtra::default() }, ); - break; + } + } + + fn lua_assignment_target( + &self, + node: SyntaxNode<'tree>, + ) -> Option<(String, Option, String)> { + if node.kind() == "identifier" { + let name = node_text(node, self.source).trim().to_string(); + return (!name.is_empty()).then(|| (name.clone(), None, name)); + } + if !matches!( + node.kind(), + "dot_index_expression" | "method_index_expression" | "bracket_index_expression" + ) { + return None; + } + let table = child_by_field(node, "table")?; + let field = child_by_field(node, "field").or_else(|| child_by_field(node, "method"))?; + let receiver = node_text(table, self.source).trim().to_string(); + let name = self.lua_static_field_name(field, node.kind() == "bracket_index_expression"); + if receiver.is_empty() || name.is_empty() { + return None; + } + let full_name = format!("{receiver}.{name}"); + Some((name, Some(receiver), full_name)) + } + + fn lua_static_field_name(&self, node: SyntaxNode<'tree>, bracketed: bool) -> String { + if node.kind() == "identifier" { + return if bracketed { + String::new() + } else { + node_text(node, self.source).trim().to_string() + }; + } + if node.kind() == "string" { + if let Some(content) = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "string_content") + { + return node_text(content, self.source).trim().to_string(); + } + } + String::new() + } + + fn extract_lua_function_value( + &mut self, + node: SyntaxNode<'tree>, + name: String, + receiver: Option, + docstring: Option, + ) { + let (kind, qualified_name) = match receiver { + Some(receiver) => ( + NodeKind::Method, + Some(self.compose_receiver_qualified_name(&receiver, &name)), + ), + None => (NodeKind::Function, None), + }; + let callable = self.create_node( + kind, + &name, + node, + NodeExtra { + docstring, + signature: self.spec.get_signature(node, self.source), + qualified_name, + is_exported: false, + ..NodeExtra::default() + }, + ); + let Some(callable) = callable else { return }; + self.node_stack.push(callable.id); + if let Some(body) = child_by_field(node, self.spec.body_field()) { + self.visit_function_body(body); + } + self.node_stack.pop(); + } + + fn extract_lua_table_functions(&mut self, table: SyntaxNode<'tree>, receiver: String) { + let fields: Vec<_> = table.named_children(&mut table.walk()).collect(); + for field in fields { + if field.kind() != "field" { + continue; + } + let Some(name_node) = child_by_field(field, "name") else { + continue; + }; + let Some(value) = child_by_field(field, "value") else { + continue; + }; + let bracketed = node_text(field, self.source).trim_start().starts_with('['); + let name = self.lua_static_field_name(name_node, bracketed); + if name.is_empty() { + continue; + } + if value.kind() == "function_definition" { + self.extract_lua_function_value(value, name, Some(receiver.clone()), None); + } else if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, format!("{receiver}.{name}")); + } } } @@ -3215,6 +3526,28 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else { method_name }; + } else if self.spec.language() == Language::Rust + && receiver.kind() == "field_expression" + { + // Preserve exactly one Rust owner-field hop so the + // resolver can recover the field's declared type: + // `self.inner.run()` -> `self.inner.run`. Deeper + // chains and non-self field receivers stay bare. + let value = child_by_field(receiver, "value"); + let field = child_by_field(receiver, "field"); + callee_name = match (value, field) { + (Some(value), Some(field)) + if value.kind() == "self" + && field.kind() == "field_identifier" => + { + format!( + "self.{}.{}", + node_text(field, self.source), + method_name + ) + } + _ => method_name, + }; } else if is_member_shaped_callee(receiver) { // #1496 — a receiver that is itself member-shaped // (`this.mailer`, `holder.values`, `a.b.c`) keeps its @@ -3915,6 +4248,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { || matches!( node_type, "arrow_function" + | "function_definition" | "function_expression" | "lambda_literal" | "lambda_expression" @@ -4096,25 +4430,25 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } fn extract_rust_impl_item(&mut self, node: SyntaxNode<'tree>) { - let mut type_nodes = node - .named_children(&mut node.walk()) - .filter(|child| child.kind() == "type_identifier") - .collect::>(); - if type_nodes.len() < 2 { - return; - } - let Some(target) = type_nodes.pop() else { + let Some(trait_node) = child_by_field(node, "trait") else { return; }; - let Some(trait_node) = type_nodes.first().copied() else { + let Some(target_name) = rust_impl_type_name(child_by_field(node, "type"), self.source) + else { return; }; - let target_name = node_text(target, self.source); let trait_name = node_text(trait_node, self.source); if let Some(owner_id) = self .nodes .iter() - .find(|n| n.file_path == self.file_path && n.name == target_name) + .find(|n| { + n.file_path == self.file_path + && n.name == target_name + && matches!( + n.kind, + NodeKind::Struct | NodeKind::Union | NodeKind::Enum | NodeKind::Class + ) + }) .map(|n| n.id.clone()) { self.push_ref(&owner_id, &trait_name, EdgeKind::Implements, trait_node); @@ -4147,6 +4481,48 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } +fn strip_erlang_arity(name: &str) -> &str { + let Some((base, tail)) = name.rsplit_once('/') else { + return name; + }; + if !base.is_empty() + && (1..=3).contains(&tail.len()) + && tail.bytes().all(|byte| byte.is_ascii_digit()) + { + base + } else { + name + } +} + +fn is_erlang_mfa_call(name: &str) -> bool { + matches!( + name, + "spawn" + | "spawn_link" + | "spawn_monitor" + | "spawn_opt" + | "apply" + | "erlang::spawn" + | "erlang::spawn_link" + | "erlang::spawn_monitor" + | "erlang::spawn_opt" + | "erlang::apply" + | "proc_lib::spawn" + | "proc_lib::spawn_link" + | "proc_lib::spawn_opt" + | "proc_lib::start" + | "proc_lib::start_link" + | "timer::apply_after" + | "timer::apply_interval" + | "rpc::call" + | "rpc::cast" + | "rpc::async_call" + | "erpc::call" + | "erpc::cast" + ) +} + #[derive(Default)] struct NodeExtra { docstring: Option, @@ -4162,6 +4538,24 @@ struct NodeExtra { qualified_name: Option, } +fn js_object_member_name(node: SyntaxNode<'_>, source: &str) -> Option { + let raw = match node.kind() { + "identifier" | "property_identifier" | "shorthand_property_identifier" => { + node_text(node, source) + } + "string" => node_text(node, source) + .trim_matches(['\'', '"']) + .to_string(), + _ => return None, + }; + let name = raw.trim(); + (!name.is_empty() + && name + .chars() + .all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())) + .then(|| name.to_string()) +} + /// The call's callee name when it is a bare identifier or `pkg::fn` (yields /// `fn`). Ports `calleeName` (r.ts:44-54). fn r_callee_name(call: SyntaxNode<'_>, source: &str) -> Option { @@ -4912,6 +5306,74 @@ return mod assert!(has_node(&nodes, NodeKind::Import, "shared.util")); } + #[test] + fn lua_function_expression_bindings_are_callable_nodes() { + let src = r#" +local function helper() return 1 end +local localFn = function() return helper() end +local M = { + callbacks = { + onStart = function() return helper() end, + ["onStop"] = function() return helper() end, + [DYNAMIC] = function() return helper() end, + }, +} +M.assignedFn = function() return helper() end +M["bracketFn"] = function() return helper() end +localFn() +M.assignedFn() +M:assignedFn() +"#; + let (nodes, refs) = run("handlers.lua", src, Language::Lua); + + let local_fn = node(&nodes, NodeKind::Function, "localFn"); + let assigned = nodes + .iter() + .find(|node| node.kind == NodeKind::Method && node.qualified_name == "M::assignedFn") + .expect("M::assignedFn"); + let on_start = nodes + .iter() + .find(|node| { + node.kind == NodeKind::Method && node.qualified_name == "M.callbacks::onStart" + }) + .expect("M.callbacks::onStart"); + let on_stop = nodes + .iter() + .find(|node| { + node.kind == NodeKind::Method && node.qualified_name == "M.callbacks::onStop" + }) + .expect("M.callbacks::onStop"); + let bracket = nodes + .iter() + .find(|node| node.kind == NodeKind::Method && node.qualified_name == "M::bracketFn") + .expect("M::bracketFn"); + + assert!(!has_node(&nodes, NodeKind::Variable, "localFn")); + assert!(!nodes.iter().any(|node| node.name == "DYNAMIC")); + for callable in [local_fn, assigned, on_start, on_stop, bracket] { + assert!( + refs.iter().any(|reference| { + reference.from_node_id == callable.id + && reference.reference_kind == EdgeKind::Calls + && reference.reference_name == "helper" + }), + "helper() should belong to {}", + callable.qualified_name + ); + } + assert!(has_ref(&refs, EdgeKind::Calls, "localFn")); + assert!(has_ref(&refs, EdgeKind::Calls, "M.assignedFn")); + } + + #[test] + fn luau_local_function_expression_is_not_a_variable() { + let src = "local helper = function(): number return 1 end\nhelper()\n"; + let (nodes, refs) = run("handlers.luau", src, Language::Luau); + assert!(has_node(&nodes, NodeKind::Function, "helper")); + assert!(!has_node(&nodes, NodeKind::Variable, "helper")); + assert!(has_ref(&refs, EdgeKind::Calls, "helper")); + } + // ---- Objective-C class_implementation ---- #[test] @@ -5166,6 +5628,142 @@ impl Draw for Button { assert!(has_ref(&refs, EdgeKind::Implements, "Draw")); } + #[test] + fn rust_impl_methods_are_owned_by_the_implementing_type() { + let src = r#" +pub trait Source { fn read(&mut self) -> usize; } +pub struct FileSource { pub n: usize } +impl Source for FileSource { fn read(&mut self) -> usize { self.n } } +pub struct BufSource { pub inner: T } +impl Source for BufSource { fn read(&mut self) -> usize { 0 } } +pub struct Parents<'a> { cur: &'a u32 } +impl<'a> Iterator for Parents<'a> { + type Item = u32; + fn next(&mut self) -> Option { None } +} +pub struct Wrapper { pub n: usize } +impl Source for &Wrapper { fn read(&mut self) -> usize { 1 } } +pub mod m { pub struct Scoped { pub n: usize } } +impl Source for m::Scoped { fn read(&mut self) -> usize { 2 } } +pub struct Own { pub n: usize } +impl From for Own { fn from(n: u32) -> Self { Own { n: n as usize } } } +"#; + let result = extract_source("src.rs", src, Some(Language::Rust)); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + + let mut method_qns = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Method) + .map(|node| node.qualified_name.as_str()) + .collect::>(); + method_qns.sort_unstable(); + assert_eq!( + method_qns, + [ + "BufSource::read", + "FileSource::read", + "Own::from", + "Parents::next", + "Scoped::read", + "Source::read", + "Wrapper::read", + ] + ); + assert_eq!( + result + .nodes + .iter() + .filter(|node| node.qualified_name == "Source::read") + .count(), + 1, + "only the trait declaration may keep Source::read" + ); + + for (owner, trait_name) in [ + ("FileSource", "Source"), + ("BufSource", "Source"), + ("Parents", "Iterator"), + ("Wrapper", "Source"), + ("Scoped", "Source"), + ("Own", "From"), + ] { + let owner_id = result + .nodes + .iter() + .find(|node| { + node.name == owner + && matches!( + node.kind, + NodeKind::Struct | NodeKind::Union | NodeKind::Enum | NodeKind::Class + ) + }) + .unwrap_or_else(|| panic!("missing owner {owner}")) + .id + .as_str(); + assert!( + result.unresolved_references.iter().any(|reference| { + reference.from_node_id == owner_id + && reference.reference_kind == EdgeKind::Implements + && reference.reference_name == trait_name + }), + "missing {owner} implements {trait_name}: {:#?}", + result.unresolved_references + ); + } + + let buf = result + .nodes + .iter() + .find(|node| node.name == "BufSource" && node.kind == NodeKind::Struct) + .expect("BufSource"); + let read = result + .nodes + .iter() + .find(|node| node.qualified_name == "BufSource::read") + .expect("BufSource::read"); + assert!(result.edges.iter().any(|edge| { + edge.kind == EdgeKind::Contains && edge.source == buf.id && edge.target == read.id + })); + } + + #[test] + fn rust_impl_without_a_single_concrete_type_stays_unowned() { + let src = r#" +pub trait Base { fn id(&self) -> u32; } +impl Base for (u32, u32) { fn id(&self) -> u32 { 0 } } +impl Base for dyn Base { fn id(&self) -> u32 { 1 } } +impl Base for *const u8 { fn id(&self) -> u32 { 2 } } +impl Base for u32 { fn id(&self) -> u32 { 3 } } +"#; + let result = extract_source("src.rs", src, Some(Language::Rust)); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + + let ids = result + .nodes + .iter() + .filter(|node| node.name == "id") + .collect::>(); + assert_eq!( + ids.iter() + .filter(|node| node.kind == NodeKind::Function) + .count(), + 4 + ); + assert_eq!( + ids.iter() + .filter(|node| node.qualified_name == "Base::id") + .count(), + 1 + ); + assert!( + result + .unresolved_references + .iter() + .all(|reference| reference.reference_kind != EdgeKind::Implements) + ); + } + // ---- helper unit checks ---- #[test] @@ -5357,6 +5955,44 @@ export const greet = (name: string) => name; assert!(has_node(&nodes, NodeKind::Function, "greet")); } + #[test] + fn typescript_object_literal_emits_only_direct_callable_members() { + let src = r#" +function helper() {} +export const api = { + run() { helper(); }, + stop: () => helper(), + nested: { + hidden() { helper(); }, + }, +}; +"#; + let (nodes, refs) = run("api.ts", src, Language::TypeScript); + let api = node(&nodes, NodeKind::Constant, "api"); + let run = node(&nodes, NodeKind::Function, "run"); + let stop = node(&nodes, NodeKind::Function, "stop"); + assert!(!has_node(&nodes, NodeKind::Function, "hidden")); + for member in [run, stop] { + assert!( + refs.iter().any(|reference| { + reference.from_node_id == member.id + && reference.reference_kind == EdgeKind::Calls + && reference.reference_name == "helper" + }), + "helper call must belong to {}", + member.name + ); + } + assert!( + nodes + .iter() + .filter(|candidate| candidate.name == "run" || candidate.name == "stop") + .all(|candidate| { + candidate.start_line >= api.start_line && candidate.end_line <= api.end_line + }) + ); + } + #[test] fn typescript_class_fields_and_methods() { let src = r#" @@ -7325,6 +7961,56 @@ g() ->\n\ 1, "two clauses of f/1 merge to one Function: {fs:?}" ); + assert_eq!(fs[0].qualified_name, "m::f/1"); + } + + #[test] + fn erlang_same_name_different_arity_is_distinct_and_exported_per_arity() { + let src = "-module(m).\n\ +-export([f/1]).\n\ +f(X) -> X.\n\ +f(X, Y) -> {X, Y}.\n"; + let (nodes, _refs) = run("m.erl", src, Language::Erlang); + let fs: Vec<_> = nodes + .iter() + .filter(|node| node.kind == NodeKind::Function && node.name == "f") + .collect(); + assert_eq!(fs.len(), 2, "f/1 and f/2 must be distinct: {fs:?}"); + let f1 = fs + .iter() + .find(|node| node.qualified_name == "m::f/1") + .expect("f/1"); + let f2 = fs + .iter() + .find(|node| node.qualified_name == "m::f/2") + .expect("f/2"); + assert!(f1.is_exported); + assert!(!f2.is_exported); + } + + #[test] + fn erlang_spec_and_local_delegation_are_arity_specific() { + let src = "-module(deleg).\n\ +-export([header/2, header/3]).\n\ +header(Name, Req) -> header(Name, Req, undefined).\n\ +-spec header(binary(), map(), any()) -> any().\n\ +header(Name, Headers, Default) -> maps:get(Name, Headers, Default).\n"; + let (nodes, refs) = run("deleg.erl", src, Language::Erlang); + let h2 = nodes + .iter() + .find(|node| node.qualified_name == "deleg::header/2") + .expect("header/2"); + let h3 = nodes + .iter() + .find(|node| node.qualified_name == "deleg::header/3") + .expect("header/3"); + assert_eq!(h2.signature.as_deref(), Some("header(Name, Req)")); + assert!( + h3.signature + .as_deref() + .is_some_and(|signature| signature.starts_with("-spec header(")) + ); + assert!(has_ref(&refs, EdgeKind::Calls, "header/3")); } #[test] @@ -7350,8 +8036,8 @@ g() ->\n\ fn erlang_remote_call_is_qualified_mod_fn() { let (_nodes, refs) = run("m.erl", ERLANG_SAMPLE, Language::Erlang); assert!( - has_ref(&refs, EdgeKind::Calls, "other::h"), - "remote other:h() → Calls other::h: {refs:?}" + has_ref(&refs, EdgeKind::Calls, "other::h/0"), + "remote other:h() → Calls other::h/0: {refs:?}" ); } @@ -7359,8 +8045,8 @@ g() ->\n\ fn erlang_local_call_extracted() { let (_nodes, refs) = run("m.erl", ERLANG_SAMPLE, Language::Erlang); assert!( - has_ref(&refs, EdgeKind::Calls, "g"), - "local g() → Calls g: {refs:?}" + has_ref(&refs, EdgeKind::Calls, "g/0"), + "local g() → Calls g/0: {refs:?}" ); } @@ -7368,8 +8054,8 @@ g() ->\n\ fn erlang_fun_value_and_record_usage_are_references() { let (_nodes, refs) = run("m.erl", ERLANG_SAMPLE, Language::Erlang); assert!( - has_ref(&refs, EdgeKind::References, "f"), - "fun f/1 → References f: {refs:?}" + has_ref(&refs, EdgeKind::References, "f/1"), + "fun f/1 → References f/1: {refs:?}" ); assert!( has_ref(&refs, EdgeKind::References, "state"), @@ -7403,6 +8089,31 @@ g() ->\n\ assert!(has_node(&nodes, NodeKind::Namespace, "m")); } + #[test] + fn erlang_static_gen_server_and_mfa_dispatch_carry_target_arity() { + let src = "-module(worker).\n\ +-define(SERVER, ?MODULE).\n\ +-export([go/0, work/2, handle_call/3]).\n\ +go() ->\n\ + gen_server:call(?SERVER, ping),\n\ + proc_lib:spawn_link(?MODULE, work, [a, b]).\n\ +work(A, B) -> {A, B}.\n\ +handle_call(Msg, From, State) -> {reply, {Msg, From}, State}.\n"; + let (_nodes, refs) = run("worker.erl", src, Language::Erlang); + assert!(has_ref(&refs, EdgeKind::Calls, "worker::handle_call/3")); + assert!(has_ref(&refs, EdgeKind::Calls, "work/2")); + } + + #[test] + fn erlang_call_arity_ignores_binary_literal_commas() { + let src = "-module(probe).\n\ +-export([go/0]).\n\ +go() -> codec:decode(<<1,2,3>>, []).\n"; + let (_nodes, refs) = run("probe.erl", src, Language::Erlang); + assert!(has_ref(&refs, EdgeKind::Calls, "codec::decode/2")); + assert!(!has_ref(&refs, EdgeKind::Calls, "codec::decode/4")); + } + // ---- CFML (dual-grammar: cfscript for bare-script, cfml for tag) ---- #[test] @@ -7656,8 +8367,8 @@ g() ->\n\ Language::Rust, ); assert!( - rs.iter().any(|c| c == "inner.fetch"), - "Rust `self.inner.fetch()` must record `inner.fetch`, got: {rs:?}" + rs.iter().any(|c| c == "self.inner.fetch"), + "Rust `self.inner.fetch()` must record `self.inner.fetch`, got: {rs:?}" ); // Swift `navigation_expression` receiver. @@ -7672,6 +8383,40 @@ g() ->\n\ ); } + #[test] + fn rust_only_single_hop_self_field_receiver_keeps_its_owner_shape() { + let calls = call_refs( + "src/outer.rs", + r#" +pub struct Outer { inner: Inner, deep: Deep } +impl Outer { + pub fn run(&mut self) { + self.inner.run(); + self.deep.inner.run(); + self.make().run(); + (self.inner).run(); + self.run(); + let local = Inner { n: 0 }; + local.run(); + } +} +"#, + Language::Rust, + ); + + assert_eq!( + calls + .iter() + .filter(|name| name.starts_with("self.")) + .cloned() + .collect::>(), + vec!["self.inner.run".to_string()] + ); + assert!(calls.iter().any(|name| name == "local.run")); + assert_eq!(calls.iter().filter(|name| *name == "run").count(), 4); + assert!(calls.iter().any(|name| name == "make")); + } + #[test] fn bare_this_receiver_still_skips() { // A `this`/`self` receiver that is the WHOLE receiver keeps the existing diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index f5b25abe..f5d07990 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -1822,7 +1822,7 @@ impl CodeGraphEngine { /// `findSymbolMatches` (`tools.ts:3220-3265`): exact-name enumeration for a /// bare name, falling back to the top fuzzy result. fn find_symbol_matches(&self, symbol: &str) -> anyhow::Result> { - let is_qualified = symbol.contains(['.', '/']) || symbol.contains("::"); + let is_qualified = symbol.contains(['.', '/', ':']) || symbol.contains("::"); if !is_qualified { let exact = self.store.nodes_by_name(symbol)?; if !exact.is_empty() { @@ -3229,18 +3229,26 @@ fn file_path_matches_hint(file_path: &str, hint: &str) -> bool { /// `matchesSymbol` (`tools.ts:3175-3210`). fn matches_symbol(node: &Node, symbol: &str) -> bool { + let mut symbol = symbol; + if let Some((without_arity, requested_arity)) = split_erlang_arity(symbol) + && let Some((_, node_arity)) = split_erlang_arity(&node.qualified_name) + { + if requested_arity != node_arity { + return false; + } + symbol = without_arity; + } if node.name == symbol { return true; } if node.kind == NodeKind::File && strip_ext(&node.name) == symbol { return true; } - if !(symbol.contains(['.', '/']) || symbol.contains("::")) { + if !(symbol.contains(['.', '/', ':']) || symbol.contains("::")) { return false; } let parts: Vec<&str> = symbol - .split("::") - .flat_map(|p| p.split(['.', '/'])) + .split(['.', '/', ':']) .filter(|p| !p.is_empty()) .collect(); if parts.len() < 2 { @@ -3278,14 +3286,27 @@ fn strip_ext(s: &str) -> &str { } fn last_qualifier_part(symbol: &str) -> Option<&str> { + let symbol = split_erlang_arity(symbol) + .map(|(base, _)| base) + .unwrap_or(symbol); let parts: Vec<&str> = symbol - .split("::") - .flat_map(|p| p.split(['.', '/'])) + .split(['.', '/', ':']) .filter(|p| !p.is_empty()) .collect(); parts.last().copied() } +fn split_erlang_arity(symbol: &str) -> Option<(&str, &str)> { + let (base, arity) = symbol.rsplit_once('/')?; + if base.is_empty() + || !(1..=3).contains(&arity.len()) + || !arity.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some((base, arity)) +} + /// Ports the typed-bus `HANDLER_METHODS` regex (`tools.ts:1816`): the runtime /// target convention for a request type's handler class. fn is_handler_method_name(name: &str) -> bool { @@ -5741,6 +5762,25 @@ mod tests { m.qualified_name = "widget::render".to_string(); assert!(matches_symbol(&m, "widget::render")); assert!(!matches_symbol(&m, "other::render")); + + let erlang = node_lang( + "request_process", + "cowboy_stream_h::request_process/3", + "src/cowboy_stream_h.erl", + 1, + 1, + NodeKind::Function, + Language::Erlang, + ); + assert!(matches_symbol(&erlang, "cowboy_stream_h:request_process/3")); + assert!(matches_symbol( + &erlang, + "cowboy_stream_h::request_process/3" + )); + assert!(!matches_symbol( + &erlang, + "cowboy_stream_h:request_process/2" + )); } #[test] @@ -7721,6 +7761,10 @@ mod tests { fn ext_last_qualifier_part_and_glob_escapes() { assert_eq!(last_qualifier_part("a::b::c"), Some("c")); assert_eq!(last_qualifier_part("plain"), Some("plain")); + assert_eq!( + last_qualifier_part("cowboy_stream_h:request_process/3"), + Some("request_process") + ); assert!(glob_match("a.b?.rs", "a.bx.rs")); assert!(!glob_match("a.b.rs", "axb.rs")); } diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index 952e6d82..6a2f0c0f 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -8,7 +8,7 @@ use crate::name_matcher::{ local_receiver_type_patterns, normalize_inferred_type_name, regex_escape, - resolve_method_on_type, + resolve_method_on_type, resolve_object_literal_member, }; use crate::path_aliases::apply_aliases; use crate::pathutil; @@ -35,7 +35,16 @@ fn extension_resolution(language: Language) -> &'static [&'static str] { "/index.tsx", "/index.js", ], - Language::JavaScript => &[".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx"], + Language::JavaScript => &[ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".xsjs", + ".xsjslib", + "/index.js", + "/index.jsx", + ], Language::Tsx => &[ ".tsx", ".ts", @@ -1456,9 +1465,13 @@ pub fn resolve_via_import( // `from . import certs`), and absolute dotted module import // (`import pkg.mod`) (import-resolver.ts:1226-1238). if reference.language == Language::Python { - if let Some(py_result) = resolve_python_module_member(reference, &imports, context) { + let py_module = lookup_python_module_member(reference, &imports, context); + if let Some(py_result) = py_module.resolved { return Some(py_result); } + if py_module.claimed { + return None; + } if let Some(py_member) = resolve_python_from_import_member(reference, &imports, context) { return Some(py_member); } @@ -1547,6 +1560,26 @@ pub fn resolve_via_import( // (which `create_edges` would then mis-promote to `instantiates`). // Ports import-resolver.ts:1298-1316 (upstream f7441f21 / #825). if !imp.is_namespace { + if matches!(target_node.kind, NodeKind::Constant | NodeKind::Variable) { + let prefix = format!("{}.", imp.local_name); + if let Some(member) = reference + .reference_name + .strip_prefix(&prefix) + .and_then(|tail| tail.split('.').next()) + .filter(|member| !member.is_empty()) + { + if let Some(literal_member) = resolve_object_literal_member( + &target_node, + member, + reference, + context, + 0.9, + ResolvedBy::Import, + ) { + return Some(literal_member); + } + } + } // An imported VALUE called through a member — // `reproStore.notifyJoinGuildStatus()` after // `import { reproStore } from './store'`. Linking the @@ -2081,34 +2114,60 @@ fn resolve_go_cross_package_reference( None } -/// Python `mod.func()` where `mod` is an imported MODULE (submodule of a -/// package or a bare `import mod`). Ports `resolvePythonModuleMember` -/// (import-resolver.ts, issue #578). -fn resolve_python_module_member( +#[derive(Default)] +struct PythonModuleMemberLookup { + claimed: bool, + resolved: Option, +} + +/// Classify and, when possible, resolve Python `mod.func()` where `mod` is an +/// imported MODULE (a submodule of a package or a bare `import mod`). +/// +/// The `claimed` bit is as important as the successful result: once a receiver +/// is known to be a module alias, an absent or ambiguous member must stay +/// unresolved instead of falling through to a same-named global guess. +fn lookup_python_module_member( reference: &RefView, imports: &[ImportMapping], context: &dyn ResolutionContext, -) -> Option { - let dot_idx = reference.reference_name.find('.')?; +) -> PythonModuleMemberLookup { + let Some(dot_idx) = reference.reference_name.find('.') else { + return PythonModuleMemberLookup::default(); + }; if dot_idx == 0 { - return None; + return PythonModuleMemberLookup::default(); } let receiver = &reference.reference_name[..dot_idx]; - let member = reference.reference_name[dot_idx + 1..].split('.').next()?; + let Some(member) = reference.reference_name[dot_idx + 1..].split('.').next() else { + return PythonModuleMemberLookup::default(); + }; if member.is_empty() { - return None; + return PythonModuleMemberLookup::default(); } - for imp in imports { - if imp.local_name != receiver { - continue; - } + let matching: Vec<&ImportMapping> = imports + .iter() + .filter(|imp| imp.local_name == receiver) + .collect(); + if matching.is_empty() { + return PythonModuleMemberLookup::default(); + } + + let mut module_bindings = Vec::new(); + let mut namespace_claimed = false; + for imp in &matching { + let module_name = if imp.exported_name == "*" { + imp.local_name.as_str() + } else { + imp.exported_name.as_str() + }; let module_path = if imp.is_namespace { + namespace_claimed = true; imp.source.clone() } else if imp.source.ends_with('.') { - format!("{}{}", imp.source, imp.local_name) + format!("{}{}", imp.source, module_name) } else { - format!("{}.{}", imp.source, imp.local_name) + format!("{}.{}", imp.source, module_name) }; let resolved_path = resolve_import_path( @@ -2118,36 +2177,86 @@ fn resolve_python_module_member( context, ) .or_else(|| find_python_module_file(&module_path, context, &reference.file_path)); - let Some(resolved_path) = resolved_path else { - continue; - }; - if resolved_path == reference.file_path { - continue; + if let Some(resolved_path) = resolved_path { + module_bindings.push(resolved_path); } + } - if let Some(target) = context - .get_nodes_in_file_shared(&resolved_path) - .into_iter() - .find(|n| { - n.name == member - && matches!( - n.kind, - NodeKind::Function - | NodeKind::Class - | NodeKind::Variable - | NodeKind::Constant - ) - }) - { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::Import, - }); - } + // `from pkg import Thing as Alias` is still a member import when + // `pkg/Thing.py` does not exist. Preserve that existing path. Namespace + // imports (`import pkg as alias`) always claim their receiver, even when the + // module is missing, because they cannot denote a package member. + if module_bindings.is_empty() { + return PythonModuleMemberLookup { + claimed: namespace_claimed, + resolved: None, + }; + } + + // Rebinding the same local alias is ambiguous by construction. Never select + // whichever import happened to be visited first. + if matching.len() != 1 || module_bindings.len() != 1 { + return PythonModuleMemberLookup { + claimed: true, + resolved: None, + }; + } + + let resolved_path = &module_bindings[0]; + if resolved_path == &reference.file_path { + return PythonModuleMemberLookup { + claimed: true, + resolved: None, + }; + } + + let mut targets = context + .get_nodes_in_file_shared(resolved_path) + .into_iter() + .filter(|n| { + n.name == member + && matches!( + n.kind, + NodeKind::Function | NodeKind::Class | NodeKind::Variable | NodeKind::Constant + ) + }); + let Some(target) = targets.next() else { + return PythonModuleMemberLookup { + claimed: true, + resolved: None, + }; + }; + if targets.next().is_some() { + return PythonModuleMemberLookup { + claimed: true, + resolved: None, + }; + } + + PythonModuleMemberLookup { + claimed: true, + resolved: Some(ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.85, + resolved_by: ResolvedBy::Import, + }), } - None +} + +/// Whether a Python qualified reference is bound to an imported module alias. +/// +/// Used by the strategy orchestrator to prevent a claimed-but-unresolved module +/// member from falling through to global name matching. +pub(crate) fn python_module_member_is_claimed( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.language != Language::Python { + return false; + } + let imports = context.get_import_mappings(&reference.file_path, reference.language); + lookup_python_module_member(reference, &imports, context).claimed } /// Python `from pkg.mod import member [as alias]` → `member` in the file @@ -2167,8 +2276,9 @@ fn resolve_python_module_member( /// Deliberately narrow, so it repairs the reported defect without displacing an /// already-correct resolution chain: /// * `Imports` references keep their own module/file resolution. -/// * FUNCTION-REF (function-as-value) references keep the strictly-gated #756 -/// path — `reference/golden/python/` pins those as `resolvedBy:function-ref`. +/// * Unaliased FUNCTION-REF references keep the strictly-gated #756 path; +/// aliased member values need this import binding because no target node is +/// named after the local alias. /// * Only the BARE reference form (`member()`) is handled; a `Member.attr()` /// tail stays with `resolve_python_module_member` and the method matcher. fn resolve_python_from_import_member( @@ -2177,68 +2287,62 @@ fn resolve_python_from_import_member( context: &dyn ResolutionContext, ) -> Option { if reference.reference_kind == codegraph_core::types::EdgeKind::Imports - || reference.is_function_ref || reference.reference_name.contains('.') || reference.reference_name.is_empty() { return None; } - for imp in imports { - if imp.local_name != reference.reference_name { - continue; - } - // A namespace/default import binds a MODULE, not a member. - if imp.is_namespace || imp.is_default || imp.exported_name.is_empty() { - continue; - } - // `import mod` yields `local_name == exported_name == source`; that is a - // module binding, handled by `resolve_python_module_member` / - // `resolve_module_import_to_file`. - if imp.source.is_empty() || imp.source == imp.local_name { - continue; - } + let matching = imports + .iter() + .filter(|imp| imp.local_name == reference.reference_name) + .filter(|imp| { + !imp.is_namespace + && !imp.is_default + && !imp.exported_name.is_empty() + && !imp.source.is_empty() + && imp.source != imp.local_name + && (!reference.is_function_ref || imp.local_name != imp.exported_name) + }) + .collect::>(); + if matching.len() != 1 { + return None; + } + let imp = matching[0]; - // Relative dotted sources resolve lexically; absolute ones need the - // dotted-module file lookup (`pkg.mod` → `pkg/mod.py`). - let Some(module_file) = resolve_import_path( - &imp.source, - &reference.file_path, - reference.language, - context, - ) - .or_else(|| find_python_module_file(&imp.source, context, &reference.file_path)) else { - continue; - }; - if module_file == reference.file_path { - continue; - } + // Relative dotted sources resolve lexically; absolute ones need the + // dotted-module file lookup (`pkg.mod` → `pkg/mod.py`). + let module_file = resolve_import_path( + &imp.source, + &reference.file_path, + reference.language, + context, + ) + .or_else(|| find_python_module_file(&imp.source, context, &reference.file_path))?; + if module_file == reference.file_path { + return None; + } - // `get_nodes_in_file` is ordered by `start_line`, so a file declaring the - // name twice resolves deterministically to the first declaration. - if let Some(target) = context - .get_nodes_in_file_shared(&module_file) - .into_iter() - .find(|n| { - n.name == imp.exported_name - && matches!( - n.kind, - NodeKind::Function - | NodeKind::Class - | NodeKind::Variable - | NodeKind::Constant - ) - }) - { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); - } + let targets = context + .get_nodes_in_file_shared(&module_file) + .into_iter() + .filter(|node| { + node.name == imp.exported_name + && matches!( + node.kind, + NodeKind::Function | NodeKind::Class | NodeKind::Variable | NodeKind::Constant + ) + }) + .collect::>(); + if targets.len() != 1 { + return None; } - None + Some(ResolvedRef { + original: reference.clone(), + target_node_id: targets[0].id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }) } /// Python absolute dotted module import `import a.b.c` → its file (the Django @@ -3324,6 +3428,36 @@ export default defaultTarget; ); } + #[test] + fn resolve_import_path_javascript_supports_xsjslib() { + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: BTreeSet::from(["src/helpers.xsjslib".to_string()]), + ..Default::default() + }; + assert_eq!( + resolve_import_path("./helpers", "src/service.xsjs", Language::JavaScript, &ctx), + Some("src/helpers.xsjslib".to_string()) + ); + } + + #[test] + fn resolve_import_path_javascript_keeps_js_precedence_over_xsjs() { + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: BTreeSet::from([ + "src/helpers.js".to_string(), + "src/helpers.xsjs".to_string(), + "src/helpers.xsjslib".to_string(), + ]), + ..Default::default() + }; + assert_eq!( + resolve_import_path("./helpers", "src/service.xsjs", Language::JavaScript, &ctx), + Some("src/helpers.js".to_string()) + ); + } + #[test] fn resolve_import_path_relative_index_file() { let mut existing = BTreeSet::new(); @@ -3946,6 +4080,62 @@ export default defaultTarget; assert_eq!(resolved.confidence, 0.9); } + #[test] + fn resolve_via_import_object_literal_member_by_containment() { + let mut existing = BTreeSet::new(); + existing.insert("src/api.ts".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "src/use.ts".to_string(), + vec![mapping("api", "api", "./api")], + ); + let mut api = exported(node( + "constant:api", + "api", + NodeKind::Constant, + "src/api.ts", + Language::TypeScript, + )); + api.start_line = 1; + api.end_line = 6; + let mut member = node( + "function:call-member", + "call", + NodeKind::Function, + "src/api.ts", + Language::TypeScript, + ); + member.start_line = 2; + member.end_line = 2; + let mut decoy = node( + "function:call-decoy", + "call", + NodeKind::Function, + "src/api.ts", + Language::TypeScript, + ); + decoy.start_line = 10; + decoy.end_line = 10; + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![api, member, decoy], + ..Default::default() + }; + let reference = reference( + "api.call", + EdgeKind::Calls, + "src/use.ts", + Language::TypeScript, + ); + + let resolved = resolve_via_import(&reference, &ctx).expect("literal member"); + assert_eq!(resolved.target_node_id, "function:call-member"); + assert_eq!(resolved.resolved_by, ResolvedBy::Import); + assert_eq!(resolved.confidence, 0.9); + } + #[test] fn resolve_via_import_generic_default_import() { let mut existing = BTreeSet::new(); @@ -4230,6 +4420,236 @@ export default defaultTarget; assert_eq!(resolved.confidence, 0.85); } + #[test] + fn resolve_python_module_member_uses_exported_name_for_from_import_alias() { + let mut existing = BTreeSet::new(); + existing.insert("pkg/module.py".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ImportMapping { + local_name: "mod_alias".to_string(), + exported_name: "module".to_string(), + source: "pkg".to_string(), + is_default: false, + is_namespace: false, + }], + ); + let target = node( + "function:func", + "func", + NodeKind::Function, + "pkg/module.py", + Language::Python, + ); + let module_file = file_node("pkg/module.py", Language::Python); + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![target, module_file], + ..Default::default() + }; + let r = reference( + "mod_alias.func", + EdgeKind::Calls, + "app.py", + Language::Python, + ); + + let resolved = resolve_via_import(&r, &ctx).expect("aliased submodule resolves"); + assert_eq!(resolved.target_node_id, "function:func"); + assert!(python_module_member_is_claimed(&r, &ctx)); + } + + #[test] + fn resolve_python_plain_module_alias_remains_supported() { + let mut existing = BTreeSet::new(); + existing.insert("top_level.py".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ImportMapping { + local_name: "tl".to_string(), + exported_name: "*".to_string(), + source: "top_level".to_string(), + is_default: false, + is_namespace: true, + }], + ); + let target = node( + "function:top_func", + "top_func", + NodeKind::Function, + "top_level.py", + Language::Python, + ); + let module_file = file_node("top_level.py", Language::Python); + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![target, module_file], + ..Default::default() + }; + let r = reference("tl.top_func", EdgeKind::Calls, "app.py", Language::Python); + + let resolved = resolve_via_import(&r, &ctx).expect("plain module alias resolves"); + assert_eq!(resolved.target_node_id, "function:top_func"); + assert!(python_module_member_is_claimed(&r, &ctx)); + } + + #[test] + fn python_from_import_without_submodule_keeps_member_semantics() { + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ImportMapping { + local_name: "Alias".to_string(), + exported_name: "Thing".to_string(), + source: "pkg".to_string(), + is_default: false, + is_namespace: false, + }], + ); + let ctx = TestContext { + project_root: "/proj".to_string(), + import_mappings, + ..Default::default() + }; + let r = reference("Alias.run", EdgeKind::Calls, "app.py", Language::Python); + + assert!(!python_module_member_is_claimed(&r, &ctx)); + assert!(resolve_via_import(&r, &ctx).is_none()); + } + + #[test] + fn python_aliased_member_function_ref_resolves_in_its_module() { + let mut existing = BTreeSet::new(); + existing.insert("pkg/types.py".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ImportMapping { + local_name: "Alias".to_string(), + exported_name: "Thing".to_string(), + source: "pkg.types".to_string(), + is_default: false, + is_namespace: false, + }], + ); + let target = node( + "class:thing", + "Thing", + NodeKind::Class, + "pkg/types.py", + Language::Python, + ); + let module_file = file_node("pkg/types.py", Language::Python); + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![target, module_file], + ..Default::default() + }; + let mut reference = reference("Alias", EdgeKind::References, "app.py", Language::Python); + reference.is_function_ref = true; + + let resolved = resolve_via_import(&reference, &ctx).expect("aliased member value"); + assert_eq!(resolved.target_node_id, "class:thing"); + } + + #[test] + fn python_duplicate_module_alias_is_claimed_but_unresolved() { + let mut existing = BTreeSet::new(); + existing.insert("a.py".to_string()); + existing.insert("b.py".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ + ImportMapping { + local_name: "dup".to_string(), + exported_name: "*".to_string(), + source: "a".to_string(), + is_default: false, + is_namespace: true, + }, + ImportMapping { + local_name: "dup".to_string(), + exported_name: "*".to_string(), + source: "b".to_string(), + is_default: false, + is_namespace: true, + }, + ], + ); + let target = node( + "function:run", + "run", + NodeKind::Function, + "a.py", + Language::Python, + ); + let a_file = file_node("a.py", Language::Python); + let b_file = file_node("b.py", Language::Python); + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![target, a_file, b_file], + ..Default::default() + }; + let r = reference("dup.run", EdgeKind::Calls, "app.py", Language::Python); + + assert!(python_module_member_is_claimed(&r, &ctx)); + assert!(resolve_via_import(&r, &ctx).is_none()); + } + + #[test] + fn python_duplicate_member_in_module_is_claimed_but_unresolved() { + let mut existing = BTreeSet::new(); + existing.insert("pkg/mod.py".to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "app.py".to_string(), + vec![ImportMapping { + local_name: "mod".to_string(), + exported_name: "*".to_string(), + source: "pkg.mod".to_string(), + is_default: false, + is_namespace: true, + }], + ); + let first = node( + "function:first", + "run", + NodeKind::Function, + "pkg/mod.py", + Language::Python, + ); + let second = node( + "function:second", + "run", + NodeKind::Function, + "pkg/mod.py", + Language::Python, + ); + let module_file = file_node("pkg/mod.py", Language::Python); + let ctx = TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + import_mappings, + nodes: vec![first, second, module_file], + ..Default::default() + }; + let r = reference("mod.run", EdgeKind::Calls, "app.py", Language::Python); + + assert!(python_module_member_is_claimed(&r, &ctx)); + assert!(resolve_via_import(&r, &ctx).is_none()); + } + #[test] fn resolve_python_absolute_module_import() { let mod_file = file_node("a/b/c.py", Language::Python); diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 903c9659..fa0985a5 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -355,6 +355,39 @@ pub fn match_by_qualified_name( } } + // Erlang qualified refs carry arity when statically known + // (`mod::function/2`). A missed exact lookup with an arity must never fall + // through to a sibling arity. An arity-less dynamic MFA ref resolves only + // when that module defines exactly one arity of the function. + if reference.language == Language::Erlang && reference.reference_name.contains("::") { + if split_erlang_arity(&reference.reference_name).is_some() { + return None; + } + let base = reference.reference_name.rsplit("::").next()?; + let prefix = format!("{}/", reference.reference_name); + let arity_candidates: Vec> = context + .get_nodes_by_name_shared(base) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Erlang + && candidate.kind == NodeKind::Function + && candidate + .qualified_name + .strip_prefix(&prefix) + .is_some_and(is_erlang_arity_digits) + }) + .collect(); + if arity_candidates.len() == 1 { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: arity_candidates[0].id.clone(), + confidence: 0.85, + resolved_by: ResolvedBy::QualifiedName, + }); + } + return None; + } + // Partial qualified name match (name-matcher.ts:234-249), again preferring // the call site's own file among the candidates whose qualified name ends // with the reference (#1079). @@ -408,6 +441,117 @@ fn prefer_call_site_file>(nodes: Vec, call_site_file: &str) - } } +fn is_object_literal_language(language: Language) -> bool { + matches!( + language, + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + | Language::ArkTs + ) +} + +fn range_within(inner: &Node, outer: &Node) -> bool { + if inner.start_line < outer.start_line || inner.end_line > outer.end_line { + return false; + } + if inner.start_line == outer.start_line && inner.start_column < outer.start_column { + return false; + } + if inner.end_line == outer.end_line && inner.end_column > outer.end_column { + return false; + } + true +} + +fn same_range(left: &Node, right: &Node) -> bool { + left.start_line == right.start_line + && left.start_column == right.start_column + && left.end_line == right.end_line + && left.end_column == right.end_column +} + +/// Resolve a direct callable/value member contained by a JS-family +/// object-literal constant or variable. Members nested inside another callable +/// body, outside the holder range, or ambiguous across distinct direct ranges +/// are deliberately rejected. +pub(crate) fn resolve_object_literal_member( + container: &Node, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, + confidence: f64, + resolved_by: ResolvedBy, +) -> Option { + if !matches!(container.kind, NodeKind::Constant | NodeKind::Variable) + || !is_object_literal_language(container.language) + || !same_language_family(container.language, reference.language) + || member.is_empty() + { + return None; + } + + let callable = |node: &Node| matches!(node.kind, NodeKind::Function | NodeKind::Method); + let accepts = |node: &Node| { + callable(node) + || (reference.reference_kind != EdgeKind::Calls + && matches!( + node.kind, + NodeKind::Property | NodeKind::Variable | NodeKind::Constant + )) + }; + let inside = context + .get_nodes_in_file_shared(&container.file_path) + .into_iter() + .filter(|node| node.id != container.id && range_within(node, container)) + .collect::>(); + let callable_bodies = inside + .iter() + .filter(|node| callable(node)) + .cloned() + .collect::>(); + let mut candidates = inside + .into_iter() + .filter(|node| node.name == member && accepts(node)) + .filter(|candidate| { + !callable_bodies.iter().any(|body| { + body.id != candidate.id + && !same_range(body, candidate) + && range_within(candidate, body) + }) + }) + .collect::>(); + if candidates.is_empty() { + return None; + } + + candidates.sort_by(|left, right| { + let left_callable = !callable(left); + let right_callable = !callable(right); + left_callable + .cmp(&right_callable) + .then(left.start_line.cmp(&right.start_line)) + .then(left.start_column.cmp(&right.start_column)) + .then(left.id.cmp(&right.id)) + }); + let first = candidates.first()?; + if candidates + .iter() + .skip(1) + .any(|candidate| !same_range(candidate, first)) + { + return None; + } + + Some(ResolvedRef { + original: reference.clone(), + target_node_id: first.id.clone(), + confidence, + resolved_by, + }) +} + /// Resolve a method on a type, walking supertypes (`resolveMethodOnType`, /// `name-matcher.ts:254-331`). #[allow(clippy::too_many_arguments)] @@ -1360,6 +1504,53 @@ pub fn match_method_call( let parsed = parse_method_call(&reference.reference_name, reference.language)?; let (object_or_class, method_name) = parsed; + // Rust call through one field of the enclosing type — emitted as + // `self..`. This branch is exclusive: a missing, external, + // generic, container, or ambiguous field type must stay unresolved rather + // than reaching the receiver-name heuristics below. + if reference.language == Language::Rust { + if let Some(field) = object_or_class.strip_prefix("self.") { + return match_rust_self_field_call(field, &method_name, reference, context); + } + } + + // Lua table functions carry the stable qualified form `Table::method`, + // while calls may use either `Table.method()` or `Table:method()`. Resolve + // that exact table member before receiver-type heuristics; an ambiguous + // exact qualified name stays unresolved instead of falling through to a + // same-named global guess. + if matches!(reference.language, Language::Lua | Language::Luau) { + let want = format!("{object_or_class}::{method_name}"); + let exact = context + .get_nodes_by_name_shared(&method_name) + .into_iter() + .filter(|candidate| { + candidate.language == reference.language + && matches!(candidate.kind, NodeKind::Function | NodeKind::Method) + && candidate.qualified_name == want + }) + .collect::>(); + if !exact.is_empty() { + let same_file = exact + .iter() + .filter(|candidate| candidate.file_path == reference.file_path) + .collect::>(); + let target = if same_file.len() == 1 { + Some(same_file[0]) + } else if same_file.is_empty() && exact.len() == 1 { + exact.first() + } else { + None + }; + return target.map(|target| ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::InstanceMethod, + }); + } + } + // Receiver-type inference (#1108/#1125): only for a simple `obj.method` / // `obj:method` (Lua) / `obj$method` (R) shape — a `Class::method` colon-call // is not an instance call. C++ uses its dedicated inferrer; every other @@ -1430,6 +1621,31 @@ pub fn match_method_call( } } + // Same-file object-literal namespace: `const api = { call() {} }` followed + // by `api.call()`. The callable has a bare qualified name, so resolve it by + // direct source-range containment before class-shaped strategies. + if is_object_literal_language(reference.language) && !object_or_class.contains('.') { + let holders = prefer_call_site_file( + context.get_nodes_by_name_shared(&object_or_class), + &reference.file_path, + ); + for holder in holders.into_iter().filter(|node| { + node.file_path == reference.file_path + && matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + }) { + if let Some(member) = resolve_object_literal_member( + &holder, + &method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ) { + return Some(member); + } + } + } + // Strategy 1: direct class name match (name-matcher.ts:825-850). When the // receiver names a class in several files, try the call site's own file // first (#1079). @@ -1562,6 +1778,189 @@ pub fn match_method_call( None } +const RUST_NON_PROJECT_FIELD_TYPES: &[&str] = &[ + "bool", "char", "str", "String", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", + "u32", "u64", "u128", "usize", "f32", "f64", "Self", "self", +]; + +const RUST_NON_DEREF_CONTAINERS: &[&str] = &[ + "Option", + "Vec", + "VecDeque", + "LinkedList", + "HashMap", + "BTreeMap", + "HashSet", + "BTreeSet", + "Mutex", + "RwLock", + "RefCell", + "Cell", + "OnceLock", + "Result", +]; + +/// Reduce a Rust field type to the project type reached by method-call +/// auto-deref. Only references and `Box`/`Rc`/`Arc` are unwrapped. +fn rust_field_type_name(raw: &str) -> Option { + static REF_PREFIX: OnceLock = OnceLock::new(); + let ref_prefix = REF_PREFIX.get_or_init(|| { + Regex::new(r"^&\s*(?:'\w+\s+)?(?:mut\s+)?").expect("rust reference prefix") + }); + + let mut value = raw.trim(); + loop { + let before = value; + if let Some(prefix) = ref_prefix.find(value) { + value = value[prefix.end()..].trim_start(); + } + if let Some(open) = value.find('<') { + let wrapper = value[..open].trim().rsplit("::").next(); + if wrapper.is_some_and(|wrapper| matches!(wrapper, "Box" | "Rc" | "Arc")) { + value = value[open + 1..].trim_start(); + } + } + if let Some(rest) = value.strip_prefix("dyn ") { + value = rest.trim_start(); + } else if let Some(rest) = value.strip_prefix("impl ") { + value = rest.trim_start(); + } + if value == before { + break; + } + } + + let end = value + .char_indices() + .find_map(|(index, ch)| matches!(ch, '<' | '>' | '+').then_some(index)) + .unwrap_or(value.len()); + let type_path = value[..end].trim(); + if type_path.contains("::") { + let root = type_path.split("::").find(|part| !part.is_empty())?; + if !matches!(root, "crate" | "self" | "super") { + return None; + } + } + let segment = type_path.rsplit("::").find(|part| !part.is_empty())?; + if !is_word(segment) + || RUST_NON_PROJECT_FIELD_TYPES.contains(&segment) + || RUST_NON_DEREF_CONTAINERS.contains(&segment) + || (segment.len() == 1 && segment.as_bytes()[0].is_ascii_uppercase()) + { + return None; + } + Some(segment.to_string()) +} + +fn strip_rust_field_line_comments(line: &str) -> String { + let line = line.split_once("//").map_or(line, |(head, _)| head); + let mut output = String::with_capacity(line.len()); + let mut rest = line; + while let Some(start) = rest.find("/*") { + output.push_str(&rest[..start]); + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find("*/") else { + return output; + }; + rest = &after_start[end + 2..]; + } + output.push_str(rest); + output +} + +/// Resolve `self.field.method()` through the field type declared on the +/// caller's own Rust struct/union. Any uncertainty is a deliberate miss. +fn match_rust_self_field_call( + field: &str, + method_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if field.is_empty() || field.contains('.') || !is_word(field) { + return None; + } + let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + let (owner_prefix, _) = caller.qualified_name.rsplit_once("::")?; + let owner_name = owner_prefix.rsplit("::").next()?; + if owner_name.is_empty() { + return None; + } + + let owners = context + .get_nodes_by_name_shared(owner_name) + .into_iter() + .filter(|node| { + node.language == Language::Rust + && matches!( + node.kind, + NodeKind::Struct | NodeKind::Union | NodeKind::Class + ) + }) + .collect::>(); + let same_file = owners + .iter() + .filter(|node| node.file_path == reference.file_path) + .collect::>(); + let owner = if same_file.len() == 1 { + same_file[0] + } else if same_file.is_empty() && owners.len() == 1 { + &owners[0] + } else { + return None; + }; + + let source = context.read_file(&owner.file_path)?; + let lines = source.lines().collect::>(); + let start = owner.start_line.saturating_sub(1) as usize; + let end = (owner.end_line.max(owner.start_line) as usize).min(lines.len()); + if start >= end { + return None; + } + let field_re = Regex::new(&format!(r"\b{}\s*:\s*([^,{{}}]+)", regex::escape(field))).ok()?; + + for raw_line in &lines[start..end] { + let line = strip_rust_field_line_comments(raw_line); + let Some(captures) = field_re.captures(&line) else { + continue; + }; + let raw_type = captures.get(1)?.as_str(); + let field_type = rust_field_type_name(raw_type)?; + + // A same-named type in multiple project locations is not enough + // evidence to choose one. External types have zero candidates. + let type_candidates = context + .get_nodes_by_name_shared(&field_type) + .into_iter() + .filter(|node| { + node.language == Language::Rust + && matches!( + node.kind, + NodeKind::Struct + | NodeKind::Union + | NodeKind::Enum + | NodeKind::Trait + | NodeKind::Class + ) + }) + .collect::>(); + if type_candidates.len() != 1 { + return None; + } + + return resolve_method_on_type( + &field_type, + method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + None, + 0, + ); + } + None +} + /// Parse `obj.method`, `Class::method`, Lua/Luau `obj:method`, or R `obj$method` /// (name-matcher.ts:770-778 + #1112). The `:`/`$` separators are recognized ONLY /// for their owning language, so a `lg:log` ref elsewhere is not a call shape. @@ -2185,6 +2584,59 @@ pub fn match_reference( return Some(result); } + // Erlang local call/fun refs (`f/1`) resolve only to the definition of the + // written arity. Language semantics make the call site's own module the + // strongest signal; a missing arity stays unresolved rather than linking a + // same-name sibling. + if reference.language == Language::Erlang + && !reference.reference_name.contains("::") + && matches!( + reference.reference_kind, + EdgeKind::Calls | EdgeKind::References + ) + && let Some((name, arity)) = split_erlang_arity(&reference.reference_name) + { + let arity_tail = format!("/{arity}"); + let candidates: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Erlang + && candidate.kind == NodeKind::Function + && candidate.qualified_name.ends_with(&arity_tail) + }) + .collect(); + if let Some(same_file) = candidates + .iter() + .find(|candidate| candidate.file_path == reference.file_path) + { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: same_file.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::ExactMatch, + }); + } + if candidates.len() == 1 { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidates[0].id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::ExactMatch, + }); + } + if let Some(best) = find_best_match(reference, &candidates) { + let proximity = compute_path_proximity(&reference.file_path, &best.file_path); + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: best.id.clone(), + confidence: if proximity >= 30.0 { 0.7 } else { 0.4 }, + resolved_by: ResolvedBy::ExactMatch, + }); + } + return None; + } + // 1c. `::`-scoped factory chain (PHP / Rust) (name-matcher.ts:1116-1123). if matches!(reference.language, Language::Php | Language::Rust) { if let Some(result) = match_scoped_call_chain(reference, context) { @@ -2223,6 +2675,18 @@ pub fn match_reference( match_fuzzy(reference, context) } +fn split_erlang_arity(name: &str) -> Option<(&str, &str)> { + let (base, arity) = name.rsplit_once('/')?; + if base.is_empty() || !is_erlang_arity_digits(arity) { + return None; + } + Some((base, arity)) +} + +fn is_erlang_arity_digits(value: &str) -> bool { + (1..=3).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + pub(crate) fn is_python_class_function_ref_target(language: Language, kind: NodeKind) -> bool { language == Language::Python && kind == NodeKind::Class } @@ -3171,6 +3635,102 @@ mod tests { assert!(match_by_qualified_name(&r, &ctx).is_none()); } + #[test] + fn erlang_qualified_name_requires_exact_written_arity() { + let f1 = mk( + "function:f1", + NodeKind::Function, + "f", + "mod::f/1", + "src/mod.erl", + Language::Erlang, + ); + let f2 = mk( + "function:f2", + NodeKind::Function, + "f", + "mod::f/2", + "src/mod.erl", + Language::Erlang, + ); + let ctx = Ctx::default() + .qualified("mod::f/2", vec![f2.clone()]) + .name("f", vec![f1, f2]); + let hit = refv( + "mod::f/2", + EdgeKind::Calls, + "src/client.erl", + Language::Erlang, + 1, + ); + assert_eq!( + match_by_qualified_name(&hit, &ctx) + .expect("exact arity") + .target_node_id, + "function:f2" + ); + let miss = refv( + "mod::f/3", + EdgeKind::Calls, + "src/client.erl", + Language::Erlang, + 1, + ); + assert!(match_by_qualified_name(&miss, &ctx).is_none()); + } + + #[test] + fn erlang_arityless_qualified_name_requires_one_arity() { + let single = mk( + "function:work", + NodeKind::Function, + "work", + "single::work/1", + "src/single.erl", + Language::Erlang, + ); + let unique = Ctx::default().name("work", vec![single]); + let reference = refv( + "single::work", + EdgeKind::Calls, + "src/spawner.erl", + Language::Erlang, + 1, + ); + assert_eq!( + match_by_qualified_name(&reference, &unique) + .expect("unique arity") + .target_node_id, + "function:work" + ); + + let job1 = mk( + "function:job1", + NodeKind::Function, + "job", + "multi::job/1", + "src/multi.erl", + Language::Erlang, + ); + let job2 = mk( + "function:job2", + NodeKind::Function, + "job", + "multi::job/2", + "src/multi.erl", + Language::Erlang, + ); + let ambiguous = Ctx::default().name("job", vec![job1, job2]); + let reference = refv( + "multi::job", + EdgeKind::Calls, + "src/spawner.erl", + Language::Erlang, + 1, + ); + assert!(match_by_qualified_name(&reference, &ambiguous).is_none()); + } + // ================= parse helpers ========================================== #[test] @@ -3277,6 +3837,366 @@ mod tests { assert!(match_method_call(&r, &ctx).is_none()); } + #[test] + fn rust_field_type_name_unwraps_only_auto_deref_layers() { + for (raw, expected) in [ + ("Inner", Some("Inner")), + ("&Inner", Some("Inner")), + ("&'a mut Inner", Some("Inner")), + ("Box", Some("Inner")), + ("Rc", Some("Inner")), + ("std::sync::Arc>", Some("Inner")), + ("Box", Some("Source")), + ("crate::inner::Inner", Some("Inner")), + ("std::vec::IntoIter", None), + ("regex::Regex", None), + ("Option", None), + ("Vec", None), + ("Mutex", None), + ("T", None), + ("u32", None), + ("(Inner, Other)", None), + ("*const Inner", None), + ("[Inner; 4]", None), + ] { + assert_eq!(rust_field_type_name(raw).as_deref(), expected, "{raw}"); + } + } + + #[test] + fn rust_self_field_call_resolves_on_the_declared_type() { + let source = "pub struct Outer {\n pub(crate) inner: Box,\n}\n"; + let mut owner = mk( + "struct:outer", + NodeKind::Struct, + "Outer", + "Outer", + "src/outer.rs", + Language::Rust, + ); + owner.start_line = 1; + owner.end_line = 3; + let caller = mk( + "method:outer-go", + NodeKind::Method, + "go", + "Outer::go", + "src/outer.rs", + Language::Rust, + ); + let inner = mk( + "struct:inner", + NodeKind::Struct, + "Inner", + "Inner", + "src/inner.rs", + Language::Rust, + ); + let target = mk( + "method:inner-run", + NodeKind::Method, + "run", + "Inner::run", + "src/inner.rs", + Language::Rust, + ); + let ctx = Ctx::default() + .file("src/outer.rs", source) + .node_by_id(caller) + .name("Outer", vec![owner]) + .name("Inner", vec![inner]) + .name("run", vec![target]); + let mut reference = refv( + "self.inner.run", + EdgeKind::Calls, + "src/outer.rs", + Language::Rust, + 6, + ); + reference.from_node_id = "method:outer-go".to_string(); + + let resolved = match_method_call(&reference, &ctx).expect("typed field call"); + assert_eq!(resolved.target_node_id, "method:inner-run"); + assert_eq!(resolved.resolved_by, ResolvedBy::InstanceMethod); + assert!((resolved.confidence - 0.85).abs() < 1e-9); + } + + #[test] + fn rust_self_field_call_does_not_guess_external_or_container_types() { + for (field_decl, field_name, method_name, type_name) in [ + ("its: std::vec::IntoIter,", "its", "next", "IntoIter"), + ("inner: Option,", "inner", "take", "Inner"), + ("items: T,", "items", "run", "T"), + ] { + let source = format!("pub struct Outer {{\n {field_decl}\n}}\n"); + let mut owner = mk( + "struct:outer", + NodeKind::Struct, + "Outer", + "Outer", + "src/outer.rs", + Language::Rust, + ); + owner.start_line = 1; + owner.end_line = 3; + let caller = mk( + "method:outer-go", + NodeKind::Method, + "go", + "Outer::go", + "src/outer.rs", + Language::Rust, + ); + let decoy_type = mk( + "struct:decoy", + NodeKind::Struct, + type_name, + type_name, + "src/decoy.rs", + Language::Rust, + ); + let decoy_method = mk( + "method:decoy", + NodeKind::Method, + method_name, + &format!("{type_name}::{method_name}"), + "src/decoy.rs", + Language::Rust, + ); + let ctx = Ctx::default() + .file("src/outer.rs", &source) + .node_by_id(caller) + .name("Outer", vec![owner]) + .name(type_name, vec![decoy_type]) + .name(method_name, vec![decoy_method]); + let mut reference = refv( + &format!("self.{field_name}.{method_name}"), + EdgeKind::Calls, + "src/outer.rs", + Language::Rust, + 6, + ); + reference.from_node_id = "method:outer-go".to_string(); + + assert!( + match_method_call(&reference, &ctx).is_none(), + "{field_decl} must stay unresolved" + ); + } + } + + #[test] + fn rust_self_field_call_rejects_ambiguous_project_type() { + let source = "pub struct Outer {\n inner: Inner,\n}\n"; + let mut owner = mk( + "struct:outer", + NodeKind::Struct, + "Outer", + "Outer", + "src/outer.rs", + Language::Rust, + ); + owner.start_line = 1; + owner.end_line = 3; + let caller = mk( + "method:outer-go", + NodeKind::Method, + "go", + "Outer::go", + "src/outer.rs", + Language::Rust, + ); + let first = mk( + "struct:first", + NodeKind::Struct, + "Inner", + "Inner", + "src/a.rs", + Language::Rust, + ); + let second = mk( + "struct:second", + NodeKind::Struct, + "Inner", + "Inner", + "src/b.rs", + Language::Rust, + ); + let target = mk( + "method:inner-run", + NodeKind::Method, + "run", + "Inner::run", + "src/a.rs", + Language::Rust, + ); + let ctx = Ctx::default() + .file("src/outer.rs", source) + .node_by_id(caller) + .name("Outer", vec![owner]) + .name("Inner", vec![first, second]) + .name("run", vec![target]); + let mut reference = refv( + "self.inner.run", + EdgeKind::Calls, + "src/outer.rs", + Language::Rust, + 6, + ); + reference.from_node_id = "method:outer-go".to_string(); + + assert!(match_method_call(&reference, &ctx).is_none()); + } + + #[test] + fn object_literal_member_resolves_by_direct_containment() { + let mut api = mk( + "constant:api", + NodeKind::Constant, + "api", + "api", + "src/api.ts", + Language::TypeScript, + ); + api.start_line = 1; + api.end_line = 8; + let mut direct = mk( + "function:direct-call", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + direct.start_line = 2; + direct.end_line = 2; + let mut get = mk( + "function:get", + NodeKind::Function, + "get", + "get", + "src/api.ts", + Language::TypeScript, + ); + get.start_line = 3; + get.end_line = 6; + let mut nested = mk( + "function:nested-call", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + nested.start_line = 4; + nested.end_line = 4; + let mut outside = mk( + "function:outside-call", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + outside.start_line = 10; + outside.end_line = 10; + let ctx = Ctx::default() + .name("api", vec![api.clone()]) + .nodes_in_file("src/api.ts", vec![api, direct, get, nested, outside]); + let reference = refv( + "api.call", + EdgeKind::Calls, + "src/api.ts", + Language::TypeScript, + 12, + ); + + let resolved = match_method_call(&reference, &ctx).expect("literal member"); + assert_eq!(resolved.target_node_id, "function:direct-call"); + assert_eq!(resolved.resolved_by, ResolvedBy::InstanceMethod); + assert!((resolved.confidence - 0.85).abs() < 1e-9); + } + + #[test] + fn object_literal_member_rejects_nested_outside_and_ambiguous_candidates() { + let mut api = mk( + "constant:api", + NodeKind::Constant, + "api", + "api", + "src/api.ts", + Language::TypeScript, + ); + api.start_line = 1; + api.end_line = 8; + let mut get = mk( + "function:get", + NodeKind::Function, + "get", + "get", + "src/api.ts", + Language::TypeScript, + ); + get.start_line = 2; + get.end_line = 5; + let mut nested = mk( + "function:nested-call", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + nested.start_line = 3; + nested.end_line = 3; + let mut outside = mk( + "function:outside-call", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + outside.start_line = 10; + outside.end_line = 10; + let nested_ctx = Ctx::default() + .name("api", vec![api.clone()]) + .nodes_in_file("src/api.ts", vec![api.clone(), get, nested, outside]); + let reference = refv( + "api.call", + EdgeKind::Calls, + "src/api.ts", + Language::TypeScript, + 12, + ); + assert!(match_method_call(&reference, &nested_ctx).is_none()); + + let mut first = mk( + "function:first", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + first.start_line = 2; + first.end_line = 2; + let mut second = mk( + "function:second", + NodeKind::Function, + "call", + "call", + "src/api.ts", + Language::TypeScript, + ); + second.start_line = 6; + second.end_line = 6; + let ambiguous_ctx = Ctx::default() + .name("api", vec![api.clone()]) + .nodes_in_file("src/api.ts", vec![api, first, second]); + assert!(match_method_call(&reference, &ambiguous_ctx).is_none()); + } + #[test] fn method_call_strategy1_direct_class_match() { // `Widget.render` — Widget is a class in the graph; render is its method. @@ -4333,6 +5253,56 @@ mod tests { assert!(match_reference(&r, &ctx).is_none()); } + #[test] + fn erlang_local_reference_prefers_same_file_and_never_sibling_arity() { + let local = mk( + "function:local", + NodeKind::Function, + "header", + "deleg::header/3", + "src/deleg.erl", + Language::Erlang, + ); + let other = mk( + "function:other", + NodeKind::Function, + "header", + "other::header/3", + "src/other.erl", + Language::Erlang, + ); + let sibling = mk( + "function:sibling", + NodeKind::Function, + "header", + "deleg::header/2", + "src/deleg.erl", + Language::Erlang, + ); + let ctx = Ctx::default().name("header", vec![other, sibling, local]); + let hit = refv( + "header/3", + EdgeKind::Calls, + "src/deleg.erl", + Language::Erlang, + 10, + ); + assert_eq!( + match_reference(&hit, &ctx) + .expect("same-file exact arity") + .target_node_id, + "function:local" + ); + let miss = refv( + "header/4", + EdgeKind::Calls, + "src/deleg.erl", + Language::Erlang, + 10, + ); + assert!(match_reference(&miss, &ctx).is_none()); + } + // ================= C++ receiver inference ================================= #[test] @@ -6206,6 +7176,24 @@ mod tests { assert_eq!(res.target_node_id, "method:log"); } + #[test] + fn lua_static_table_member_resolves_for_dot_and_colon_calls() { + let member = mk( + "method:assigned", + NodeKind::Method, + "assignedFn", + "M::assignedFn", + "a.lua", + Language::Lua, + ); + let ctx = Ctx::default().name("assignedFn", vec![member]); + for reference_name in ["M.assignedFn", "M:assignedFn"] { + let reference = refv(reference_name, EdgeKind::Calls, "a.lua", Language::Lua, 3); + let resolved = match_method_call(&reference, &ctx).expect("static table method"); + assert_eq!(resolved.target_node_id, "method:assigned"); + } + } + #[test] fn lua_capitalized_method_call_not_self_matched() { // #1124: `lg:Log()` is byte-identical to a Luau annotation `lg: Log`; the diff --git a/crates/codegraph-resolve/src/path_aliases.rs b/crates/codegraph-resolve/src/path_aliases.rs index 61cc10d5..5e38acc3 100644 --- a/crates/codegraph-resolve/src/path_aliases.rs +++ b/crates/codegraph-resolve/src/path_aliases.rs @@ -3,13 +3,15 @@ //! Ports `upstream resolution/path-aliases.ts`. Reads //! `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json` at the project //! root and converts the patterns into a form the import resolver can consult -//! (`path-aliases.ts:1-24`). Scope mirrors the upstream v1: reads tsconfig then -//! jsconfig, honors `baseUrl` + `paths`, supports the single `*` wildcard, does -//! NOT follow `extends` chains or read Vite/webpack configs (`path-aliases.ts:14-20`). +//! (`path-aliases.ts:1-24`). Reads `tsconfig.json`, then `jsconfig.json`, then +//! `tsconfig.base.json`; follows bounded `extends` chains; honors config-relative +//! `baseUrl` + `paths`; and supports the single `*` wildcard. Vite/webpack +//! configs remain out of scope. use crate::pathutil; use serde_json::Value; -use std::path::Path; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; /// A single alias pattern from `compilerOptions.paths` /// (`AliasPattern`, `path-aliases.ts:31-45`). @@ -35,6 +37,21 @@ pub struct AliasMap { pub patterns: Vec, } +#[derive(Debug, Clone, Default)] +struct EffectiveOptions { + /// Already resolved against the config file that declared it. + base_url: Option, + /// `paths` replaces its inherited value wholesale, matching TypeScript. + paths: Option>, + /// Directory of the config that declared `paths`; used when no `baseUrl` + /// exists anywhere in the effective chain. + paths_dir: Option, +} + +/// Real-world chains are normally one to three files deep. Keep malformed or +/// adversarial projects bounded and deterministic. +const MAX_EXTENDS_DEPTH: usize = 32; + /// Strip JSONC comments + trailing commas (`stripJsonc`, `path-aliases.ts:65-104`). /// /// Walks the source as a tiny state machine tracking string context so that a @@ -111,6 +128,123 @@ fn strip_trailing_commas(src: &str) -> String { out } +fn path_to_posix(path: &Path) -> String { + pathutil::normalize(&path.to_string_lossy().replace('\\', "/")) +} + +fn resolve_config_path(from_dir: &Path, spec: &str) -> PathBuf { + let path = Path::new(spec); + if path.is_absolute() { + path.to_path_buf() + } else { + from_dir.join(path) + } +} + +fn append_json_extension(path: &Path) -> PathBuf { + let mut raw = path.as_os_str().to_os_string(); + raw.push(".json"); + PathBuf::from(raw) +} + +fn is_file(path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) +} + +/// Locate a TypeScript `extends` target. Relative and absolute values are +/// resolved from the referencing config; package specifiers are searched under +/// `node_modules` while walking toward the filesystem root. Every form accepts +/// an explicit file, an implied `.json`, or a directory `tsconfig.json`. +fn resolve_extends_target(spec: &str, from_dir: &Path) -> Option { + let candidates_for = |base: PathBuf| { + [ + base.clone(), + append_json_extension(&base), + base.join("tsconfig.json"), + ] + }; + + if spec.starts_with("./") || spec.starts_with("../") || Path::new(spec).is_absolute() { + return candidates_for(resolve_config_path(from_dir, spec)) + .into_iter() + .find(|candidate| is_file(candidate)); + } + + for dir in from_dir.ancestors() { + let base = dir.join("node_modules").join(spec); + if let Some(candidate) = candidates_for(base) + .into_iter() + .find(|candidate| is_file(candidate)) + { + return Some(candidate); + } + } + None +} + +fn read_tsconfig_like(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let parsed = serde_json::from_str::(&strip_jsonc(&text)).ok()?; + parsed.is_object().then_some(parsed) +} + +/// Fold one config's parents first, then apply the nearest config. A cycle or +/// overlong chain stops that parent branch while preserving options already +/// reached through the rest of the chain. +fn load_effective_options( + file_path: &Path, + stack: &mut HashSet, + depth: usize, +) -> Option { + let key = path_to_posix(file_path); + if depth > MAX_EXTENDS_DEPTH || stack.contains(&key) { + return None; + } + let raw = read_tsconfig_like(file_path)?; + stack.insert(key.clone()); + + let dir = file_path.parent().unwrap_or_else(|| Path::new("")); + let mut effective = EffectiveOptions::default(); + let parents: Vec<&str> = match raw.get("extends") { + Some(Value::String(parent)) => vec![parent.as_str()], + Some(Value::Array(parents)) => parents.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + }; + for parent in parents { + let Some(target) = resolve_extends_target(parent, dir) else { + continue; + }; + let Some(inherited) = load_effective_options(&target, stack, depth + 1) else { + continue; + }; + if inherited.base_url.is_some() { + effective.base_url = inherited.base_url; + } + if inherited.paths.is_some() { + effective.paths = inherited.paths; + effective.paths_dir = inherited.paths_dir; + } + } + stack.remove(&key); + + let compiler_options = raw.get("compilerOptions").and_then(Value::as_object); + if let Some(base_url) = compiler_options + .and_then(|options| options.get("baseUrl")) + .and_then(Value::as_str) + { + effective.base_url = Some(path_to_posix(&resolve_config_path(dir, base_url))); + } + if let Some(paths) = compiler_options + .and_then(|options| options.get("paths")) + .and_then(Value::as_object) + { + effective.paths = Some(paths.clone()); + effective.paths_dir = Some(path_to_posix(dir)); + } + + Some(effective) +} + /// Split a pattern around its `*` wildcard (`splitWildcard`, `path-aliases.ts:124-136`). fn split_wildcard(pattern: &str) -> (String, String, bool) { match pattern.find('*') { @@ -125,37 +259,37 @@ fn split_wildcard(pattern: &str) -> (String, String, bool) { /// Load aliases for `project_root` (`loadProjectAliases`, `path-aliases.ts:145-200`). /// -/// Returns `None` when no tsconfig/jsconfig with usable `paths` is present. +/// Returns `None` when no candidate config or inherited parent has usable +/// `paths`. A nearer config replaces inherited `paths`; `baseUrl` is resolved +/// relative to the file that declared it. pub fn load_project_aliases(project_root: &str) -> Option { - let candidates = ["tsconfig.json", "jsconfig.json"]; - let mut raw: Option = None; + let candidates = ["tsconfig.json", "jsconfig.json", "tsconfig.base.json"]; + let mut effective: Option = None; for name in candidates { let p = Path::new(project_root).join(name); - if p.exists() { - if let Ok(text) = std::fs::read_to_string(&p) { - if let Ok(parsed) = serde_json::from_str::(&strip_jsonc(&text)) { - if parsed.is_object() { - raw = Some(parsed); - break; - } - } - } + if !is_file(&p) { + continue; + } + let Some(options) = load_effective_options(&p, &mut HashSet::new(), 0) else { + continue; + }; + if effective.is_none() { + effective = Some(options.clone()); + } + if options.paths.is_some() { + effective = Some(options); + break; } } - let raw = raw?; - - let compiler_options = raw.get("compilerOptions").and_then(Value::as_object); - let base_url_rel = compiler_options - .and_then(|co| co.get("baseUrl")) - .and_then(Value::as_str) - .unwrap_or("."); - let base_url = pathutil::resolve(project_root, base_url_rel); - - let paths = compiler_options.and_then(|co| co.get("paths"))?; - let paths = paths.as_object()?; + let effective = effective?; + let base_url = effective + .base_url + .or(effective.paths_dir) + .unwrap_or_else(|| pathutil::normalize(project_root)); + let paths = effective.paths?; let mut patterns: Vec = Vec::new(); - for (pattern, targets) in paths { + for (pattern, targets) in &paths { let Some(targets) = targets.as_array() else { continue; }; @@ -232,7 +366,7 @@ pub fn apply_aliases(import_path: &str, aliases: &AliasMap, project_root: &str) let absolute = pathutil::resolve(&aliases.base_url, &filled); let rel = pathutil::relative(project_root, &absolute); // Skip rewrites that escape the project root (path-aliases.ts:235-236). - if rel.starts_with("..") { + if rel == ".." || rel.starts_with("../") { continue; } out.push(rel); @@ -258,6 +392,12 @@ mod tests { p } + fn write_json(path: impl AsRef, value: Value) { + let path = path.as_ref(); + std::fs::create_dir_all(path.parent().expect("config parent")).unwrap(); + std::fs::write(path, serde_json::to_string_pretty(&value).unwrap()).unwrap(); + } + #[test] fn strip_jsonc_removes_line_and_block_comments() { let src = "{\n // line\n \"a\": 1, /* block */ \"b\": 2\n}"; @@ -338,6 +478,216 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn load_project_aliases_follows_relative_multi_hop_extends() { + let root = temp_dir("extends-relative"); + write_json( + root.join("tsconfig.root.json"), + serde_json::json!({ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@app/*": ["packages/*/src"] } + } + }), + ); + write_json( + root.join("tsconfig.mid.json"), + serde_json::json!({ "extends": "./tsconfig.root" }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "./tsconfig.mid.json" }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("@app/ui", &map, root.to_str().unwrap()), + vec!["packages/ui/src"] + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn load_project_aliases_accepts_absolute_extends_target() { + let root = temp_dir("extends-absolute"); + let base = root.join("config/aliases.json"); + write_json( + &base, + serde_json::json!({ + "compilerOptions": { "paths": { "~/*": ["src/*"] } } + }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": base.to_string_lossy() }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("~/thing", &map, root.to_str().unwrap()), + vec!["config/src/thing"] + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn load_project_aliases_resolves_node_modules_package_extends() { + let root = temp_dir("extends-package"); + write_json( + root.join("node_modules/@acme/tsconfig/tsconfig.json"), + serde_json::json!({ + "compilerOptions": { "paths": { "@acme/*": ["../../../src/*"] } } + }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "@acme/tsconfig" }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("@acme/thing", &map, root.to_str().unwrap()), + vec!["src/thing"] + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn inherited_paths_and_baseurl_are_anchored_at_declaring_config() { + let root = temp_dir("extends-anchor"); + write_json( + root.join("config/tsconfig.base.json"), + serde_json::json!({ + "compilerOptions": { + "baseUrl": "..", + "paths": { "~/*": ["src/*"] } + } + }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "./config/tsconfig.base.json" }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("~/foo", &map, root.to_str().unwrap()), + vec!["src/foo"] + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn child_paths_replace_parent_paths_and_nearest_baseurl_wins() { + let root = temp_dir("extends-override"); + write_json( + root.join("tsconfig.base.json"), + serde_json::json!({ + "compilerOptions": { + "baseUrl": "base-dir", + "paths": { + "@x/*": ["from-base/*"], + "@parent/*": ["parent/*"] + } + } + }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "baseUrl": "own-dir", + "paths": { "@x/*": ["from-child/*"] } + } + }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("@x/y", &map, root.to_str().unwrap()), + vec!["own-dir/from-child/y"] + ); + assert!(apply_aliases("@parent/y", &map, root.to_str().unwrap()).is_empty()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn cyclic_extends_terminates_and_keeps_reached_options() { + let root = temp_dir("extends-cycle"); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "./a.json" }), + ); + write_json( + root.join("a.json"), + serde_json::json!({ + "extends": "./b.json", + "compilerOptions": { "paths": { "@cycle/*": ["from-a/*"] } } + }), + ); + write_json( + root.join("b.json"), + serde_json::json!({ "extends": "./a.json" }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("@cycle/x", &map, root.to_str().unwrap()), + vec!["from-a/x"] + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn extends_depth_is_bounded() { + let root = temp_dir("extends-depth"); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "./c0.json" }), + ); + for index in 0..32 { + write_json( + root.join(format!("c{index}.json")), + serde_json::json!({ "extends": format!("./c{}.json", index + 1) }), + ); + } + write_json( + root.join("c32.json"), + serde_json::json!({ + "compilerOptions": { "paths": { "@too-deep/*": ["src/*"] } } + }), + ); + + assert!(load_project_aliases(root.to_str().unwrap()).is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn solution_root_falls_back_to_tsconfig_base() { + let root = temp_dir("base-fallback"); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "files": [], "references": [] }), + ); + write_json( + root.join("tsconfig.base.json"), + serde_json::json!({ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@scope/*": ["libs/*/src/index.ts"] } + } + }), + ); + + let map = load_project_aliases(root.to_str().unwrap()).expect("aliases"); + assert_eq!( + apply_aliases("@scope/lib", &map, root.to_str().unwrap()), + vec!["libs/lib/src/index.ts"] + ); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn load_project_aliases_none_when_no_config() { let root = temp_dir("none"); diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 2e103a89..bfdd687f 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -9,7 +9,8 @@ use crate::framework::FrameworkResolver; use crate::import_resolver::{ - is_bound_to_out_of_repo_module, is_php_include_path_ref, resolve_jvm_import, resolve_via_import, + is_bound_to_out_of_repo_module, is_php_include_path_ref, python_module_member_is_claimed, + resolve_jvm_import, resolve_via_import, }; use crate::name_matcher::{ crosses_known_family, is_php_property_receiver_shape, is_python_class_function_ref_target, @@ -1059,7 +1060,12 @@ impl ReferenceResolver { // Fast pre-filter (index.ts:664-670). The `FrameworkResolver` // claims-reference escape is a no-op while the extension-point list is // empty. - if !self.has_any_possible_match(&reference.reference_name) + let existence_name = if reference.language == Language::Erlang { + strip_erlang_arity(&reference.reference_name) + } else { + &reference.reference_name + }; + if !self.has_any_possible_match_for(existence_name, reference.language) && !self.matches_any_import(reference, context) && !self .framework_resolver_extensions @@ -1130,6 +1136,13 @@ impl ReferenceResolver { return (candidates.into_iter().reduce(highest_confidence), None); } + // A Python receiver bound to an imported module is authoritative. If + // that module/member is missing or ambiguous, retain the unresolved + // reference rather than guessing a same-named global callable. + if python_module_member_is_claimed(reference, context) { + return (candidates.into_iter().reduce(highest_confidence), None); + } + // Strategy 3: name matching (index.ts:723-726). if let Some(name_result) = self.gate_language(match_reference(reference, context), reference, context) @@ -1914,6 +1927,38 @@ impl ReferenceResolver { false } + /// Language-aware extension of the generic fast pre-filter. + /// + /// Lua/Luau method calls use `receiver:method`, while R member calls use + /// `receiver$method`. Admit those forms when either side is a known project + /// symbol without interpreting their separators in unrelated languages. + fn has_any_possible_match_for(&self, name: &str, language: Language) -> bool { + if self.has_any_possible_match(name) { + return true; + } + let Some(known) = &self.known_names else { + return true; + }; + + let separator = match language { + Language::Lua | Language::Luau => ':', + Language::R => '$', + _ => return false, + }; + let Some((receiver, member)) = name.split_once(separator) else { + return false; + }; + if receiver.is_empty() + || member.is_empty() + || (separator == ':' && member.starts_with(':')) + || member.contains(separator) + { + return false; + } + + known.contains(receiver) || known.contains(member) + } + /// Does `reference.reference_name` match an import in its file? /// (`matchesAnyImport`, `index.ts:635-647`). fn matches_any_import(&self, reference: &RefView, context: &dyn ResolutionContext) -> bool { @@ -2184,6 +2229,20 @@ fn capitalize(s: &str) -> String { } } +fn strip_erlang_arity(name: &str) -> &str { + let Some((base, arity)) = name.rsplit_once('/') else { + return name; + }; + if !base.is_empty() + && (1..=3).contains(&arity.len()) + && arity.bytes().all(|byte| byte.is_ascii_digit()) + { + base + } else { + name + } +} + #[cfg(test)] mod tests { use super::*; @@ -2478,6 +2537,21 @@ mod tests { assert!(!r.has_any_possible_match("unrelated.symbol")); } + #[test] + fn has_any_possible_match_language_separators() { + let mut r = ReferenceResolver::new("/root"); + r.known_names = Some(BTreeSet::from([ + "assignedFn".to_string(), + "log".to_string(), + ])); + + assert!(r.has_any_possible_match_for("M:assignedFn", Language::Lua)); + assert!(r.has_any_possible_match_for("M:assignedFn", Language::Luau)); + assert!(r.has_any_possible_match_for("logger$log", Language::R)); + assert!(!r.has_any_possible_match_for("M:assignedFn", Language::TypeScript)); + assert!(!r.has_any_possible_match_for("M:a:b", Language::Lua)); + } + // --------------------------------------------------------------------- // Store-driven: create_edges promotions + resolve_all/persist paths // --------------------------------------------------------------------- @@ -3390,6 +3464,52 @@ mod tests { assert_eq!(resolved.target_node_id, onblur.id); } + #[test] + fn resolve_one_lua_colon_static_table_member_survives_fast_prefilter() { + let mut store = Store::open(&temp_db("lua-colon-table-member")).expect("open"); + let mut member = mk_node2( + "method:assigned", + NodeKind::Method, + "assignedFn", + "a.lua", + Language::Lua, + ); + member.qualified_name = "M::assignedFn".to_string(); + let caller = mk_node2( + "function:caller", + NodeKind::Function, + "caller", + "a.lua", + Language::Lua, + ); + store + .upsert_nodes(&[member.clone(), caller.clone()]) + .expect("nodes"); + + let mut resolver = ReferenceResolver::new("/root"); + resolver.warm_caches(&crate::context::StoreResolutionContext::new( + &store, "/root", + )); + let context = crate::context::StoreResolutionContext::new(&store, "/root"); + let reference = RefView { + row_id: None, + from_node_id: caller.id, + reference_name: "M:assignedFn".to_string(), + reference_kind: EdgeKind::Calls, + line: 3, + column: 0, + file_path: "a.lua".to_string(), + language: Language::Lua, + is_function_ref: false, + reference_subkind: None, + }; + + let resolved = resolver + .resolve_one(&reference, &context) + .expect("resolves"); + assert_eq!(resolved.target_node_id, member.id); + } + // Synthetic kind-gate coverage only: this test hand-sets `is_exported = true` // on Python class nodes, a state the extractor never produces because Python // inherits `LanguageSpec::is_exported`'s `false` (`spec.rs:106`). Real Python diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index aca33a52..70862261 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -115,7 +115,7 @@ pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; /// The extraction-pipeline version this binary produces. This is the single /// source of truth: no other crate may define its own copy. -pub const CURRENT_EXTRACTION_VERSION: u64 = 11; +pub const CURRENT_EXTRACTION_VERSION: u64 = 12; /// The `project_metadata` key under which a built index records the extraction /// version it was produced with. Single source of truth for the key spelling. diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index 9def2187..0aa78f8c 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -119,7 +119,7 @@ fn assert_corrupt( fn index_state_constants_and_canonical_payload_are_exact() { /// The extraction version the immediately preceding release wrote. Read from /// `index_state.rs` at implementation time; see plan decision C1 step 0. - const PREVIOUS_EXTRACTION_VERSION: u64 = 10; + const PREVIOUS_EXTRACTION_VERSION: u64 = 11; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index 76242000..fe5dd449 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -1,6 +1,8 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -118,28 +120,6 @@ pub fn sync_project_once_cancellable( sync_project_once_with_scope(project_root, &paths, &scope, Some(cancel), |_, _| {}) } -/// Whole-project sync using watcher-owned path patterns while preserving every -/// other scan option from the normal sync path. -/// -/// The watcher owns `include`/`exclude` because it derived them from the SAME -/// project config when it started; every other option (size limit, ignore sets, -/// extension overrides, framework config) is loaded here from the addressed -/// project, so a watcher-triggered full sync scans exactly what a direct sync of -/// that project scans. -pub(crate) fn sync_project_once_with_patterns( - project_root: impl AsRef, - include: &[String], - exclude: &[String], - cancel: Option<&SyncCancellation>, -) -> Result { - let project_root = project_root.as_ref(); - let paths = index_paths(project_root)?; - let mut scope = ProjectScope::load(project_root, &paths)?; - scope.options.include = include.to_vec(); - scope.options.exclude = exclude.to_vec(); - sync_project_once_with_scope(project_root, &paths, &scope, cancel, |_, _| {}) -} - /// Like [`sync_project_once`] but invokes `on_progress(done, total)` after each /// candidate file is processed, letting a caller drive a progress bar. The /// callback is a pure side effect: it never gates or reorders work, so the @@ -175,15 +155,15 @@ fn sync_project_once_with_scope( match open_sync_writer(paths, cancel)? { SyncWriter::Incremental(mut store) => { let mut candidates = codegraph_extract::engine::scan_project(project_root, options)?; - // Cold CLI sync has no watcher event list, so deletions are found by - // diffing tracked files against scan_project's on-disk set; absent - // paths flow through sync_one's delete branch (upstream removal pass, - // index.ts:1436-1441). The `exists()` guard keeps a still-present file - // that merely became ignored. + // Cold CLI sync has no watcher event list, so removals are found by + // diffing tracked files against scan_project's current in-scope set. + // This includes both physically absent files and still-present files + // that a newly loaded include/exclude/.gitignore rule moved out of + // scope. Both flow through sync_one's forced-absent branch. let on_disk = candidates.iter().cloned().collect::>(); let mut absent = Vec::new(); for tracked in store.all_files()? { - if !on_disk.contains(&tracked.path) && !project_root.join(&tracked.path).exists() { + if !on_disk.contains(&tracked.path) { absent.push(tracked.path); } } @@ -297,19 +277,25 @@ pub fn sync_changed_paths( db_path: impl AsRef, paths: impl IntoIterator>, ) -> Result { - sync_changed_paths_with_patterns(project_root, db_path, paths, &[], &[], None) + sync_changed_paths_current_scope(project_root, db_path, paths, None) } -/// Like [`sync_changed_paths`] but threads the `codegraph.json`/`config.toml` -/// `include`/`exclude` path patterns (#1063) so the watcher's incremental sync -/// scopes files exactly as the full scan does — an included gitignored path is -/// handled, a built-in skip is not, and an explicit `exclude` wins. -pub fn sync_changed_paths_with_patterns( +/// Scoped sync under the addressed project's config as it exists at the start +/// of THIS operation. The watcher uses this path so queued events can never +/// re-admit a file that a newer config has excluded. +pub(crate) fn sync_changed_paths_cancellable( + project_root: impl AsRef, + db_path: impl AsRef, + paths: impl IntoIterator>, + cancel: &SyncCancellation, +) -> Result { + sync_changed_paths_current_scope(project_root, db_path, paths, Some(cancel)) +} + +fn sync_changed_paths_current_scope( project_root: impl AsRef, db_path: impl AsRef, changed: impl IntoIterator>, - include: &[String], - exclude: &[String], cancel: Option<&SyncCancellation>, ) -> Result { let started = Instant::now(); @@ -327,13 +313,12 @@ pub fn sync_changed_paths_with_patterns( ); } - // The addressed project's own immutable config drives extraction and any - // migration escalation; the caller-supplied `include`/`exclude` (the watcher's - // own patterns, derived from this same project config) override those two - // fields so watcher scope and scan scope stay identical. - let mut scope = ProjectScope::load(project_root, &paths)?; - scope.options.include = include.to_vec(); - scope.options.exclude = exclude.to_vec(); + // The addressed project's current config drives extraction and migration. + // Reloading it for every incremental operation prevents a queued event from + // re-admitting a file that a newer scope has excluded. + let scope = ProjectScope::load(project_root, &paths)?; + let include = scope.options.include.clone(); + let exclude = scope.options.exclude.clone(); // Same classify-before-mutate gate as the cold full sync: a Current namespace // stays incremental, while Missing/Outdated/recoverable Building escalate to a @@ -346,8 +331,8 @@ pub fn sync_changed_paths_with_patterns( project_root, changed, &scope, - include, - exclude, + &include, + &exclude, started, |_, _| {}, )?; @@ -406,7 +391,8 @@ fn sync_paths_with_store( continue; } outcome.files_checked += 1; - if !policy.should_handle_file(&relative) { + let in_scope = policy.should_handle_file(&relative); + if !in_scope && store.file_by_path(&relative)?.is_none() { outcome.files_ignored += 1; on_progress(done + 1, total); continue; @@ -420,6 +406,7 @@ fn sync_paths_with_store( &mut dependent_sites, &mut dependent_fallbacks, &mut changed_names, + !in_scope, )? { changed = true; reindexed.insert(relative); @@ -652,6 +639,7 @@ fn sweep_orphaned_refs(project_root: &Path, store: &mut Store) -> Result<()> { /// (`RESOLVE_BATCH_ROWS`) so a healed index is byte-equal to `index --force`. const ORPHAN_SWEEP_BATCH_ROWS: usize = 5_000; +#[cfg(test)] pub(crate) fn default_db_path(project_root: &Path) -> Result { Ok(codegraph_core::IndexPaths::resolve( project_root, @@ -670,12 +658,18 @@ fn sync_one( dependent_sites: &mut BTreeMap>, dependent_fallbacks: &mut BTreeSet, changed_names: &mut HashSet, + force_absent: bool, ) -> Result { let full = project_root.join(relative); // One stat serves as both the existence check and the (mtime, size) source. - let metadata = match fs::metadata(&full) { - Ok(metadata) if metadata.is_file() => metadata, + let metadata = match (!force_absent).then(|| fs::metadata(&full)) { + Some(Ok(metadata)) if metadata.is_file() => metadata, _ => { + let was_tracked = store.file_by_path(relative)?.is_some(); + if !was_tracked { + outcome.files_ignored += 1; + return Ok(false); + } for dependent in store.reference_sites_dependent_on_file(relative)? { merge_dependent_site(dependent_sites, dependent_fallbacks, dependent); } @@ -1622,10 +1616,10 @@ pub(crate) mod tests { } #[test] - fn sync_changed_paths_with_patterns_handles_included_gitignored_file() { - // #1063: the watcher's incremental path uses - // sync_changed_paths_with_patterns; a gitignored file named in include is - // reindexed rather than ignored by the WatchPolicy gate. + fn sync_changed_paths_reloads_include_for_gitignored_file() { + // #1063 plus hot reload: each incremental sync reloads the project's + // current config, so adding an include rule makes a previously ignored + // gitignored file eligible without restarting the watcher. let dir = TestDir::new("watch-incr-include"); fs::write(dir.path().join(".gitignore"), "Tools/\n").unwrap(); fs::create_dir_all(dir.path().join("Tools")).unwrap(); @@ -1641,16 +1635,12 @@ pub(crate) mod tests { assert_eq!(plain.files_reindexed, 0); assert!(plain.files_ignored >= 1); - // With include: the same file is now handled and reindexed. - let included = sync_changed_paths_with_patterns( + write_project_config( dir.path(), - &db, - ["Tools/helper.ts"], - &["Tools/".to_string()], - &[], - None, - ) - .unwrap(); + "[app]\nname = \"p\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", + ); + // With the newly loaded include: the same file is handled and reindexed. + let included = sync_changed_paths(dir.path(), &db, ["Tools/helper.ts"]).unwrap(); assert_eq!(included.files_reindexed, 1); assert!( included @@ -1659,6 +1649,48 @@ pub(crate) mod tests { ); } + #[test] + fn incremental_sync_removes_newly_excluded_file_and_can_readmit_it() { + let dir = TestDir::new("watch-incr-scope-reload"); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/app.ts"), + "export function before() { return 1; }\n", + ) + .unwrap(); + let db = default_db_path(dir.path()).unwrap(); + + let initial = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); + assert_eq!(initial.files_reindexed, 1); + + write_project_config( + dir.path(), + "[app]\nname = \"p\"\n\n[indexing]\nexclude = [\"src/app.ts\"]\n", + ); + let excluded = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); + assert_eq!(excluded.files_removed, 1); + assert!( + Store::open(&db) + .unwrap() + .file_by_path("src/app.ts") + .unwrap() + .is_none(), + "a queued event must not re-admit a file excluded by the current scope" + ); + + write_project_config(dir.path(), "[app]\nname = \"p\"\n"); + let readmitted = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); + assert_eq!(readmitted.files_reindexed, 1); + assert!( + Store::open(&db) + .unwrap() + .file_by_path("src/app.ts") + .unwrap() + .is_some(), + "removing the exclusion must make the same on-disk file indexable again" + ); + } + /// Rewrite the authoritative state slot's built version so the namespace /// classifies `Outdated` while its database stays byte-identical. fn stage_outdated(dir: &TestDir) -> u64 { diff --git a/crates/codegraph-watch/src/watcher.rs b/crates/codegraph-watch/src/watcher.rs index d6ab89bd..758039d7 100644 --- a/crates/codegraph-watch/src/watcher.rs +++ b/crates/codegraph-watch/src/watcher.rs @@ -18,14 +18,13 @@ use codegraph_extract::ExtensionOverrides; use crate::policy::{WatchPolicy, watch_disabled_reason}; use crate::sync::{ - SyncCancellation, SyncOutcome, default_db_path, sync_changed_paths_with_patterns, - sync_project_once_with_patterns, + SyncCancellation, SyncOutcome, sync_changed_paths_cancellable, sync_project_once_cancellable, }; type SyncCallback = Arc; type SyncFn = Arc) -> Result + Send + Sync>; -/// Whole-project sync, used when an event cannot be expressed as a path list — -/// currently only a removed DIRECTORY (see [`RemovalHint`]). +/// Whole-project sync, used when an event changes the effective project scope +/// or cannot be expressed as a path list (see [`RemovalHint`]). type FullSyncFn = Arc Result + Send + Sync>; type NoticeCallback = Arc; @@ -37,6 +36,144 @@ type NoticeCallback = Arc; /// create event. type SharedWatcher = Arc>>; +#[derive(Debug, Clone)] +struct RuntimeWatchScope { + policy: WatchPolicy, + ignore_dirs: Vec, + ignore_paths: Vec, + include: Vec, + exclude: Vec, + extensions: Arc, + debounce: Duration, + enabled: bool, +} + +impl RuntimeWatchScope { + fn from_options(project_root: &Path, options: &WatchOptions) -> Self { + let policy = WatchPolicy::with_config( + project_root, + &options.ignore_dirs, + &options.ignore_paths, + &options.include, + &options.exclude, + ) + .with_extension_overrides(Arc::clone(&options.extensions)); + Self { + policy, + ignore_dirs: options.ignore_dirs.clone(), + ignore_paths: options.ignore_paths.clone(), + include: options.include.clone(), + exclude: options.exclude.clone(), + extensions: Arc::clone(&options.extensions), + debounce: options.debounce, + enabled: !options.no_watch, + } + } + + fn rebuild_policy(&mut self, project_root: &Path) { + self.policy = WatchPolicy::with_config( + project_root, + &self.ignore_dirs, + &self.ignore_paths, + &self.include, + &self.exclude, + ) + .with_extension_overrides(Arc::clone(&self.extensions)); + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct ControlChanges { + config_toml: bool, + codegraph_json: bool, + root_gitignore: bool, +} + +impl ControlChanges { + fn any(self) -> bool { + self.config_toml || self.codegraph_json || self.root_gitignore + } +} + +#[derive(Debug, Clone)] +struct ControlFiles { + config_toml: String, + codegraph_json: String, + root_gitignore: String, +} + +impl ControlFiles { + fn new(project_root: &Path, paths: &IndexPaths) -> Self { + Self { + config_toml: relative_path(project_root, &paths.config_toml()), + codegraph_json: relative_path(project_root, &paths.extension_config()), + root_gitignore: ".gitignore".to_string(), + } + } + + fn classify(&self, relative: &str, changes: &mut ControlChanges) -> bool { + if relative == self.config_toml { + changes.config_toml = true; + true + } else if relative == self.codegraph_json { + changes.codegraph_json = true; + true + } else if relative == self.root_gitignore { + changes.root_gitignore = true; + true + } else { + false + } + } +} + +fn relative_path(project_root: &Path, path: &Path) -> String { + path.strip_prefix(project_root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn reload_runtime_scope( + project_root: &Path, + paths: &IndexPaths, + previous: &RuntimeWatchScope, + changes: ControlChanges, +) -> (RuntimeWatchScope, bool, Option) { + let mut next = previous.clone(); + let mut applied = changes.root_gitignore; + let mut error = None; + + if changes.config_toml { + match Config::load_for_paths(None, paths) { + Ok(config) => { + next.ignore_dirs = config.indexing.ignore_dirs.clone(); + next.ignore_paths = config.indexing.ignore_paths.clone(); + next.include = config.indexing.include.clone(); + next.exclude = config.indexing.exclude.clone(); + next.debounce = debounce_from_env_or(config.watch.debounce_ms); + next.enabled = config.watch.enabled; + applied = true; + } + Err(err) => { + error = Some(format!( + "watch config reload failed; keeping the last valid config: {err}" + )); + } + } + } + if changes.codegraph_json { + // JSON is deliberately tolerant: malformed content produces empty + // overrides plus its existing warning, matching every other loader. + next.extensions = ExtensionOverrides::load_for_paths(paths); + applied = true; + } + if applied { + next.rebuild_policy(project_root); + } + (next, applied, error) +} + // libc errnos used to classify a backend watch failure. Hard-coded (rather than // pulling in `libc`) because these three values are stable across every Unix the // project targets; on Windows `raw_os_error()` returns the Win32 code, which will @@ -250,6 +387,51 @@ fn register_new_dirs(watcher: &SharedWatcher, policy: &WatchPolicy, new_dir: &Pa }); } +fn reconcile_watch_dirs( + watcher: &SharedWatcher, + project_root: &Path, + policy: &WatchPolicy, + known_dirs: &mut BTreeSet, + degraded: &Arc, + on_degraded: &Option, + on_sync_error: &Option, +) -> bool { + let desired = known_directory_paths(project_root, policy); + if watch_registration(platform_watch_backend()) == WatchRegistration::SingleRootRecursive { + *known_dirs = desired; + return true; + } + + let Ok(mut guard) = watcher.lock() else { + if let Some(callback) = on_sync_error { + callback("watch scope reload could not lock the watcher".to_string()); + } + return true; + }; + let Some(watcher) = guard.as_mut() else { + *known_dirs = desired; + return true; + }; + + for relative in known_dirs.difference(&desired) { + // A removed directory often no longer exists by the time this runs; + // unwatch is best-effort in that case because the backend has already + // dropped the path. The desired set remains authoritative. + let _ = watcher.unwatch(&project_root.join(relative)); + } + for relative in desired.difference(known_dirs) { + let path = project_root.join(relative); + if let Err(err) = watcher.watch(&path, RecursiveMode::NonRecursive) { + match handle_watch_error(&err, degraded, on_degraded, on_sync_error) { + WatchErrorClass::Degrade => return false, + WatchErrorClass::Warn | WatchErrorClass::Other => {} + } + } + } + *known_dirs = desired; + true +} + /// Double `prev` for the next backoff step, saturating at [`MAX_BACKOFF`]. /// /// A zero/sub-ms `prev` seeds the schedule at 1ms so the doubling progresses; the @@ -425,33 +607,24 @@ impl ProjectWatcher { if watch_disabled_reason(&project_root, options.no_watch).is_some() { return Ok(None); } - let policy = WatchPolicy::with_config( + let index_paths = Arc::new(IndexPaths::resolve( &project_root, - &options.ignore_dirs, - &options.ignore_paths, - &options.include, - &options.exclude, - ) - .with_extension_overrides(Arc::clone(&options.extensions)); + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )?); + let control_files = ControlFiles::new(&project_root, &index_paths); + let runtime_scope = RuntimeWatchScope::from_options(&project_root, &options); + let policy = runtime_scope.policy.clone(); let db_path = match options.db_path.clone() { Some(db) => db, - None => default_db_path(&project_root)?, + None => index_paths.current_db(), }; let cancel = options.cancel.clone(); let sync_fn = options.sync_fn.clone().unwrap_or_else(|| { let project_root = project_root.clone(); - let include = options.include.clone(); - let exclude = options.exclude.clone(); + let db_path = db_path.clone(); let cancel = cancel.clone(); Arc::new(move |paths| { - sync_changed_paths_with_patterns( - &project_root, - &db_path, - paths, - &include, - &exclude, - Some(&cancel), - ) + sync_changed_paths_cancellable(&project_root, &db_path, paths, &cancel) }) }); // A removed directory cannot be expressed as a path list (its tracked @@ -459,12 +632,8 @@ impl ProjectWatcher { // disk), so it escalates to the SAME full sync `codegraph sync` runs. let full_sync_fn = options.full_sync_fn.clone().unwrap_or_else(|| { let project_root = project_root.clone(); - let include = options.include.clone(); - let exclude = options.exclude.clone(); let cancel = cancel.clone(); - Arc::new(move || { - sync_project_once_with_patterns(&project_root, &include, &exclude, Some(&cancel)) - }) + Arc::new(move || sync_project_once_cancellable(&project_root, &cancel)) }); let (tx, rx) = mpsc::channel(); let degraded = Arc::new(DegradedState::default()); @@ -499,7 +668,22 @@ impl ProjectWatcher { // handles per registration in notify v6). Other targets retain the // pruned per-directory NonRecursive strategy that prevents Linux // inotify exhaustion. - let targets = initial_watch_targets(platform_watch_backend(), &project_root, &policy); + let backend = platform_watch_backend(); + let mut targets = initial_watch_targets(backend, &project_root, &policy); + // The index root is structurally ignored source, but its two + // project-control files must still be observed. Per-directory + // backends therefore add one explicit non-recursive control watch. + if watch_registration(backend) == WatchRegistration::PerDirNonRecursive + && index_paths.current_root().is_dir() + && !targets + .iter() + .any(|(dir, _)| dir == index_paths.current_root()) + { + targets.push(( + index_paths.current_root().to_path_buf(), + RecursiveMode::NonRecursive, + )); + } let mut watch_err: Option = None; for (dir, mode) in &targets { if let Err(err) = watcher.watch(dir, *mode) { @@ -543,11 +727,9 @@ impl ProjectWatcher { Arc::new(Mutex::new(Some(watcher))) }; - let loop_policy = policy.clone(); let on_sync_complete = options.on_sync_complete.clone(); let on_degraded = options.on_degraded.clone(); let on_sync_error = options.on_sync_error.clone(); - let debounce = options.debounce; let loop_degraded = Arc::clone(°raded); let loop_watcher = Arc::clone(&watcher); let finished = Arc::new(AtomicBool::new(false)); @@ -555,8 +737,10 @@ impl ProjectWatcher { let thread = thread::spawn(move || { event_loop(EventLoopCtx { rx, - policy: loop_policy, - debounce, + project_root, + index_paths, + control_files, + runtime_scope, sync_fn, full_sync_fn, on_sync_complete, @@ -763,8 +947,10 @@ struct PendingInfo { struct EventLoopCtx { rx: mpsc::Receiver, - policy: WatchPolicy, - debounce: Duration, + project_root: PathBuf, + index_paths: Arc, + control_files: ControlFiles, + runtime_scope: RuntimeWatchScope, sync_fn: SyncFn, full_sync_fn: FullSyncFn, on_sync_complete: Option, @@ -778,8 +964,10 @@ struct EventLoopCtx { fn event_loop(ctx: EventLoopCtx) { let EventLoopCtx { rx, - policy, - debounce, + project_root, + index_paths, + control_files, + mut runtime_scope, sync_fn, full_sync_fn, on_sync_complete, @@ -812,41 +1000,74 @@ fn event_loop(ctx: EventLoopCtx) { match message { Some(LoopMessage::Event(batch)) => { let WatchEventBatch { paths, removal } = batch; + let mut control_changes = ControlChanges::default(); + let mut normalized = Vec::new(); for path in paths { - if let Some(relative) = policy.normalize_relative(&path) { - // A removed DIRECTORY bypasses extension filtering: it has - // no source extension, so the file gate below would drop it - // and every tracked descendant would linger in the index - // forever. The watch policy still applies (an ignored dir is - // still ignored), and the removal escalates the burst to one - // full sync — the only pass that can find those descendants. - if classify_removed_directory(removal, &relative, &mut known_dirs) { - if policy.should_watch_dir(&relative) { - full_sync_pending = true; - let now = epoch_millis(); + if let Some(relative) = runtime_scope.policy.normalize_relative(&path) { + let is_control = control_files.classify(&relative, &mut control_changes); + normalized.push((path, relative, is_control)); + } + } + + // Project control files are recognized BEFORE ordinary + // include/exclude filtering. A successful reload swaps the + // complete runtime scope at once, reconciles the OS watch set, + // and makes one full scan dominate every queued path delta. + if control_changes.any() { + let (next, applied, reload_error) = reload_runtime_scope( + &project_root, + &index_paths, + &runtime_scope, + control_changes, + ); + if let Some(reason) = reload_error + && let Some(callback) = &on_sync_error + { + callback(reason); + } + if applied { + if !reconcile_watch_dirs( + &watcher, + &project_root, + &next.policy, + &mut known_dirs, + °raded, + &on_degraded, + &on_sync_error, + ) { + break; + } + runtime_scope = next; + full_sync_pending = true; + let now = epoch_millis(); + for (_, relative, is_control) in &normalized { + if *is_control { pending - .entry(relative) + .entry(relative.clone()) .and_modify(|info| info.last_seen_ms = now) .or_insert(PendingInfo { first_seen_ms: now, last_seen_ms: now, }); } - continue; - } - // A brand-new non-ignored directory holds no inotify watch - // yet (Linux watches are per-dir NonRecursive — see - // `collect_watch_dirs`). Register it (and any non-ignored - // descendants created in the same burst, e.g. `mkdir -p`) so - // edits inside it are seen without a server restart. - if path.is_dir() && policy.should_watch_dir(&relative) { - register_new_dirs(&watcher, &policy, &path); - known_dirs.extend(known_directory_paths(&path, &policy)); } - if policy.should_handle_file(&relative) - || (policy.allows_file_path(&relative) - && maybe_deleted_source(&relative)) - { + } + } + + for (path, relative, is_control) in normalized { + if is_control || !runtime_scope.enabled { + continue; + } + + // A removed DIRECTORY bypasses extension filtering: it has + // no source extension, so the file gate below would drop it + // and every tracked descendant would linger in the index + // forever. The watch policy still applies (an ignored dir is + // still ignored), and the removal escalates the burst to one + // full sync — the only pass that can find those descendants. + if classify_removed_directory(removal, &relative, &mut known_dirs) { + if runtime_scope.policy.should_watch_dir(&relative) { + full_sync_pending = true; let now = epoch_millis(); pending .entry(relative) @@ -856,12 +1077,35 @@ fn event_loop(ctx: EventLoopCtx) { last_seen_ms: now, }); } + continue; + } + // A brand-new non-ignored directory holds no inotify watch + // yet (Linux watches are per-dir NonRecursive — see + // `collect_watch_dirs`). Register it (and any non-ignored + // descendants created in the same burst, e.g. `mkdir -p`) so + // edits inside it are seen without a server restart. + if path.is_dir() && runtime_scope.policy.should_watch_dir(&relative) { + register_new_dirs(&watcher, &runtime_scope.policy, &path); + known_dirs.extend(known_directory_paths(&path, &runtime_scope.policy)); + } + if runtime_scope.policy.should_handle_file(&relative) + || (runtime_scope.policy.allows_file_path(&relative) + && maybe_deleted_source(&relative)) + { + let now = epoch_millis(); + pending + .entry(relative) + .and_modify(|info| info.last_seen_ms = now) + .or_insert(PendingInfo { + first_seen_ms: now, + last_seen_ms: now, + }); } } if !pending.is_empty() { // Resetting the timer on every event ports the upstream exactly-once // burst semantics (`upstream sync/watcher.ts:529-540`). - deadline = Some(Instant::now() + debounce); + deadline = Some(Instant::now() + runtime_scope.debounce); } } Some(LoopMessage::WatchError(err)) => { @@ -1116,6 +1360,20 @@ mod tests { use std::fs; use std::sync::{Arc, Mutex}; + fn write_current_config(project_root: &Path, contents: &str) -> IndexPaths { + let paths = IndexPaths::resolve(project_root, None).unwrap(); + fs::create_dir_all(paths.current_root()).unwrap(); + fs::write(paths.config_toml(), contents).unwrap(); + paths + } + + fn live_project_options(project_root: &Path) -> WatchOptions { + let paths = IndexPaths::resolve(project_root, None).unwrap(); + let config = Config::load_for_paths(None, &paths).unwrap(); + let extensions = ExtensionOverrides::load_for_paths(&paths); + WatchOptions::for_project(&config, extensions) + } + #[test] fn noop_watcher_completion_preserves_trigger_paths() { let outcome = with_trigger_paths( @@ -1191,6 +1449,12 @@ mod tests { // descendant — instead of an incremental path-scoped sync. let dir = crate::sync::tests::TestDir::new("watch-deleted-dir"); fs::write(dir.path().join(".gitignore"), "Tools/\n").unwrap(); + let index_paths = IndexPaths::resolve(dir.path(), None).unwrap(); + fs::write( + index_paths.config_toml(), + "[app]\nname = \"watch-deleted-dir\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", + ) + .unwrap(); fs::create_dir_all(dir.path().join("Tools/feature")).unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::write( @@ -1214,7 +1478,7 @@ mod tests { ) .unwrap(); let db = crate::sync::default_db_path(dir.path()).unwrap(); - let indexed = crate::sync::sync_changed_paths_with_patterns( + let indexed = crate::sync::sync_changed_paths( dir.path(), &db, [ @@ -1223,9 +1487,6 @@ mod tests { "Tools/keep.ts", "src/excluded.ts", ], - &["Tools/".to_string()], - &[], - None, ) .unwrap(); assert_eq!( @@ -1233,10 +1494,9 @@ mod tests { "all fixture files indexed first" ); - // The watcher-local patterns deliberately oppose the process-global/default - // full-sync scope: include the gitignored Tools tree, but exclude one normal - // source file. Both surviving files change before the directory removal so - // the resulting stored symbols prove which patterns the full sync used. + // Both surviving files change before the directory removal so the + // resulting stored symbols prove the full sync reloaded the project's + // authoritative current config rather than a frozen startup snapshot. fs::write( dir.path().join("Tools/keep.ts"), "export function includedAfter() { return 30; }\n", @@ -1265,7 +1525,6 @@ mod tests { db_path: Some(db.clone()), sync_fn: Some(sync_fn), include: vec!["Tools/".to_string()], - exclude: vec!["src/excluded.ts".to_string()], on_sync_complete: Some(Arc::new(move |outcome| { outcome_tx.send(outcome).unwrap(); })), @@ -1324,12 +1583,240 @@ mod tests { .map(|node| node.name) .collect::>(); assert!( - excluded_names.iter().any(|name| name == "excludedBefore") - && !excluded_names.iter().any(|name| name == "excludedAfter"), - "watcher-local exclude must keep src/excluded.ts untouched: {excluded_names:?}" + excluded_names.iter().any(|name| name == "excludedAfter"), + "the current config must let full sync refresh src/excluded.ts: {excluded_names:?}" ); } + #[test] + fn config_reload_reconciles_scope_and_dominates_queued_paths() { + let _env = crate::test_env::env_guard(); + let dir = crate::sync::tests::TestDir::new("watch-config-reload"); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::create_dir_all(dir.path().join("generated")).unwrap(); + fs::write( + dir.path().join("src/keep.ts"), + "export function keep() { return 1; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("generated/drop.ts"), + "export function drop() { return 2; }\n", + ) + .unwrap(); + let paths = write_current_config(dir.path(), "[app]\nname = \"watch-config\"\n"); + let initial = crate::sync::sync_project_once(dir.path()).unwrap(); + assert_eq!(initial.files_reindexed, 2); + + let (outcome_tx, outcome_rx) = mpsc::channel(); + let mut options = live_project_options(dir.path()); + options.debounce = Duration::from_secs(30); + options.inert_for_tests = true; + options.on_sync_complete = Some(Arc::new(move |outcome| { + outcome_tx.send(outcome).unwrap(); + })); + let watcher = ProjectWatcher::start(dir.path(), options).unwrap().unwrap(); + let control = relative_path(dir.path(), &paths.config_toml()); + + fs::write( + paths.config_toml(), + "[app]\nname = \"watch-config\"\n\n[indexing]\nexclude = [\"generated/\"]\n", + ) + .unwrap(); + watcher.ingest_event_for_tests(&control); + watcher.ingest_event_for_tests("generated/drop.ts"); + watcher.flush_for_tests(); + let excluded = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("scope-removal full sync"); + assert_eq!(excluded.files_removed, 1); + assert_eq!(excluded.trigger_paths, vec![control.clone()]); + assert!( + codegraph_store::Store::open(&paths.current_db()) + .unwrap() + .file_by_path("generated/drop.ts") + .unwrap() + .is_none(), + "the full reconcile must remove a file excluded by the new scope" + ); + + fs::write(paths.config_toml(), "[app]\nname = \"watch-config\"\n").unwrap(); + watcher.ingest_event_for_tests(&control); + watcher.flush_for_tests(); + let readmitted = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("scope-readmission full sync"); + assert_eq!(readmitted.files_reindexed, 1); + assert!( + codegraph_store::Store::open(&paths.current_db()) + .unwrap() + .file_by_path("generated/drop.ts") + .unwrap() + .is_some(), + "removing the exclusion must readmit the existing source file" + ); + watcher.stop(); + } + + #[test] + fn malformed_toml_keeps_last_valid_scope_and_reports_error() { + let _env = crate::test_env::env_guard(); + let dir = crate::sync::tests::TestDir::new("watch-config-invalid"); + fs::create_dir_all(dir.path().join("generated")).unwrap(); + fs::write( + dir.path().join("generated/drop.ts"), + "export function drop() { return 1; }\n", + ) + .unwrap(); + let paths = write_current_config( + dir.path(), + "[app]\nname = \"watch-config\"\n\n[indexing]\nexclude = [\"generated/\"]\n", + ); + let control = relative_path(dir.path(), &paths.config_toml()); + let incremental_calls = Arc::new(AtomicUsize::new(0)); + let incremental_counter = Arc::clone(&incremental_calls); + let full_calls = Arc::new(AtomicUsize::new(0)); + let full_counter = Arc::clone(&full_calls); + let (error_tx, error_rx) = mpsc::channel(); + let mut options = live_project_options(dir.path()); + options.debounce = Duration::from_secs(30); + options.inert_for_tests = true; + options.sync_fn = Some(Arc::new(move |_| { + incremental_counter.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + })); + options.full_sync_fn = Some(Arc::new(move || { + full_counter.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + })); + options.on_sync_error = Some(Arc::new(move |error| { + error_tx.send(error).unwrap(); + })); + let watcher = ProjectWatcher::start(dir.path(), options).unwrap().unwrap(); + + fs::write(paths.config_toml(), "[app\nthis is not toml").unwrap(); + watcher.ingest_event_for_tests(&control); + watcher.flush_for_tests(); + let error = error_rx + .recv_timeout(Duration::from_secs(2)) + .expect("malformed config warning"); + assert!(error.contains("keeping the last valid config"), "{error}"); + + watcher.ingest_event_for_tests("generated/drop.ts"); + watcher.flush_for_tests(); + assert!(watcher.pending_files().is_empty()); + watcher.stop(); + assert_eq!(incremental_calls.load(AtomicOrdering::SeqCst), 0); + assert_eq!(full_calls.load(AtomicOrdering::SeqCst), 0); + } + + #[test] + fn extension_config_reload_handles_and_then_drops_custom_source() { + let _env = crate::test_env::env_guard(); + let dir = crate::sync::tests::TestDir::new("watch-extension-reload"); + fs::write( + dir.path().join("plugin.zz"), + "local function plugin()\n return 1\nend\n", + ) + .unwrap(); + let paths = IndexPaths::resolve(dir.path(), None).unwrap(); + let control = relative_path(dir.path(), &paths.extension_config()); + let (outcome_tx, outcome_rx) = mpsc::channel(); + let mut options = live_project_options(dir.path()); + options.debounce = Duration::from_secs(30); + options.inert_for_tests = true; + options.on_sync_complete = Some(Arc::new(move |outcome| { + outcome_tx.send(outcome).unwrap(); + })); + let watcher = ProjectWatcher::start(dir.path(), options).unwrap().unwrap(); + + fs::write(paths.extension_config(), r#"{"extensions":{".zz":"lua"}}"#).unwrap(); + watcher.ingest_event_for_tests(&control); + watcher.ingest_event_for_tests("plugin.zz"); + watcher.flush_for_tests(); + let added = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("extension-add full sync"); + assert_eq!(added.files_reindexed, 1); + assert_eq!( + added.trigger_paths, + vec![control.clone(), "plugin.zz".to_string()] + ); + assert!( + codegraph_store::Store::open(&paths.current_db()) + .unwrap() + .file_by_path("plugin.zz") + .unwrap() + .is_some() + ); + + fs::write(paths.extension_config(), "{ malformed").unwrap(); + watcher.ingest_event_for_tests(&control); + watcher.flush_for_tests(); + let removed = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("tolerant extension reset full sync"); + assert_eq!(removed.files_removed, 1); + assert!( + codegraph_store::Store::open(&paths.current_db()) + .unwrap() + .file_by_path("plugin.zz") + .unwrap() + .is_none(), + "malformed JSON keeps the existing tolerant empty-override semantics" + ); + watcher.stop(); + } + + #[test] + fn root_gitignore_reload_reconciles_and_readmits_sources() { + let _env = crate::test_env::env_guard(); + let dir = crate::sync::tests::TestDir::new("watch-gitignore-reload"); + fs::create_dir_all(dir.path().join("generated")).unwrap(); + fs::write( + dir.path().join("generated/drop.ts"), + "export function drop() { return 1; }\n", + ) + .unwrap(); + let paths = IndexPaths::resolve(dir.path(), None).unwrap(); + let initial = crate::sync::sync_project_once(dir.path()).unwrap(); + assert_eq!(initial.files_reindexed, 1); + let (outcome_tx, outcome_rx) = mpsc::channel(); + let mut options = live_project_options(dir.path()); + options.debounce = Duration::from_secs(30); + options.inert_for_tests = true; + options.on_sync_complete = Some(Arc::new(move |outcome| { + outcome_tx.send(outcome).unwrap(); + })); + let watcher = ProjectWatcher::start(dir.path(), options).unwrap().unwrap(); + + fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap(); + watcher.ingest_event_for_tests(".gitignore"); + watcher.ingest_event_for_tests("generated/drop.ts"); + watcher.flush_for_tests(); + let removed = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("gitignore-removal full sync"); + assert_eq!(removed.files_removed, 1); + assert_eq!(removed.trigger_paths, vec![".gitignore".to_string()]); + + fs::write(dir.path().join(".gitignore"), "").unwrap(); + watcher.ingest_event_for_tests(".gitignore"); + watcher.flush_for_tests(); + let readmitted = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("gitignore-readmission full sync"); + assert_eq!(readmitted.files_reindexed, 1); + assert!( + codegraph_store::Store::open(&paths.current_db()) + .unwrap() + .file_by_path("generated/drop.ts") + .unwrap() + .is_some() + ); + watcher.stop(); + } + #[test] fn ambiguous_remove_of_known_directory_keeps_directory_semantics() { // Windows ReadDirectoryChangesW reports directory deletion as Any. The diff --git a/docs/architecture.md b/docs/architecture.md index 638a32f8..7b6dd12e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -302,9 +302,15 @@ serverInfo:{name:"codegraph",version}, instructions}`;`tools/list` 返回 8 - 基于 `notify` v6 `RecommendedWatcher`(递归 + 测试用 inert seam)。事件循环按 burst 合并:每事件重置单一去抖截止点(`watcher.ts:529-540`)。 - 默认去抖 `CODEGRAPH_WATCH_DEBOUNCE_MS`,clamp 到 100ms..60s,回落 2000ms。 -- 过滤逻辑:默认忽略目录 + 根 `.gitignore` 合并(negation 在内建之后应用)。 +- 过滤逻辑:默认忽略目录 + 当前索引根 `config.toml` 的 include/exclude + + 根 `.gitignore` 合并(negation 在内建之后应用)。当前索引根 + `config.toml` / `codegraph.json` 与根 `.gitignore` 是控制文件,事件先于普通 + scope 过滤识别;变更会重载 Config、扩展名映射和 WatchPolicy,原子替换运行时 + scope、增减 per-directory watch,并触发一次覆盖所有已排队增量的完整 reconcile。 + TOML 损坏时继续使用最后一次有效 scope;JSON 保持宽容的空 override 语义。 - 增量同步:对候选变更文件读取并哈希,`files.content_hash` 命中则跳过;否则删除 旧的 per-file 节点/文件行 → 写入新提取结果 → 持久化后跑 `ReferenceResolver`。 + 每次增量操作都会重新加载当前项目 scope,因此旧事件无法把新排除的文件重新加入。 `codegraph-extract` 保持只读,watch 仅调用其公共 API。 --- diff --git a/docs/cli.md b/docs/cli.md index 9c8b894b..c198f808 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -863,6 +863,16 @@ This keeps the total watch count well inside the OS inotify limit on large trees and makes daemon startup fast. A newly-created non-ignored directory is picked up automatically on its create event — no restart required. +Three project-control files are recognized before ordinary include/exclude +filtering: the selected index root's `config.toml` and `codegraph.json`, plus the +project-root `.gitignore`. Editing one reloads the effective Config, extension +overrides, and watch policy, atomically replaces the live scope, reconciles +per-directory OS watches, and schedules one full project reconcile. That full +reconcile dominates queued path events, while every later incremental sync +re-checks the current scope, so an old event cannot re-add a newly excluded +file. Invalid TOML keeps the last valid runtime scope and reports the error; +malformed JSON retains the existing tolerant empty-override behavior. + The watcher is also auto-disabled when the resolved project root is the filesystem root (`/`) or the current user's home directory (`$HOME`). This commonly happens when an IDE or agent (e.g. Kiro) launches `codegraph serve @@ -930,6 +940,24 @@ Rules: inheritance, so one project can never adopt another's overrides. - A malformed JSON file is ignored and the error is logged; it does not abort indexing. +- A running watcher reloads this file on change and performs one full reconcile, + so adding or removing an extension override takes effect without restarting + the MCP server. + +### Extraction-version upgrades + +After upgrading the binary, let diagnostics choose the least destructive path: + +```bash +codegraph status /path/to/project --json +codegraph init /path/to/project # no usable index +codegraph sync /path/to/project # ordinary changes or a supported upgrade +codegraph index --force /path/to/project # only when the CLI explicitly requires recovery +``` + +Extraction version 11 → 12 is a supported `sync` upgrade: `status` reports the +old index as outdated, and `sync` rebuilds it into the current namespace. Do not +run `index --force` solely because the extraction version changed. ### `--prompt-hook` detail diff --git a/docs/equivalence.md b/docs/equivalence.md index 3306df51..26a49d52 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -261,9 +261,10 @@ Like the Godot fixture, both the index and the dump are byte-stable, and the ### Python fixture The dedicated `reference/golden/python/` fixture guards Python bare -class-as-value function references without changing the shared `mini` corpus. -Its three-file source corpus lives at `crates/codegraph-bench/fixtures/python/` -and pins six positive `References` edges: +class-as-value references plus aliased imports (#1626) without changing the +shared `mini` corpus. Its seven indexed files live at +`crates/codegraph-bench/fixtures/python/` and retain the original six positive +`References` edges: - same-file class values in a direct return, assignment RHS, registry-pair value, call argument, and list literal; @@ -278,6 +279,19 @@ It also pins two negative boundaries: a tuple return does not recurse into `register(...)` calls used by the argument and method shapes legitimately remain as two unresolved `calls` rows. +The added alias files pin three import-resolution contracts: + +- `import top_level as top_alias; top_alias.top_func()` binds the receiver to + module `top_level`, never to a global same-named callable; +- `from pkg import module as mod_alias; mod_alias.func()` prefers the existing + module file `pkg/module.py`; +- `from imported_types import ImportedClass as ImportedAlias; return +ImportedAlias` preserves member-import semantics and resolves the aliased + class-as-value reference by import. + +Missing/duplicate/ambiguous module aliases remain unresolved, and a claimed +module alias never falls through to global bare-name matching. + Regenerate the committed database and canonical artifacts from a clean corpus: ```bash @@ -359,6 +373,12 @@ general C++ inheritance shapes plus templated-base stripping: Since the C++ namespace-prefix work (Release D) stores `Tpl`'s qualified name as `ns::Tpl`, this ref now RESOLVES to a real `Extends` edge (`Q` → `ns::Tpl`) in `edges.json` instead of remaining an unresolved ref. +- **plain derived declarations in `.h`** — `plain_derived.h` is promoted from + the extension-default C parser to C++ by a full-source, lexically masked + `class/struct Derived : Base` probe. It covers access modifiers, `virtual`, + qualified names, and templated bases. `plain_header_negatives.h` pins the + false-positive boundary: comments, strings, character literals, + preprocessor text, bitfields, labels, and ternaries do not promote C source. Three further files exercise the Release D C++ extraction gains: @@ -488,8 +508,9 @@ The minimal source corpus lives at `crates/codegraph-bench/fixtures/cpp/` (`attr_macro.c`, `base.hpp`, `com_interface.hpp`, `derived.cpp`, `instantiate_agg.cpp`, `instantiate_rank.cpp`, `namespaced.cpp`, `namespaced_member.cpp`, `namespaced_member.hpp`, `neg_interface.hpp`, -`operators.cpp`, `template_method.cpp`, `templated_call.cpp`, `ue_actor.h`, -`union_agg.c`, `union_agg.cpp`, `union_agg.mm` — 17 files). The inheritance base +`operators.cpp`, `plain_derived.h`, `plain_header_negatives.h`, +`template_method.cpp`, `templated_call.cpp`, `ue_actor.h`, `union_agg.c`, +`union_agg.cpp`, `union_agg.mm` — 19 files). The inheritance base classes live in a `.hpp` file (not `.h`, which maps to `Language::C` by extension); `ue_actor.h` deliberately uses `.h` to guard the content-based C++ reclassification, and `attr_macro.c` uses `.c` so the @@ -522,9 +543,9 @@ Like the Ruby fixture, both the index and the dump are byte-stable, and the ### Rust fixture -`reference/golden/rust/` guards the two Tier 3 items that need Rust source, which -no other corpus could reach — before it there was no `.rs` file anywhere under -`crates/codegraph-bench/fixtures/`: +`reference/golden/rust/` is the repository's Rust corpus. It retains the +original unit-struct/union contracts and now also pins generic `impl` ownership +and `self.field.method()` resolution: - **unit structs are indexed** (upstream #1513 / PR #1514) — `lib.rs` declares `struct UnitStruct;` beside `BraceStruct { … }` and `TupleStruct(u8)`, and gives @@ -540,6 +561,17 @@ no other corpus could reach — before it there was no `.rs` file anywhere under - **cross-file resolution** — `consumer.rs` calls `u.greet()` / `b.greet()` on a `&UnitStruct` and a `&Bits`, so `implements` and method resolution have to work across files rather than only within one. +- **implementing-type ownership** — `impl_ownership.rs` covers generic, + lifetime, reference, qualified, inherent, and trait impls. Methods qualify + under the implementing type, not the trait; only trait impls emit + `Implements`. Tuple, `dyn`, raw-pointer, and primitive impls remain ordinary + functions because there is no single safe owner. +- **validated self-field calls** — `self_field.rs` pins + `self.inner.run()`/reference/`Box` wrappers to the unique project field type. + `Option`, `Vec`, `Mutex`, generic, external, and ambiguous field types stay + unresolved, and genuine `self.run()` recursion keeps its self-edge. Once the + extractor recognizes the single-hop shape it never falls through to a global + same-name guess. **The Rust corpus deliberately makes NO instantiation claim.** An `instantiates` edge fires only for the CALL-EXPRESSION construction form: `TupleStruct(2)` yes, a bare @@ -551,6 +583,9 @@ instead (`instantiate_agg.cpp` / `instantiate_rank.cpp`), which is upstream's ow shape. The corpus carries **no `Cargo.toml`**: it is indexed, not compiled, and a manifest inside the workspace tree could confuse `cargo`. +The four source files are `lib.rs`, `consumer.rs`, `impl_ownership.rs`, and +`self_field.rs`. + Regenerate reproducibly (identical recipe to the C++ fixture, substituting `rust`): ```bash @@ -568,6 +603,39 @@ cargo run -p codegraph-bench --bin bench -- \ `generated_golden_matches_committed_rust_fixture` and `rust_db_is_self_equivalent_to_rust_golden` enforce byte-stability. +### Lua fixture + +`reference/golden/lua/` guards #1616 function expressions in assignments and +table fields. Its single `handlers.lua` source pins: + +- `local localFn = function ... end` as one `Function` and no duplicate + `Variable`; +- `M.assignedFn = function` and `M["bracketFn"] = function` as methods qualified + `M::assignedFn` / `M::bracketFn`; +- nested constructor fields as `M.callbacks::onStart` and + `M.callbacks::onStop`, while a computed key remains dynamic and emits no + callable; +- every body `helper()` call attributed to its synthesized callable; +- `localFn()`, `M.assignedFn()`, and `M:assignedFn()` resolving to the intended + target, with dot and colon calls sharing `M::assignedFn`. + +Regenerate reproducibly: + +```bash +mkdir -p reference/golden/lua +rm -rf /tmp/cg-fixture-lua +cp -r crates/codegraph-bench/fixtures/lua /tmp/cg-fixture-lua +cargo build --release -p codegraph-rs +CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ + ./target/release/codegraph init /tmp/cg-fixture-lua +cp /tmp/cg-fixture-lua/.codegraph/codegraph.db reference/golden/lua/colby.db +cargo run -p codegraph-bench --bin bench -- \ + --gen-golden reference/golden/lua/colby.db reference/golden/lua +``` + +`generated_golden_matches_committed_lua_fixture` and +`lua_db_is_self_equivalent_to_lua_golden` enforce byte-stability. + ### Go fixture `reference/golden/go/` byte-pins `files.generated` — the content-header @@ -944,47 +1012,39 @@ The `generated_golden_matches_committed_terraform_fixture` and ### Erlang fixture -An eleventh golden fixture, `reference/golden/erlang/`, guards Erlang extraction -(upstream #1165 / `6511722`, the extraction slice only). Erlang is a **new -`Language::Erlang` variant** backed by a **dedicated `tree-sitter-erlang` -grammar** (`.erl`/`.hrl` → `Language::Erlang`). Erlang is form-based — a -function's name lives on its `function_clause`, the grammar emits one `fun_decl` -per clause, `record_decl` carries fields as direct children, and -`-spec`/`-callback`/type bodies parse as `call` nodes — so `ERLANG_SPEC` has -all-empty C-family type-sets (only `package_types`/`import_types` are wired, as -upstream) and extraction is driven by the `Language::Erlang`-guarded -`visit_erlang_node` walker extension. The corpus -(`crates/codegraph-bench/fixtures/erlang/m.erl`) is a single deterministic file -with `-module(m)`, `-export([f/1, g/0])`, `-include("foo.hrl")`, `-define(X, 1)`, -`-record(state, {a, b})`, a `-spec f(integer()) -> integer().`, a two-clause -`f/1`, and a `g/0` that references `fun f/1`, constructs `#state{}`, calls the -remote `other:h()`, and self-calls `g()`. What it guards: - -- the `.erl` file with `"language": "erlang"`; -- `-module(m)` → `NodeKind::Namespace` (so every function's qualified name is - `m::f` — the shape the remote-call branch emits, so `mod:f(...)` resolves - through the standard qualified-name matcher); -- clause-merge dedup: the two `f/1` clauses merge to exactly ONE - `NodeKind::Function` `f`; -- `-record(state, {a, b})` → `NodeKind::Struct` `state` with `NodeKind::Field` - children `a` and `b`; -- `-define(X, 1)` → `NodeKind::Constant` `X`; -- `-include("foo.hrl")` → `NodeKind::Import` + an `Imports` file edge; -- local `g()` → a `Calls` edge, remote `other:h()` → a `Calls` ref `other::h`; -- `fun f/1` (function value) and `#state{}` (record usage) → `References`, NOT - `Calls`; -- the `-spec f(integer()) -> integer().` and `-callback` / record-field - type-position `call` nodes mint NO bogus type call refs (no `integer` call). - -The local `g()` self-call and the `foo.hrl` include resolve; `other::h` (the -`other` module is absent from the fixture) is the sole unresolved `refs.json` -row. The non-Godot framework bridges — `-behaviour` callback contracts, -`gen_server:call/cast(?MODULE|?SERVER)` → `handle_call`/`handle_cast`, the -`spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument callee lift, var-module -dispatch, and `.app`/`.app.src` resource-tuple wiring — are all **DEFERRED**, so -none of those edges is emitted. Adding the variant is byte-neutral for -`colby.schema.sql` (language is a stored TEXT value, not DDL) and for the ten -existing goldens (none holds a `.erl`/`.hrl` file). +`reference/golden/erlang/` retains the original Erlang extraction contract +(upstream #1165 / `6511722`) and now guards arity-qualified identity and +resolution (upstream `41c1075`). Erlang is form-based — a function's name lives +on its `function_clause`, the grammar emits one `fun_decl` per clause, and +`-spec`/type bodies also contain `call` nodes — so extraction remains in the +`Language::Erlang`-guarded walker extension rather than the C-family type sets. + +The seven-file corpus under `crates/codegraph-bench/fixtures/erlang/` pins: + +- display names remain bare (`get`, `job`, `header`), while qualified names are + `module::function/arity`, such as `store::get/1`, `store::get/2`, + `multi::job/1`, and `multi::job/2`; +- adjacent clauses merge only when both name and arity match (`m::f/1` remains + one function), while same-name/different-arity definitions remain distinct; +- exports and specs select the exact arity; +- local and remote calls carry arity, so `store:get/2` resolves while the + deliberate `store:get/3` call stays unresolved instead of binding a sibling; +- `fun store:get/1` resolves as a function-value `References` edge; +- the statically safe `erlang:spawn(single, work, Args)` MFA form resolves + `single::work/1`; `multi::job` stays unresolved when the runtime argument + list does not prove whether `/1` or `/2` is intended; +- commas inside `<>` do not inflate `binary_arg/1` or its call + arity; +- the original module, record, macro, include, recursive-call, and + type-position negatives in `m.erl` remain intact. + +The unresolved set intentionally includes `store::get/3`, external library +calls such as `lists::map/2` and `maps::get/3`, `other::h/0`, the runtime +`erlang::spawn/3` calls themselves, and ambiguous `multi::job`. Variable-module +dispatch, dynamic MFA targets, behaviour callback contracts, and +`.app`/`.app.src` resource wiring remain outside this corpus. The CLI and MCP +node lookup normalize Erlang's source spelling `mod:fn/3` to the stored +`mod::fn/3` form. Regenerate reproducibly (identical recipe to the Terraform fixture, substituting `erlang`): @@ -1051,12 +1111,14 @@ The `generated_golden_matches_committed_cfml_fixture` and ### TypeScript resolution fixture The dedicated `reference/golden/typescript/` fixture guards TypeScript export -alias and explicit JavaScript-specifier resolution (#1482 / #1482b) without -changing the shared `mini` corpus. Its six-file source corpus lives at -`crates/codegraph-bench/fixtures/typescript/`; source lines are contractual -because node IDs include the declaration line. +aliases, JavaScript-family import resolution, object-literal namespaces, and +inherited path aliases without changing the shared `mini` corpus. It has 14 +indexed source files plus `tsconfig.json` and +`config/tsconfig.base.json`; source lines are contractual because node IDs +include the declaration line. -The `runAll` function pins six positive `Calls` edges, in source order: +The original `runAll` function retains six positive `Calls` edges, in source +order: - `viaConstAlias()` → the local `constTarget` function behind `export const constAlias = constTarget`; @@ -1071,10 +1133,25 @@ The `runAll` function pins six positive `Calls` edges, in source order: the existing extensionless behavior. Two negative rules make false positives visible: `viaMissing` remains the only -unresolved call and import pair in `refs.json`, with no `Calls` edge; and no +unresolved direct-import call and import pair in `refs.json`, with no `Calls` +edge; and no `Calls` edge may target either the exported `Constant` `constAlias` or the JavaScript `collisionTarget` in `collision.js`. +The expanded corpus also pins: + +- local `api.run()` / `api.stop()` and imported `api.run()` / `api.stop()` to + direct callable members of one exported object literal; +- source-range containment prevents the unrelated top-level `run` function + from becoming the target, while nested `api.nested.run()` remains unresolved; +- `tsconfig.json` extends the JSONC/trailing-comma + `config/tsconfig.base.json`; the declaring config's `baseUrl` resolves + `@fixture/aliased` to `src/aliased.ts`; +- extensionless imports consider `.xsjs` and `.xsjslib` after ordinary + `.js`/`.jsx`/`.mjs`/`.cjs` candidates and before index candidates, so + `legacy_helpers.xsjslib` resolves while `legacy_priority.js` still wins over + the same-name `.xsjs` file. + Regenerate the committed database and canonical artifacts from a clean corpus: ```bash diff --git a/docs/languages.md b/docs/languages.md index 76b086f0..8bcbd7c5 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -20,37 +20,37 @@ Tree-sitter parses the file and extracts all symbols (functions, classes, struct variables, imports, etc.) plus call and dependency edges. This is the richest extraction level. -| Language | Extensions | Extraction | Notes | -| ----------- | ------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | | -| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | -| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | | -| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | -| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | -| Python | `.py` `.pyw` | Full tree-sitter | Bare class names used as values resolve to `References` edges in return, assignment, pair, argument, and list positions. Tuple returns are not recursively traversed, and bare names never resolve to methods; methods require a receiver. | -| Go | `.go` | Full tree-sitter | | -| Rust | `.rs` | Full tree-sitter | | -| Java | `.java` | Full tree-sitter | | -| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C by heuristics | -| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` | Full tree-sitter | | -| C# | `.cs` | Full tree-sitter | | -| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | -| Ruby | `.rb` `.rake` | Full tree-sitter | | -| Swift | `.swift` | Full tree-sitter | | -| Kotlin | `.kt` `.kts` | Full tree-sitter | Top-level functions, class methods, and extension functions include raw parameter-list signatures with optional declared return types; constructors, accessors, lambdas, and anonymous functions are not synthesized as callable nodes | -| Dart | `.dart` | Full tree-sitter | | -| Scala | `.scala` `.sc` | Full tree-sitter | | -| Lua | `.lua` | Full tree-sitter | | -| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | -| Objective-C | `.m` `.mm` | Full tree-sitter | | -| R | `.r` | Full tree-sitter | | -| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | -| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred | -| Terraform | `.tf` `.tfvars` `.tofu` | Full tree-sitter | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver deferred | -| Erlang | `.erl` `.hrl` | Full tree-sitter | `tree-sitter-erlang` grammar (WhatsApp/ELP); `-module`→namespace (functions qualify as `m::f`), multi-clause `fun_decl`→one function (clause-merge), `-record`→struct+fields, `-define`→constant, `-type`/`-opaque`→type alias, `-include`→import; local/remote `mod:f`→calls, `fun f/1`/record usages→references; `-spec`/`-callback` type bodies mint no bogus refs; behaviour/gen_server/spawn MFA resolver bridges deferred | -| GDScript | `.gd` | Full tree-sitter | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`docs/godot.md`](godot.md) | -| Pascal | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx` | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path | -| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred | +| Language | Extensions | Extraction | Notes | +| ----------- | ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | Direct callable members of local/imported object-literal constants resolve by source containment. Root `tsconfig.json` / `jsconfig.json` / `tsconfig.base.json` aliases follow relative, absolute, and `node_modules` package `extends` chains (depth 32, cycle-safe); nearest config wins, `paths` replaces wholesale, and each `baseUrl` is resolved relative to its declaring config. | +| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | +| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | Direct callable object-literal members resolve like TypeScript. Extensionless imports consider `.xsjs` / `.xsjslib` after `.js` / `.jsx` / `.mjs` / `.cjs` and before index candidates, so ordinary JavaScript keeps collision precedence. | +| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | +| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | +| Python | `.py` `.pyw` | Full tree-sitter | Bare class names used as values resolve to `References` edges in return, assignment, pair, argument, and list positions. `import x as y` binds `y` to module `x`; `from p import x as y` prefers module `p.x` when that file exists, otherwise it remains a member import. Missing, duplicate, or ambiguous aliases stay unresolved and never fall through to a global bare-name guess. Tuple returns are not recursively traversed, and bare names never resolve to methods. | +| Go | `.go` | Full tree-sitter | | +| Rust | `.rs` | Full tree-sitter | Methods in generic, lifetime, reference, and qualified `impl` blocks belong to the implementing type; only trait impls emit `Implements`. Exact `self.field.method()` calls resolve through the owning struct field's declared project type, unwrapping only references and `Box` / `Rc` / `Arc`; containers, generics, external types, and ambiguity remain unresolved rather than guessed. | +| Java | `.java` | Full tree-sitter | | +| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C. C++ detection scans masked full source for ordinary `class/struct Derived : Base` forms while rejecting comments, strings, character literals, preprocessor text, bitfields, labels, and ternaries. | +| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` | Full tree-sitter | Class/struct inheritance supports access modifiers, `virtual`, qualified bases, and templated-base stripping; plain derived declarations in `.h` are detected by the C/C++ content classifier. | +| C# | `.cs` | Full tree-sitter | | +| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | +| Ruby | `.rb` `.rake` | Full tree-sitter | | +| Swift | `.swift` | Full tree-sitter | | +| Kotlin | `.kt` `.kts` | Full tree-sitter | Top-level functions, class methods, and extension functions include raw parameter-list signatures with optional declared return types; constructors, accessors, lambdas, and anonymous functions are not synthesized as callable nodes | +| Dart | `.dart` | Full tree-sitter | | +| Scala | `.scala` `.sc` | Full tree-sitter | | +| Lua | `.lua` | Full tree-sitter | `local f = function` emits one Function (not a duplicate Variable); `M.f = function`, string-key table assignments, and nested table constructor functions emit qualified Methods. Body calls belong to the synthesized callable; `f()`, `M.f()`, and `M:f()` resolve, while computed keys stay dynamic. | +| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | +| Objective-C | `.m` `.mm` | Full tree-sitter | | +| R | `.r` | Full tree-sitter | | +| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | +| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred | +| Terraform | `.tf` `.tfvars` `.tofu` | Full tree-sitter | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver deferred | +| Erlang | `.erl` `.hrl` | Full tree-sitter | Functions keep a bare display name and qualify as `module::function/arity`; clauses, exports, specs, local/remote calls, `fun`, static `gen_server` targets, and spawn/apply-style MFA lists are arity-aware. Binary-literal commas do not inflate arity. Missing or ambiguous arities remain unresolved; dynamic behaviour/resource wiring is still deferred. | +| GDScript | `.gd` | Full tree-sitter | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`docs/godot.md`](godot.md) | +| Pascal | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx` | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path | +| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred | --- diff --git a/docs/mcp.md b/docs/mcp.md index 4349a435..e143ed0a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -376,6 +376,13 @@ files. Events are debounced (default ~2 s; tunable via `CODEGRAPH_WATCH_DEBOUNCE_MS`) so a burst of saves triggers one incremental rebuild rather than many. The watcher is auto-disabled on WSL2 `/mnt/` drives where recursive watch is too slow; set `CODEGRAPH_FORCE_WATCH=1` to override. +The selected index root's `config.toml` and `codegraph.json`, plus the project +root `.gitignore`, are live control files: their events bypass ordinary +include/exclude filtering, reload the running scope, reconcile OS watch targets, +and trigger one full scan that supersedes queued path deltas. Invalid TOML keeps +the last valid scope and is reported; malformed JSON keeps the existing tolerant +empty-override semantics. Every later incremental sync re-checks the current +scope, preventing stale events from re-admitting excluded files. When the resolved root is exactly `$HOME` or the filesystem root (`/`), the server first disables the daemon, the file watcher, AND catch-up sync — not just the watcher. This happens when an IDE or agent (e.g. Kiro) launches diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index fabeb8b1..78dd475d 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -408,6 +408,10 @@ codegraph http list / stop # 管理运行中的 HTTP 服务 自定义扩展名映射写在 `.codegraph/codegraph.json`;可选的 Claude prompt hook 通过 `codegraph install --prompt-hook` 启用。 +运行中的 watcher 会热重载该文件、当前索引根的 `config.toml`,以及项目根 +`.gitignore`,并执行一次完整 reconcile:新排除的文件会被移出图谱,新纳入或新扩展名 +文件会被加入,无需重启 MCP 服务。TOML 损坏时保留最后一次有效 scope 并报告错误; +JSON 继续沿用宽容的空 override 语义。 完整参考——守护进程生命周期、所有环境变量、HTTP 注册表、扩展名映射、Claude hook: [`../cli.md`](../cli.md#daemon-watch--environment-variables)。 @@ -445,7 +449,7 @@ codegraph completions elvish --install # Elvish ## CodeGraph 的能力范围 -**做什么:** 确定性代码结构提取,支持 32 种语言(TypeScript、Python、Go、Rust、 +**做什么:** 确定性代码结构提取,支持 38 种语言(TypeScript、Python、Go、Rust、 Java、C/C++、C#、Vue、Svelte、GDScript 等——详见 [`../languages.md`](../languages.md)),跨文件解析(含 Godot 项目图),图遍历, FTS5 检索,全图导出(含确定性 PageRank 中心性),MCP/CLI 表面,golden 字节稳定输出。 @@ -455,6 +459,12 @@ CodeGraph 会记录稳定的文件错误,不保留该文件的部分节点、 索引项目中的其他文件。后续增量 `sync` 会删除该文件原有图谱,并与 `index --force` 收敛到相同结果。 +图谱正确性还覆盖:Erlang `module::function/arity` 身份、Rust 泛型/引用/qualified +`impl` 归属与经字段类型验证的 `self.field.method()`、JS/TS 对象字面量 namespace +成员、TypeScript/JavaScript 配置继承 alias、Python import alias、Lua function +expression callable、无扩展名导入解析到 `.xsjs`/`.xsjslib`,以及 `.h` 中普通派生 +C++ class/struct。精确静态分析边界见 [`../languages.md`](../languages.md)。 + **不做什么:** 二进制内部无任何 AI/向量/嵌入/LLM(硬约束,`scripts/guardrail.sh` 强制执行);无语义检索;不新增固定 `LANGUAGES` 集以外的语言。 @@ -462,18 +472,19 @@ CodeGraph 会记录稳定的文件错误,不保留该文件的部分节点、 ## 支持的语言 -CodeGraph 支持 **32 种语言**,按提取深度分为三个层级: +CodeGraph 支持 **38 种语言**,按提取深度分为三个层级: -**Tier 1 — 完整符号提取(23 种):** TypeScript、TSX、JavaScript、JSX、 +**Tier 1 — 完整符号提取(29 种):** TypeScript、TSX、JavaScript、JSX、ArkTS、 Python、Go、Rust、Java、C、C++、C#、PHP、Ruby、Swift、Kotlin、Dart、Scala、Lua、 -Luau、Objective-C、R、GDScript、Pascal。 +Luau、Objective-C、R、Solidity、Nix、Terraform、Erlang、CFML、GDScript、Pascal。 **Tier 2 — 嵌入式 / 模板提取(6 种):** Vue(`